From 98fa532135682c1f52b6fd09af272a66f25fbfb6 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 10 Sep 2024 14:13:50 -0400 Subject: [PATCH 001/123] fix(deps): update dependency fastapi-slim to v0.112.4 (#12545) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- machine-learning/poetry.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/machine-learning/poetry.lock b/machine-learning/poetry.lock index 43e3e0ae362f1..532ba1779e7b1 100644 --- a/machine-learning/poetry.lock +++ b/machine-learning/poetry.lock @@ -680,13 +680,13 @@ test = ["pytest (>=6)"] [[package]] name = "fastapi-slim" -version = "0.112.2" +version = "0.114.0" description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" optional = false python-versions = ">=3.8" files = [ - {file = "fastapi_slim-0.112.2-py3-none-any.whl", hash = "sha256:c023f74768f187af142c2fe5ff9e4ca3c4c1940bbde7df008cb283532422a23f"}, - {file = "fastapi_slim-0.112.2.tar.gz", hash = "sha256:75b8eb0c6ee05a20270da7a527ac7ad53b83414602f42b68f7027484dab3aedb"}, + {file = "fastapi_slim-0.114.0-py3-none-any.whl", hash = "sha256:83c8e95301c75c6575f7f6c4b885bf42a4c0b4a85e936e2faca25055470d0afe"}, + {file = "fastapi_slim-0.114.0.tar.gz", hash = "sha256:2299d5e0b8818f264725bd13dd91c80b904589be06c98c3d8115132576e5e2dd"}, ] [package.dependencies] From 9b528519e49772ca3e6f569f9dafb051d36a5dc4 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 10 Sep 2024 14:32:35 -0400 Subject: [PATCH 002/123] chore(deps): update dependency ruff to v0.6.4 (#12553) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- machine-learning/poetry.lock | 38 ++++++++++++++++++------------------ 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/machine-learning/poetry.lock b/machine-learning/poetry.lock index 532ba1779e7b1..20ffc6466aac6 100644 --- a/machine-learning/poetry.lock +++ b/machine-learning/poetry.lock @@ -2834,29 +2834,29 @@ jupyter = ["ipywidgets (>=7.5.1,<9)"] [[package]] name = "ruff" -version = "0.6.3" +version = "0.6.4" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" files = [ - {file = "ruff-0.6.3-py3-none-linux_armv6l.whl", hash = "sha256:97f58fda4e309382ad30ede7f30e2791d70dd29ea17f41970119f55bdb7a45c3"}, - {file = "ruff-0.6.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3b061e49b5cf3a297b4d1c27ac5587954ccb4ff601160d3d6b2f70b1622194dc"}, - {file = "ruff-0.6.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:34e2824a13bb8c668c71c1760a6ac7d795ccbd8d38ff4a0d8471fdb15de910b1"}, - {file = "ruff-0.6.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bddfbb8d63c460f4b4128b6a506e7052bad4d6f3ff607ebbb41b0aa19c2770d1"}, - {file = "ruff-0.6.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ced3eeb44df75353e08ab3b6a9e113b5f3f996bea48d4f7c027bc528ba87b672"}, - {file = "ruff-0.6.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:47021dff5445d549be954eb275156dfd7c37222acc1e8014311badcb9b4ec8c1"}, - {file = "ruff-0.6.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:7d7bd20dc07cebd68cc8bc7b3f5ada6d637f42d947c85264f94b0d1cd9d87384"}, - {file = "ruff-0.6.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:500f166d03fc6d0e61c8e40a3ff853fa8a43d938f5d14c183c612df1b0d6c58a"}, - {file = "ruff-0.6.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:42844ff678f9b976366b262fa2d1d1a3fe76f6e145bd92c84e27d172e3c34500"}, - {file = "ruff-0.6.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70452a10eb2d66549de8e75f89ae82462159855e983ddff91bc0bce6511d0470"}, - {file = "ruff-0.6.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:65a533235ed55f767d1fc62193a21cbf9e3329cf26d427b800fdeacfb77d296f"}, - {file = "ruff-0.6.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d2e2c23cef30dc3cbe9cc5d04f2899e7f5e478c40d2e0a633513ad081f7361b5"}, - {file = "ruff-0.6.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d8a136aa7d228975a6aee3dd8bea9b28e2b43e9444aa678fb62aeb1956ff2351"}, - {file = "ruff-0.6.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:f92fe93bc72e262b7b3f2bba9879897e2d58a989b4714ba6a5a7273e842ad2f8"}, - {file = "ruff-0.6.3-py3-none-win32.whl", hash = "sha256:7a62d3b5b0d7f9143d94893f8ba43aa5a5c51a0ffc4a401aa97a81ed76930521"}, - {file = "ruff-0.6.3-py3-none-win_amd64.whl", hash = "sha256:746af39356fee2b89aada06c7376e1aa274a23493d7016059c3a72e3b296befb"}, - {file = "ruff-0.6.3-py3-none-win_arm64.whl", hash = "sha256:14a9528a8b70ccc7a847637c29e56fd1f9183a9db743bbc5b8e0c4ad60592a82"}, - {file = "ruff-0.6.3.tar.gz", hash = "sha256:183b99e9edd1ef63be34a3b51fee0a9f4ab95add123dbf89a71f7b1f0c991983"}, + {file = "ruff-0.6.4-py3-none-linux_armv6l.whl", hash = "sha256:c4b153fc152af51855458e79e835fb6b933032921756cec9af7d0ba2aa01a258"}, + {file = "ruff-0.6.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:bedff9e4f004dad5f7f76a9d39c4ca98af526c9b1695068198b3bda8c085ef60"}, + {file = "ruff-0.6.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d02a4127a86de23002e694d7ff19f905c51e338c72d8e09b56bfb60e1681724f"}, + {file = "ruff-0.6.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7862f42fc1a4aca1ea3ffe8a11f67819d183a5693b228f0bb3a531f5e40336fc"}, + {file = "ruff-0.6.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eebe4ff1967c838a1a9618a5a59a3b0a00406f8d7eefee97c70411fefc353617"}, + {file = "ruff-0.6.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:932063a03bac394866683e15710c25b8690ccdca1cf192b9a98260332ca93408"}, + {file = "ruff-0.6.4-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:50e30b437cebef547bd5c3edf9ce81343e5dd7c737cb36ccb4fe83573f3d392e"}, + {file = "ruff-0.6.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c44536df7b93a587de690e124b89bd47306fddd59398a0fb12afd6133c7b3818"}, + {file = "ruff-0.6.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ea086601b22dc5e7693a78f3fcfc460cceabfdf3bdc36dc898792aba48fbad6"}, + {file = "ruff-0.6.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b52387d3289ccd227b62102c24714ed75fbba0b16ecc69a923a37e3b5e0aaaa"}, + {file = "ruff-0.6.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0308610470fcc82969082fc83c76c0d362f562e2f0cdab0586516f03a4e06ec6"}, + {file = "ruff-0.6.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:803b96dea21795a6c9d5bfa9e96127cc9c31a1987802ca68f35e5c95aed3fc0d"}, + {file = "ruff-0.6.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:66dbfea86b663baab8fcae56c59f190caba9398df1488164e2df53e216248baa"}, + {file = "ruff-0.6.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:34d5efad480193c046c86608dbba2bccdc1c5fd11950fb271f8086e0c763a5d1"}, + {file = "ruff-0.6.4-py3-none-win32.whl", hash = "sha256:f0f8968feea5ce3777c0d8365653d5e91c40c31a81d95824ba61d871a11b8523"}, + {file = "ruff-0.6.4-py3-none-win_amd64.whl", hash = "sha256:549daccee5227282289390b0222d0fbee0275d1db6d514550d65420053021a58"}, + {file = "ruff-0.6.4-py3-none-win_arm64.whl", hash = "sha256:ac4b75e898ed189b3708c9ab3fc70b79a433219e1e87193b4f2b77251d058d14"}, + {file = "ruff-0.6.4.tar.gz", hash = "sha256:ac3b5bfbee99973f80aa1b7cbd1c9cbce200883bdd067300c22a6cc1c7fba212"}, ] [[package]] From 233372303b7ce2a7edffb534c447a8621ce449a8 Mon Sep 17 00:00:00 2001 From: Jonathan Jogenfors Date: Wed, 11 Sep 2024 16:40:52 +0200 Subject: [PATCH 003/123] feat(server): default exclusion patterns (#12566) * Add default exclusion patterns * simplify * fix tests --- e2e/src/api/specs/library.e2e-spec.ts | 4 ++-- server/src/services/library.service.spec.ts | 6 +++--- server/src/services/library.service.ts | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/e2e/src/api/specs/library.e2e-spec.ts b/e2e/src/api/specs/library.e2e-spec.ts index 2f6274d1fca4d..8d98e866301f8 100644 --- a/e2e/src/api/specs/library.e2e-spec.ts +++ b/e2e/src/api/specs/library.e2e-spec.ts @@ -83,7 +83,7 @@ describe('/libraries', () => { refreshedAt: null, assetCount: 0, importPaths: [], - exclusionPatterns: [], + exclusionPatterns: expect.any(Array), }), ); }); @@ -270,7 +270,7 @@ describe('/libraries', () => { refreshedAt: null, assetCount: 0, importPaths: [], - exclusionPatterns: [], + exclusionPatterns: expect.any(Array), }), ); }); diff --git a/server/src/services/library.service.spec.ts b/server/src/services/library.service.spec.ts index 2d4e1d5776bfe..36bdfd05dc1db 100644 --- a/server/src/services/library.service.spec.ts +++ b/server/src/services/library.service.spec.ts @@ -892,7 +892,7 @@ describe(LibraryService.name, () => { expect.objectContaining({ name: expect.any(String), importPaths: [], - exclusionPatterns: [], + exclusionPatterns: expect.any(Array), }), ); }); @@ -917,7 +917,7 @@ describe(LibraryService.name, () => { expect.objectContaining({ name: 'My Awesome Library', importPaths: [], - exclusionPatterns: [], + exclusionPatterns: expect.any(Array), }), ); }); @@ -947,7 +947,7 @@ describe(LibraryService.name, () => { expect.objectContaining({ name: expect.any(String), importPaths: ['/data/images', '/data/videos'], - exclusionPatterns: [], + exclusionPatterns: expect.any(Array), }), ); }); diff --git a/server/src/services/library.service.ts b/server/src/services/library.service.ts index 2aa0df402a373..3dd81dd61377b 100644 --- a/server/src/services/library.service.ts +++ b/server/src/services/library.service.ts @@ -245,7 +245,7 @@ export class LibraryService { ownerId: dto.ownerId, name: dto.name ?? 'New External Library', importPaths: dto.importPaths ?? [], - exclusionPatterns: dto.exclusionPatterns ?? [], + exclusionPatterns: dto.exclusionPatterns ?? ['**/@eaDir/**', '**/._*'], }); return mapLibrary(library); } From 01c7adc24d8df7b93ac081fd81040515bd3452b5 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Wed, 11 Sep 2024 16:26:29 -0400 Subject: [PATCH 004/123] feat(web): unlink live photos (#12574) feat(web): unlink live photo --- e2e/src/api/specs/asset.e2e-spec.ts | 10 ++++ .../openapi/lib/model/update_asset_dto.dart | 6 -- open-api/immich-openapi-specs.json | 1 + open-api/typescript-sdk/src/fetch-client.ts | 2 +- server/src/dtos/asset.dto.ts | 4 +- server/src/interfaces/event.interface.ts | 1 + server/src/interfaces/job.interface.ts | 1 + server/src/services/asset.service.ts | 29 ++++++++-- server/src/services/job.service.ts | 2 +- server/src/services/notification.service.ts | 5 ++ server/src/utils/asset.util.ts | 29 +++++++++- .../actions/link-live-photo-action.svelte | 55 +++++++++++++++---- web/src/lib/i18n/en.json | 3 + web/src/lib/utils/actions.ts | 3 +- .../(user)/photos/[[assetId=id]]/+page.svelte | 34 +++++++++--- 15 files changed, 148 insertions(+), 37 deletions(-) diff --git a/e2e/src/api/specs/asset.e2e-spec.ts b/e2e/src/api/specs/asset.e2e-spec.ts index e065e60c993d8..e0281085cf1f0 100644 --- a/e2e/src/api/specs/asset.e2e-spec.ts +++ b/e2e/src/api/specs/asset.e2e-spec.ts @@ -577,6 +577,16 @@ describe('/asset', () => { expect(body).toMatchObject({ id: user1Assets[0].id, livePhotoVideoId: asset.id }); }); + it('should unlink a motion photo', async () => { + const { status, body } = await request(app) + .put(`/assets/${user1Assets[0].id}`) + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ livePhotoVideoId: null }); + + expect(status).toEqual(200); + expect(body).toMatchObject({ id: user1Assets[0].id, livePhotoVideoId: null }); + }); + it('should update date time original when sidecar file contains DateTimeOriginal', async () => { const sidecarData = ` diff --git a/mobile/openapi/lib/model/update_asset_dto.dart b/mobile/openapi/lib/model/update_asset_dto.dart index 6e5be5683f484..9aa413d24221e 100644 --- a/mobile/openapi/lib/model/update_asset_dto.dart +++ b/mobile/openapi/lib/model/update_asset_dto.dart @@ -63,12 +63,6 @@ class UpdateAssetDto { /// num? latitude; - /// - /// Please note: This property should have been non-nullable! Since the specification file - /// does not include a default value (using the "default:" property), however, the generated - /// source code must fall back to having a nullable type. - /// Consider adding a "default:" property in the specification file to hide this note. - /// String? livePhotoVideoId; /// diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index b80bb52a11383..77c2ab127f87a 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -12243,6 +12243,7 @@ }, "livePhotoVideoId": { "format": "uuid", + "nullable": true, "type": "string" }, "longitude": { diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 7cf4d48eda66b..021551d947a52 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -427,7 +427,7 @@ export type UpdateAssetDto = { isArchived?: boolean; isFavorite?: boolean; latitude?: number; - livePhotoVideoId?: string; + livePhotoVideoId?: string | null; longitude?: number; rating?: number; }; diff --git a/server/src/dtos/asset.dto.ts b/server/src/dtos/asset.dto.ts index 02ea2c69a990e..703b1ccfe3225 100644 --- a/server/src/dtos/asset.dto.ts +++ b/server/src/dtos/asset.dto.ts @@ -69,8 +69,8 @@ export class UpdateAssetDto extends UpdateAssetBase { @IsString() description?: string; - @ValidateUUID({ optional: true }) - livePhotoVideoId?: string; + @ValidateUUID({ optional: true, nullable: true }) + livePhotoVideoId?: string | null; } export class RandomAssetsDto { diff --git a/server/src/interfaces/event.interface.ts b/server/src/interfaces/event.interface.ts index 61233a8001eb3..0cd0207155853 100644 --- a/server/src/interfaces/event.interface.ts +++ b/server/src/interfaces/event.interface.ts @@ -21,6 +21,7 @@ type EmitEventMap = { 'asset.tag': [{ assetId: string }]; 'asset.untag': [{ assetId: string }]; 'asset.hide': [{ assetId: string; userId: string }]; + 'asset.show': [{ assetId: string; userId: string }]; // session events 'session.delete': [{ sessionId: string }]; diff --git a/server/src/interfaces/job.interface.ts b/server/src/interfaces/job.interface.ts index bc780398eaf05..a0533fa63f9c0 100644 --- a/server/src/interfaces/job.interface.ts +++ b/server/src/interfaces/job.interface.ts @@ -120,6 +120,7 @@ export interface IBaseJob { export interface IEntityJob extends IBaseJob { id: string; source?: 'upload' | 'sidecar-write' | 'copy'; + notify?: boolean; } export interface IAssetDeleteJob extends IEntityJob { diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index ecc9a135759da..1d8d7d05d328f 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -39,7 +39,7 @@ import { IStackRepository } from 'src/interfaces/stack.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; import { requireAccess } from 'src/utils/access'; -import { getAssetFiles, getMyPartnerIds, onBeforeLink } from 'src/utils/asset.util'; +import { getAssetFiles, getMyPartnerIds, onAfterUnlink, onBeforeLink, onBeforeUnlink } from 'src/utils/asset.util'; import { usePagination } from 'src/utils/pagination'; export class AssetService { @@ -159,17 +159,26 @@ export class AssetService { await requireAccess(this.access, { auth, permission: Permission.ASSET_UPDATE, ids: [id] }); const { description, dateTimeOriginal, latitude, longitude, rating, ...rest } = dto; + const repos = { asset: this.assetRepository, event: this.eventRepository }; + let previousMotion: AssetEntity | null = null; if (rest.livePhotoVideoId) { - await onBeforeLink( - { asset: this.assetRepository, event: this.eventRepository }, - { userId: auth.user.id, livePhotoVideoId: rest.livePhotoVideoId }, - ); + await onBeforeLink(repos, { userId: auth.user.id, livePhotoVideoId: rest.livePhotoVideoId }); + } else if (rest.livePhotoVideoId === null) { + const asset = await this.findOrFail(id); + if (asset.livePhotoVideoId) { + previousMotion = await onBeforeUnlink(repos, { livePhotoVideoId: asset.livePhotoVideoId }); + } } await this.updateMetadata({ id, description, dateTimeOriginal, latitude, longitude, rating }); await this.assetRepository.update({ id, ...rest }); + + if (previousMotion) { + await onAfterUnlink(repos, { userId: auth.user.id, livePhotoVideoId: previousMotion.id }); + } + const asset = await this.assetRepository.getById(id, { exifInfo: true, owner: true, @@ -180,9 +189,11 @@ export class AssetService { }, files: true, }); + if (!asset) { throw new BadRequestException('Asset not found'); } + return mapAsset(asset, { auth }); } @@ -326,6 +337,14 @@ export class AssetService { await this.jobRepository.queueAll(jobs); } + private async findOrFail(id: string) { + const asset = await this.assetRepository.getById(id); + if (!asset) { + throw new BadRequestException('Asset not found'); + } + return asset; + } + private async updateMetadata(dto: ISidecarWriteJob) { const { id, description, dateTimeOriginal, latitude, longitude, rating } = dto; const writes = _.omitBy({ description, dateTimeOriginal, latitude, longitude, rating }, _.isUndefined); diff --git a/server/src/services/job.service.ts b/server/src/services/job.service.ts index aa84ef4f40957..58eba6245b7f9 100644 --- a/server/src/services/job.service.ts +++ b/server/src/services/job.service.ts @@ -289,7 +289,7 @@ export class JobService { } case JobName.GENERATE_THUMBNAIL: { - if (item.data.source !== 'upload') { + if (!(item.data.notify || item.data.source === 'upload')) { break; } diff --git a/server/src/services/notification.service.ts b/server/src/services/notification.service.ts index b1c862dc1225c..01da235bf0086 100644 --- a/server/src/services/notification.service.ts +++ b/server/src/services/notification.service.ts @@ -64,6 +64,11 @@ export class NotificationService { this.eventRepository.clientSend(ClientEvent.ASSET_HIDDEN, userId, assetId); } + @OnEmit({ event: 'asset.show' }) + async onAssetShow({ assetId }: ArgOf<'asset.show'>) { + await this.jobRepository.queue({ name: JobName.GENERATE_THUMBNAIL, data: { id: assetId, notify: true } }); + } + @OnEmit({ event: 'user.signup' }) async onUserSignup({ notify, id, tempPassword }: ArgOf<'user.signup'>) { if (notify) { diff --git a/server/src/utils/asset.util.ts b/server/src/utils/asset.util.ts index f2a03a9dcb159..44c291e139766 100644 --- a/server/src/utils/asset.util.ts +++ b/server/src/utils/asset.util.ts @@ -1,4 +1,5 @@ import { BadRequestException } from '@nestjs/common'; +import { StorageCore } from 'src/cores/storage.core'; import { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { AssetFileEntity } from 'src/entities/asset-files.entity'; @@ -134,8 +135,10 @@ export const getMyPartnerIds = async ({ userId, repository, timelineEnabled }: P return [...partnerIds]; }; +export type AssetHookRepositories = { asset: IAssetRepository; event: IEventRepository }; + export const onBeforeLink = async ( - { asset: assetRepository, event: eventRepository }: { asset: IAssetRepository; event: IEventRepository }, + { asset: assetRepository, event: eventRepository }: AssetHookRepositories, { userId, livePhotoVideoId }: { userId: string; livePhotoVideoId: string }, ) => { const motionAsset = await assetRepository.getById(livePhotoVideoId); @@ -154,3 +157,27 @@ export const onBeforeLink = async ( await eventRepository.emit('asset.hide', { assetId: motionAsset.id, userId }); } }; + +export const onBeforeUnlink = async ( + { asset: assetRepository }: AssetHookRepositories, + { livePhotoVideoId }: { livePhotoVideoId: string }, +) => { + const motion = await assetRepository.getById(livePhotoVideoId); + if (!motion) { + return null; + } + + if (StorageCore.isAndroidMotionPath(motion.originalPath)) { + throw new BadRequestException('Cannot unlink Android motion photos'); + } + + return motion; +}; + +export const onAfterUnlink = async ( + { asset: assetRepository, event: eventRepository }: AssetHookRepositories, + { userId, livePhotoVideoId }: { userId: string; livePhotoVideoId: string }, +) => { + await assetRepository.update({ id: livePhotoVideoId, isVisible: true }); + await eventRepository.emit('asset.show', { assetId: livePhotoVideoId, userId }); +}; diff --git a/web/src/lib/components/photos-page/actions/link-live-photo-action.svelte b/web/src/lib/components/photos-page/actions/link-live-photo-action.svelte index fa33b7d5ccd11..24107b9f88c2e 100644 --- a/web/src/lib/components/photos-page/actions/link-live-photo-action.svelte +++ b/web/src/lib/components/photos-page/actions/link-live-photo-action.svelte @@ -1,44 +1,75 @@ {#if menuItem} - + {/if} {#if !menuItem} {#if loading} {:else} - + {/if} {/if} diff --git a/web/src/lib/i18n/en.json b/web/src/lib/i18n/en.json index dbd6f32fde7c5..e27cc54d52156 100644 --- a/web/src/lib/i18n/en.json +++ b/web/src/lib/i18n/en.json @@ -641,6 +641,7 @@ "unable_to_get_comments_number": "Unable to get number of comments", "unable_to_get_shared_link": "Failed to get shared link", "unable_to_hide_person": "Unable to hide person", + "unable_to_link_motion_video": "Unable to link motion video", "unable_to_link_oauth_account": "Unable to link OAuth account", "unable_to_load_album": "Unable to load album", "unable_to_load_asset_activity": "Unable to load asset activity", @@ -679,6 +680,7 @@ "unable_to_submit_job": "Unable to submit job", "unable_to_trash_asset": "Unable to trash asset", "unable_to_unlink_account": "Unable to unlink account", + "unable_to_unlink_motion_video": "Unable to unlink motion video", "unable_to_update_album_cover": "Unable to update album cover", "unable_to_update_album_info": "Unable to update album info", "unable_to_update_library": "Unable to update library", @@ -1219,6 +1221,7 @@ "unknown": "Unknown", "unknown_year": "Unknown Year", "unlimited": "Unlimited", + "unlink_motion_video": "Unlink motion video", "unlink_oauth": "Unlink OAuth", "unlinked_oauth_account": "Unlinked OAuth account", "unnamed_album": "Unnamed Album", diff --git a/web/src/lib/utils/actions.ts b/web/src/lib/utils/actions.ts index f1772c200e15a..291d3926ee151 100644 --- a/web/src/lib/utils/actions.ts +++ b/web/src/lib/utils/actions.ts @@ -6,7 +6,8 @@ import { handleError } from './handle-error'; export type OnDelete = (assetIds: string[]) => void; export type OnRestore = (ids: string[]) => void; -export type OnLink = (asset: AssetResponseDto) => void; +export type OnLink = (assets: { still: AssetResponseDto; motion: AssetResponseDto }) => void; +export type OnUnlink = (assets: { still: AssetResponseDto; motion: AssetResponseDto }) => void; export type OnArchive = (ids: string[], isArchived: boolean) => void; export type OnFavorite = (ids: string[], favorite: boolean) => void; export type OnStack = (ids: string[]) => void; diff --git a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte index a1131ecfbb16c..4649da8205120 100644 --- a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte @@ -23,8 +23,9 @@ import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { AssetStore } from '$lib/stores/assets.store'; import { preferences, user } from '$lib/stores/user.store'; + import type { OnLink, OnUnlink } from '$lib/utils/actions'; import { openFileUploadDialog } from '$lib/utils/file-uploader'; - import { AssetTypeEnum, type AssetResponseDto } from '@immich/sdk'; + import { AssetTypeEnum } from '@immich/sdk'; import { mdiDotsVertical, mdiPlus } from '@mdi/js'; import { onDestroy } from 'svelte'; import { t } from 'svelte-i18n'; @@ -35,12 +36,21 @@ const { isMultiSelectState, selectedAssets } = assetInteractionStore; let isAllFavorite: boolean; + let isAllOwned: boolean; let isAssetStackSelected: boolean; + let isLinkActionAvailable: boolean; $: { const selection = [...$selectedAssets]; + isAllOwned = selection.every((asset) => asset.ownerId === $user.id); isAllFavorite = selection.every((asset) => asset.isFavorite); isAssetStackSelected = selection.length === 1 && !!selection[0].stack; + const isLivePhoto = selection.length === 1 && !!selection[0].livePhotoVideoId; + const isLivePhotoCandidate = + selection.length === 2 && + selection.some((asset) => asset.type === AssetTypeEnum.Image) && + selection.some((asset) => asset.type === AssetTypeEnum.Image); + isLinkActionAvailable = isAllOwned && (isLivePhoto || isLivePhotoCandidate); } const handleEscape = () => { @@ -53,11 +63,14 @@ } }; - const handleLink = (asset: AssetResponseDto) => { - if (asset.livePhotoVideoId) { - assetStore.removeAssets([asset.livePhotoVideoId]); - } - assetStore.updateAssets([asset]); + const handleLink: OnLink = ({ still, motion }) => { + assetStore.removeAssets([motion.id]); + assetStore.updateAssets([still]); + }; + + const handleUnlink: OnUnlink = ({ still, motion }) => { + assetStore.addAssets([motion]); + assetStore.updateAssets([still]); }; onDestroy(() => { @@ -87,8 +100,13 @@ onUnstack={(assets) => assetStore.addAssets(assets)} /> {/if} - {#if $selectedAssets.size === 2 && [...$selectedAssets].some((asset) => asset.type === AssetTypeEnum.Image && [...$selectedAssets].some((asset) => asset.type === AssetTypeEnum.Video))} - + {#if isLinkActionAvailable} + {/if} From ad58d7e23edd993a8920c48e50932508b7be1d59 Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Wed, 11 Sep 2024 20:25:57 -0400 Subject: [PATCH 005/123] chore(ml): downgrade to cuda 12.2 (#12587) * downgrade to cuda 12.2 * update docs --- docs/docs/features/ml-hardware-acceleration.md | 2 +- machine-learning/Dockerfile | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/docs/features/ml-hardware-acceleration.md b/docs/docs/features/ml-hardware-acceleration.md index b20c3fc2b6315..9f2d33cc35d7c 100644 --- a/docs/docs/features/ml-hardware-acceleration.md +++ b/docs/docs/features/ml-hardware-acceleration.md @@ -38,7 +38,7 @@ You do not need to redo any machine learning jobs after enabling hardware accele - The GPU must have compute capability 5.2 or greater. - The server must have the official NVIDIA driver installed. -- The installed driver must be >= 545 (it must support CUDA 12.3.2). +- The installed driver must be >= 535 (it must support CUDA 12.2). - On Linux (except for WSL2), you also need to have [NVIDIA Container Toolkit][nvct] installed. #### OpenVINO diff --git a/machine-learning/Dockerfile b/machine-learning/Dockerfile index baeefbf0d8064..e49fde1464831 100644 --- a/machine-learning/Dockerfile +++ b/machine-learning/Dockerfile @@ -49,7 +49,12 @@ RUN apt-get update && \ apt-get remove wget -yqq && \ rm -rf /var/lib/apt/lists/* -FROM nvidia/cuda:12.3.2-cudnn9-runtime-ubuntu22.04@sha256:fa44193567d1908f7ca1f3abf8623ce9c63bc8cba7bcfdb32702eb04d326f7a8 AS prod-cuda +FROM nvidia/cuda:12.2.2-runtime-ubuntu22.04@sha256:94c1577b2cd9dd6c0312dc04dff9cb2fdce2b268018abc3d7c2dbcacf1155000 AS prod-cuda + +RUN apt-get update && \ + apt-get install --no-install-recommends -yqq libcudnn9-cuda-12 && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* COPY --from=builder-cuda /usr/local/bin/python3 /usr/local/bin/python3 COPY --from=builder-cuda /usr/local/lib/python3.11 /usr/local/lib/python3.11 From b2c5a90af78d647627b204debf3fb9b05eae93aa Mon Sep 17 00:00:00 2001 From: Pavel Sapachev Date: Thu, 12 Sep 2024 04:23:23 +0300 Subject: [PATCH 006/123] docs: proper value of word-based suggestions setting to setup VSCode (#12586) --- docs/docs/developer/setup.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/developer/setup.md b/docs/docs/developer/setup.md index 5f8d442aa85e8..32e79849efdcf 100644 --- a/docs/docs/developer/setup.md +++ b/docs/docs/developer/setup.md @@ -106,7 +106,7 @@ in User `settings.json` (`cmd + shift + p` and search for `Open User Settings JS "editor.suggest.snippetsPreventQuickSuggestions": false, "editor.suggestSelection": "first", "editor.tabCompletion": "onlySnippets", - "editor.wordBasedSuggestions": false, + "editor.wordBasedSuggestions": "off", "editor.defaultFormatter": "Dart-Code.dart-code" } } From 1593eaf6fc399a092e91babb5a890198d76071ea Mon Sep 17 00:00:00 2001 From: Matthew Momjian <50788000+mmomjian@users.noreply.github.com> Date: Wed, 11 Sep 2024 21:27:40 -0400 Subject: [PATCH 007/123] docs: add server backup to First Steps (#12555) * prompt for backups on setup * add file * case, update backup restore * Update backup-and-restore.md * Update backup-and-restore.md * Update backup-and-restore.md * Update backup-and-restore.md * Update post-install.mdx --- .../docs/administration/backup-and-restore.md | 22 ++++++++++++------- docs/docs/install/post-install.mdx | 7 +++++- docs/docs/partials/_server-backup.md | 2 ++ 3 files changed, 22 insertions(+), 9 deletions(-) create mode 100644 docs/docs/partials/_server-backup.md diff --git a/docs/docs/administration/backup-and-restore.md b/docs/docs/administration/backup-and-restore.md index 3d226dd0615df..860b1e1ce7426 100644 --- a/docs/docs/administration/backup-and-restore.md +++ b/docs/docs/administration/backup-and-restore.md @@ -21,6 +21,8 @@ The recommended way to backup and restore the Immich database is to use the `pg_ It is not recommended to directly backup the `DB_DATA_LOCATION` folder. Doing so while the database is running can lead to a corrupted backup that cannot be restored. ::: +### Manual Backup and Restore + @@ -29,10 +31,11 @@ docker exec -t immich_postgres pg_dumpall --clean --if-exists --username=postgre ``` ```bash title='Restore' -docker compose down -v # CAUTION! Deletes all Immich data to start from scratch. -# rm -rf DB_DATA_LOCATION # CAUTION! Deletes all Immich data to start from scratch. +docker compose down -v # CAUTION! Deletes all Immich data to start from scratch +## Uncomment the next line and replace DB_DATA_LOCATION with your Postgres path to permanently reset the Postgres database +# rm -rf DB_DATA_LOCATION # CAUTION! Deletes all Immich data to start from scratch docker compose pull # Update to latest version of Immich (if desired) -docker compose create # Create Docker containers for Immich apps without running them. +docker compose create # Create Docker containers for Immich apps without running them docker start immich_postgres # Start Postgres server sleep 10 # Wait for Postgres server to start up gunzip < "/path/to/backup/dump.sql.gz" \ @@ -49,10 +52,11 @@ docker exec -t immich_postgres pg_dumpall --clean --if-exists --username=postgre ``` ```powershell title='Restore' -docker compose down -v # CAUTION! Deletes all Immich data to start from scratch. -# Remove-Item -Recurse -Force DB_DATA_LOCATION # CAUTION! Deletes all Immich data to start from scratch. +docker compose down -v # CAUTION! Deletes all Immich data to start from scratch +## Uncomment the next line and replace DB_DATA_LOCATION with your Postgres path to permanently reset the Postgres database +# Remove-Item -Recurse -Force DB_DATA_LOCATION # CAUTION! Deletes all Immich data to start from scratch docker compose pull # Update to latest version of Immich (if desired) -docker compose create # Create Docker containers for Immich apps without running them. +docker compose create # Create Docker containers for Immich apps without running them docker start immich_postgres # Start Postgres server sleep 10 # Wait for Postgres server to start up gc "C:\path\to\backup\dump.sql" | docker exec -i immich_postgres psql --username=postgres # Restore Backup @@ -68,6 +72,8 @@ Note that for the database restore to proceed properly, it requires a completely Some deployment methods make it difficult to start the database without also starting the server or microservices. In these cases, you may set the environmental variable `DB_SKIP_MIGRATIONS=true` before starting the services. This will prevent the server from running migrations that interfere with the restore process. Note that both the server and microservices must have this variable set to prevent the migrations from running. Be sure to remove this variable and restart the services after the database is restored. ::: +### Automatic Database Backups + The database dumps can also be automated (using [this image](https://github.com/prodrigestivill/docker-postgres-backup-local)) by editing the docker compose file to match the following: ```yaml @@ -157,7 +163,7 @@ for more info read the [release notes](https://github.com/immich-app/immich/rele - The Immich database containing all the information to allow the system to function properly. **Note:** This folder will only appear to users who have made the changes mentioned in [v1.102.0](https://github.com/immich-app/immich/discussions/8930) (an optional, non-mandatory change) or who started with this version. - - Stored in `UPLOAD_LOCATION/postgres`. + - Stored in `DB_DATA_LOCATION`. :::danger A backup of this folder does not constitute a backup of your database! @@ -203,7 +209,7 @@ When you turn off the storage template engine, it will leave the assets in `UPLO - The Immich database containing all the information to allow the system to function properly. **Note:** This folder will only appear to users who have made the changes mentioned in [v1.102.0](https://github.com/immich-app/immich/discussions/8930) (an optional, non-mandatory change) or who started with this version. - - Stored in `UPLOAD_LOCATION/postgres`. + - Stored in `DB_DATA_LOCATION`. :::danger A backup of this folder does not constitute a backup of your database! diff --git a/docs/docs/install/post-install.mdx b/docs/docs/install/post-install.mdx index ba8aa2e9a35a6..bc1ee80b47d11 100644 --- a/docs/docs/install/post-install.mdx +++ b/docs/docs/install/post-install.mdx @@ -8,6 +8,7 @@ import StorageTemplate from '/docs/partials/_storage-template.md'; import MobileAppDownload from '/docs/partials/_mobile-app-download.md'; import MobileAppLogin from '/docs/partials/_mobile-app-login.md'; import MobileAppBackup from '/docs/partials/_mobile-app-backup.md'; +import ServerBackup from '/docs/partials/_server-backup.md'; # Post Install Steps @@ -33,6 +34,10 @@ A list of common steps to take after installing Immich include: -## Step 6 - Backup Your Library +## Step 6 - Upload Your Library + +## Step 7 - Setup Server Backups + + diff --git a/docs/docs/partials/_server-backup.md b/docs/docs/partials/_server-backup.md new file mode 100644 index 0000000000000..7aab90a753c24 --- /dev/null +++ b/docs/docs/partials/_server-backup.md @@ -0,0 +1,2 @@ +Now that you have imported some pictures, you should setup server backups to preserve your memories. +You can do so by following our [backup guide](/docs/administration/backup-and-restore.md). From d489813a885b1ec09ccf2412594c4590c470152c Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 11 Sep 2024 21:28:27 -0400 Subject: [PATCH 008/123] chore(deps): update base-image to v20240910 (major) (#12546) chore(deps): update base-image to v20240910 Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- server/Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/Dockerfile b/server/Dockerfile index 04a495e090a53..ec38d8f9ce607 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -1,5 +1,5 @@ # dev build -FROM ghcr.io/immich-app/base-server-dev:20240903@sha256:ca18e2805ec8ddcf0ac7734a6eaf6d9a08bd3a14218bf0dbdbe865d83117190f AS dev +FROM ghcr.io/immich-app/base-server-dev:20240910@sha256:3fd455fe051bef63b1440753596e2afa34ff0513fe30aa71a5b76ebb2d751e9f AS dev RUN apt-get install --no-install-recommends -yqq tini WORKDIR /usr/src/app @@ -41,7 +41,7 @@ RUN npm run build # prod build -FROM ghcr.io/immich-app/base-server-prod:20240903@sha256:d0d170ceeee7ef6c7b62b5d927820d74c14a9893f3e6285c1b9df45b33951b09 +FROM ghcr.io/immich-app/base-server-prod:20240910@sha256:4e03fe801b74eede63e91d2d9bce3a7b05699f536c211391f2d82a83c1f63470 WORKDIR /usr/src/app ENV NODE_ENV=production \ From 95987c9777dca3098b9a4d49df091d8d7e71a90d Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 11 Sep 2024 21:30:05 -0400 Subject: [PATCH 009/123] chore(deps): update node (#12528) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- cli/Dockerfile | 2 +- cli/package-lock.json | 10 +++++----- cli/package.json | 2 +- e2e/package-lock.json | 12 ++++++------ e2e/package.json | 2 +- open-api/typescript-sdk/package-lock.json | 8 ++++---- open-api/typescript-sdk/package.json | 2 +- server/Dockerfile | 2 +- server/package-lock.json | 14 +++++++------- server/package.json | 2 +- web/Dockerfile | 2 +- 11 files changed, 29 insertions(+), 29 deletions(-) diff --git a/cli/Dockerfile b/cli/Dockerfile index e3cce6d448249..b08aba9d3c2b5 100644 --- a/cli/Dockerfile +++ b/cli/Dockerfile @@ -1,4 +1,4 @@ -FROM node:20.17.0-alpine3.20@sha256:1a526b97cace6b4006256570efa1a29cd1fe4b96a5301f8d48e87c5139438a45 AS core +FROM node:20.17.0-alpine3.20@sha256:2d07db07a2df6830718ae2a47db6fedce6745f5bcd174c398f2acdda90a11c03 AS core WORKDIR /usr/src/open-api/typescript-sdk COPY open-api/typescript-sdk/package*.json open-api/typescript-sdk/tsconfig*.json ./ diff --git a/cli/package-lock.json b/cli/package-lock.json index f443c141b9e06..15a7ccd185122 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -24,7 +24,7 @@ "@types/cli-progress": "^3.11.0", "@types/lodash-es": "^4.17.12", "@types/mock-fs": "^4.13.1", - "@types/node": "^20.16.2", + "@types/node": "^20.16.5", "@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/parser": "^8.0.0", "@vitest/coverage-v8": "^2.0.5", @@ -59,7 +59,7 @@ "@oazapfts/runtime": "^1.0.2" }, "devDependencies": { - "@types/node": "^20.16.2", + "@types/node": "^20.16.5", "typescript": "^5.3.3" } }, @@ -1324,9 +1324,9 @@ } }, "node_modules/@types/node": { - "version": "20.16.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.3.tgz", - "integrity": "sha512-/wdGiWRkMOm53gAsSyFMXFZHbVg7C6CbkrzHNpaHoYfsUWPg7m6ZRKtvQjgvQ9i8WT540a3ydRlRQbxjY30XxQ==", + "version": "20.16.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.5.tgz", + "integrity": "sha512-VwYCweNo3ERajwy0IUlqqcyZ8/A7Zwa9ZP3MnENWcB11AejO+tLy3pu850goUW2FC/IJMdZUfKpX/yxL1gymCA==", "dev": true, "license": "MIT", "dependencies": { diff --git a/cli/package.json b/cli/package.json index 0d560c8456585..34424f2957cec 100644 --- a/cli/package.json +++ b/cli/package.json @@ -20,7 +20,7 @@ "@types/cli-progress": "^3.11.0", "@types/lodash-es": "^4.17.12", "@types/mock-fs": "^4.13.1", - "@types/node": "^20.16.2", + "@types/node": "^20.16.5", "@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/parser": "^8.0.0", "@vitest/coverage-v8": "^2.0.5", diff --git a/e2e/package-lock.json b/e2e/package-lock.json index 97e396c09f1b0..575388b1f6284 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -15,7 +15,7 @@ "@immich/sdk": "file:../open-api/typescript-sdk", "@playwright/test": "^1.44.1", "@types/luxon": "^3.4.2", - "@types/node": "^20.16.2", + "@types/node": "^20.16.5", "@types/oidc-provider": "^8.5.1", "@types/pg": "^8.11.0", "@types/pngjs": "^6.0.4", @@ -64,7 +64,7 @@ "@types/cli-progress": "^3.11.0", "@types/lodash-es": "^4.17.12", "@types/mock-fs": "^4.13.1", - "@types/node": "^20.16.2", + "@types/node": "^20.16.5", "@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/parser": "^8.0.0", "@vitest/coverage-v8": "^2.0.5", @@ -99,7 +99,7 @@ "@oazapfts/runtime": "^1.0.2" }, "devDependencies": { - "@types/node": "^20.16.2", + "@types/node": "^20.16.5", "typescript": "^5.3.3" } }, @@ -1516,9 +1516,9 @@ "dev": true }, "node_modules/@types/node": { - "version": "20.16.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.3.tgz", - "integrity": "sha512-/wdGiWRkMOm53gAsSyFMXFZHbVg7C6CbkrzHNpaHoYfsUWPg7m6ZRKtvQjgvQ9i8WT540a3ydRlRQbxjY30XxQ==", + "version": "20.16.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.5.tgz", + "integrity": "sha512-VwYCweNo3ERajwy0IUlqqcyZ8/A7Zwa9ZP3MnENWcB11AejO+tLy3pu850goUW2FC/IJMdZUfKpX/yxL1gymCA==", "dev": true, "license": "MIT", "dependencies": { diff --git a/e2e/package.json b/e2e/package.json index 3577bc4510a9e..6e98431aaac0f 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -25,7 +25,7 @@ "@immich/sdk": "file:../open-api/typescript-sdk", "@playwright/test": "^1.44.1", "@types/luxon": "^3.4.2", - "@types/node": "^20.16.2", + "@types/node": "^20.16.5", "@types/oidc-provider": "^8.5.1", "@types/pg": "^8.11.0", "@types/pngjs": "^6.0.4", diff --git a/open-api/typescript-sdk/package-lock.json b/open-api/typescript-sdk/package-lock.json index 6d5b78ee9a588..da50429cc52e7 100644 --- a/open-api/typescript-sdk/package-lock.json +++ b/open-api/typescript-sdk/package-lock.json @@ -12,7 +12,7 @@ "@oazapfts/runtime": "^1.0.2" }, "devDependencies": { - "@types/node": "^20.16.2", + "@types/node": "^20.16.5", "typescript": "^5.3.3" } }, @@ -22,9 +22,9 @@ "integrity": "sha512-8tKiYffhwTGHSHYGnZ3oneLGCjX0po/XAXQ5Ng9fqKkvIdl/xz8+Vh8i+6xjzZqvZ2pLVpUcuSfnvNI/x67L0g==" }, "node_modules/@types/node": { - "version": "20.16.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.3.tgz", - "integrity": "sha512-/wdGiWRkMOm53gAsSyFMXFZHbVg7C6CbkrzHNpaHoYfsUWPg7m6ZRKtvQjgvQ9i8WT540a3ydRlRQbxjY30XxQ==", + "version": "20.16.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.5.tgz", + "integrity": "sha512-VwYCweNo3ERajwy0IUlqqcyZ8/A7Zwa9ZP3MnENWcB11AejO+tLy3pu850goUW2FC/IJMdZUfKpX/yxL1gymCA==", "dev": true, "license": "MIT", "dependencies": { diff --git a/open-api/typescript-sdk/package.json b/open-api/typescript-sdk/package.json index afa5f4585810b..46c5ac8c56260 100644 --- a/open-api/typescript-sdk/package.json +++ b/open-api/typescript-sdk/package.json @@ -19,7 +19,7 @@ "@oazapfts/runtime": "^1.0.2" }, "devDependencies": { - "@types/node": "^20.16.2", + "@types/node": "^20.16.5", "typescript": "^5.3.3" }, "repository": { diff --git a/server/Dockerfile b/server/Dockerfile index ec38d8f9ce607..f615f8712a7ae 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -25,7 +25,7 @@ COPY --from=dev /usr/src/app/node_modules/@img ./node_modules/@img COPY --from=dev /usr/src/app/node_modules/exiftool-vendored.pl ./node_modules/exiftool-vendored.pl # web build -FROM node:20.17.0-alpine3.20@sha256:1a526b97cace6b4006256570efa1a29cd1fe4b96a5301f8d48e87c5139438a45 AS web +FROM node:20.17.0-alpine3.20@sha256:2d07db07a2df6830718ae2a47db6fedce6745f5bcd174c398f2acdda90a11c03 AS web WORKDIR /usr/src/open-api/typescript-sdk COPY open-api/typescript-sdk/package*.json open-api/typescript-sdk/tsconfig*.json ./ diff --git a/server/package-lock.json b/server/package-lock.json index 51f038dfa0301..048c51c520375 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -83,7 +83,7 @@ "@types/lodash": "^4.14.197", "@types/mock-fs": "^4.13.1", "@types/multer": "^1.4.7", - "@types/node": "^20.16.2", + "@types/node": "^20.16.5", "@types/nodemailer": "^6.4.14", "@types/picomatch": "^3.0.0", "@types/react": "^18.3.4", @@ -6151,9 +6151,9 @@ } }, "node_modules/@types/node": { - "version": "20.16.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.3.tgz", - "integrity": "sha512-/wdGiWRkMOm53gAsSyFMXFZHbVg7C6CbkrzHNpaHoYfsUWPg7m6ZRKtvQjgvQ9i8WT540a3ydRlRQbxjY30XxQ==", + "version": "20.16.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.5.tgz", + "integrity": "sha512-VwYCweNo3ERajwy0IUlqqcyZ8/A7Zwa9ZP3MnENWcB11AejO+tLy3pu850goUW2FC/IJMdZUfKpX/yxL1gymCA==", "dependencies": { "undici-types": "~6.19.2" } @@ -20027,9 +20027,9 @@ } }, "@types/node": { - "version": "20.16.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.3.tgz", - "integrity": "sha512-/wdGiWRkMOm53gAsSyFMXFZHbVg7C6CbkrzHNpaHoYfsUWPg7m6ZRKtvQjgvQ9i8WT540a3ydRlRQbxjY30XxQ==", + "version": "20.16.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.5.tgz", + "integrity": "sha512-VwYCweNo3ERajwy0IUlqqcyZ8/A7Zwa9ZP3MnENWcB11AejO+tLy3pu850goUW2FC/IJMdZUfKpX/yxL1gymCA==", "requires": { "undici-types": "~6.19.2" } diff --git a/server/package.json b/server/package.json index 48e873a8f84c6..f9fcbde550d5d 100644 --- a/server/package.json +++ b/server/package.json @@ -109,7 +109,7 @@ "@types/lodash": "^4.14.197", "@types/mock-fs": "^4.13.1", "@types/multer": "^1.4.7", - "@types/node": "^20.16.2", + "@types/node": "^20.16.5", "@types/nodemailer": "^6.4.14", "@types/picomatch": "^3.0.0", "@types/react": "^18.3.4", diff --git a/web/Dockerfile b/web/Dockerfile index 4bc711e15ece5..19d8d890ab55e 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -1,4 +1,4 @@ -FROM node:20.17.0-alpine3.20@sha256:1a526b97cace6b4006256570efa1a29cd1fe4b96a5301f8d48e87c5139438a45 +FROM node:20.17.0-alpine3.20@sha256:2d07db07a2df6830718ae2a47db6fedce6745f5bcd174c398f2acdda90a11c03 RUN apk add --no-cache tini USER node From 4e08ff6c3382b3231db9cf855444d007134c9cd7 Mon Sep 17 00:00:00 2001 From: Pavel Sapachev Date: Thu, 12 Sep 2024 04:35:16 +0300 Subject: [PATCH 010/123] fix(web): remove unnecessary divider in External Library settings (#12583) * fix(web): remove unnecessary divider in External Library Settings * fix: narrowing --- .../library-settings/library-settings.svelte | 163 ++++++++---------- 1 file changed, 76 insertions(+), 87 deletions(-) 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 f6ed132c8c0a7..68867a2a494ac 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 @@ -29,95 +29,84 @@
- -
-
- -
- -
- onReset({ ...options, configKeys: ['library'] })} - onSave={() => onSave({ library: config.library })} - showResetToDefault={!isEqual(savedConfig.library, defaultConfig.library)} - {disabled} - /> -
-
-
- - -
-
- - -
- - + +
+ +
+
+
- - -

- - - {message} - - -

-
-
-
+ +
+ -
- onReset({ ...options, configKeys: ['library'] })} - onSave={() => onSave({ library: config.library })} - showResetToDefault={!isEqual(savedConfig.library, defaultConfig.library)} - {disabled} - /> -
- - +
+ + +
+ + + +

+ + + {message} + + +

+
+
+
+
+ + onReset({ ...options, configKeys: ['library'] })} + onSave={() => onSave({ library: config.library })} + showResetToDefault={!isEqual(savedConfig.library, defaultConfig.library)} + {disabled} + /> +
+
From fa095c3ca065228dcdd636c1f4829f55c0ead8f9 Mon Sep 17 00:00:00 2001 From: "Weblate (bot)" Date: Thu, 12 Sep 2024 03:51:02 +0200 Subject: [PATCH 011/123] chore(web): update translations (#12384) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Translate-URL: https://hosted.weblate.org/projects/immich/immich/bg/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/ca/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/cs/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/de/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/el/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/es/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/et/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/fr/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/hi/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/hu/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/id/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/it/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/pl/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/pt/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/ru/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/sr_Cyrl/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/sr_Latn/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/sv/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/uk/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/vi/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/zh_Hant/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/zh_SIMPLIFIED/ Translation: Immich/immich Co-authored-by: Adam Co-authored-by: Alexander WB Co-authored-by: Aryiu Co-authored-by: Bezruchenko Simon Co-authored-by: ChoosenMEME Co-authored-by: Denis Pacquier Co-authored-by: IM Ben Co-authored-by: Indrek Haav Co-authored-by: Jaime Branco Co-authored-by: Javier Montón Co-authored-by: Joachim Klahr Co-authored-by: Jonathan Jogenfors Co-authored-by: Julian Stauffer Co-authored-by: Mateusz Kosiorek Co-authored-by: Maximos Prasinos Co-authored-by: Miki Mrvos Co-authored-by: Noisy Fridge Co-authored-by: Patrick Wagner Co-authored-by: Rashmi Pawar Co-authored-by: Shagon94 Co-authored-by: Shawn Co-authored-by: Zsolt Kozaróczy Co-authored-by: chapvic Co-authored-by: dvbthien Co-authored-by: gallegonovato Co-authored-by: opl- Co-authored-by: pyccl Co-authored-by: rbasliana <91536894+rbasliana@users.noreply.github.com> Co-authored-by: waclaw66 Co-authored-by: Вячеслав Лукьяненко Co-authored-by: Димитър Низамов Co-authored-by: 李奕寯 --- web/src/lib/i18n/bg.json | 12 ++- web/src/lib/i18n/ca.json | 8 +- web/src/lib/i18n/cs.json | 7 +- web/src/lib/i18n/de.json | 8 ++ web/src/lib/i18n/el.json | 22 ++++ web/src/lib/i18n/es.json | 3 + web/src/lib/i18n/et.json | 2 +- web/src/lib/i18n/fr.json | 2 + web/src/lib/i18n/hi.json | 2 +- web/src/lib/i18n/hu.json | 7 +- web/src/lib/i18n/id.json | 15 ++- web/src/lib/i18n/it.json | 15 ++- web/src/lib/i18n/pl.json | 23 +++-- web/src/lib/i18n/pt.json | 3 +- web/src/lib/i18n/ru.json | 13 ++- web/src/lib/i18n/sr_Cyrl.json | 15 ++- web/src/lib/i18n/sr_Latn.json | 11 +- web/src/lib/i18n/sv.json | 50 ++++----- web/src/lib/i18n/uk.json | 10 +- web/src/lib/i18n/vi.json | 3 + web/src/lib/i18n/zh_Hant.json | 33 ++++-- web/src/lib/i18n/zh_SIMPLIFIED.json | 152 +++++++++++++++------------- 22 files changed, 276 insertions(+), 140 deletions(-) diff --git a/web/src/lib/i18n/bg.json b/web/src/lib/i18n/bg.json index 3780c48482c28..492a888f62c8a 100644 --- a/web/src/lib/i18n/bg.json +++ b/web/src/lib/i18n/bg.json @@ -138,6 +138,10 @@ "map_style_description": "URL адрес към файл \"style.json\" за задаване на стил на картата", "metadata_extraction_job": "Извличане на метаданни", "metadata_extraction_job_description": "Извличане на метаданни от всеки ресурс, като GPS и резолюция", + "metadata_faces_import_setting": "Включи импорт на лице", + "metadata_faces_import_setting_description": "Импортирай лица от EXIF данни и помощни файлове", + "metadata_settings": "Опции за метаданни", + "metadata_settings_description": "Управление на настройките за метаданни", "migration_job": "Миграция", "migration_job_description": "Мигриране на миниатюрите за ресурси и лица към най-новата структура на папките", "no_paths_added": "Няма добавени пътища", @@ -275,7 +279,7 @@ "transcoding_optimal_description": "Видеоклипове с по-висока от целевата разделителна способност или не в приетия формат", "transcoding_preferred_hardware_device": "Предпочитано хардуерно устройство", "transcoding_preferred_hardware_device_description": "Прилага се само за VAAPI и QSV. Задава dri възела, използван за хардуерно транскодиране.", - "transcoding_preset_preset": "", + "transcoding_preset_preset": "Предварително зададени(-preset)", "transcoding_preset_preset_description": "Скорост на компресия. По-бавните предварително зададени настройки създават по-малки файлове и повишават качеството при насочване към определен битрейт. VP9 игнорира скорости над „по-бързо“.", "transcoding_reference_frames": "Референтни фреймове", "transcoding_reference_frames_description": "Броят кадри за препратка при компресиране на даден кадър. По-високите стойности подобряват ефективността на компресията, но забавят кодирането. 0 задава тази стойност автоматично.", @@ -292,10 +296,10 @@ "transcoding_tone_mapping_description": "Опитва се да запази външния вид на HDR видеоклипове, когато се преобразува в SDR. Всеки алгоритъм прави различни компромиси за цвят, детайлност и яркост. Hable запазва детайлите, Mobius запазва цвета, а Reinhard запазва яркостта.", "transcoding_tone_mapping_npl": "", "transcoding_tone_mapping_npl_description": "Цветовете ще бъдат коригирани, за да изглеждат нормално за дисплей с тази яркост. Противоинтуитивно, по-ниските стойности увеличават яркостта на видеото и обратно, тъй като компенсират яркостта на дисплея. 0 задава тази стойност автоматично.", - "transcoding_transcode_policy": "", + "transcoding_transcode_policy": "Правила за транскодиране", "transcoding_transcode_policy_description": "Правила за това кога видеоклипът трябва да бъде транскодиран. HDR видеоклиповете винаги ще бъдат транскодирани (освен ако транскодирането е деактивирано).", - "transcoding_two_pass_encoding": "", - "transcoding_two_pass_encoding_setting_description": "", + "transcoding_two_pass_encoding": "Кодиране с двойно минаване", + "transcoding_two_pass_encoding_setting_description": "Транскодирането с две минавания създава по-добре кодиране видеа. Когато максималния битрейт е включен (задължително е да се работи с H.264 и HEVC), тази опция използва диапазон на битрейта базиран на максималния битрейт и игнорира CRF. За VP9, CRF може да се използва ако максималният битрейт е изключен.", "transcoding_video_codec": "Видеокодек", "transcoding_video_codec_description": "VP9 има висока ефективност и уеб съвместимост, но отнема повече време за транскодиране. HEVC работи по подобен начин, но има по-ниска уеб съвместимост. H.264 е широко съвместим и бърз за разкодиране, но създава много по-големи файлове. AV1 е най-ефективният кодек, но му липсва поддръжка на по-стари устройства.", "trash_enabled_description": "Активирайте функциите за кошче", diff --git a/web/src/lib/i18n/ca.json b/web/src/lib/i18n/ca.json index 52880c322d034..a0fd6ff437f7f 100644 --- a/web/src/lib/i18n/ca.json +++ b/web/src/lib/i18n/ca.json @@ -8,9 +8,9 @@ "active": "Actiu", "activity": "Activitat", "activity_changed": "L'activitat està {enabled, select, true {activada} other {desactivada}}", - "add": "Agregar", - "add_a_description": "Afegir una descripció", - "add_a_location": "Afegir una ubicació", + "add": "Afig", + "add_a_description": "Afegiu una descripció", + "add_a_location": "Afegiu una ubicació", "add_a_name": "Afegir un nom", "add_a_title": "Afegir un títol", "add_exclusion_pattern": "Afegir un patró d'exclusió", @@ -138,7 +138,7 @@ "map_settings_description": "Gestiona la configuració del mapa", "map_style_description": "URL a un tema del mapa style.json", "metadata_extraction_job": "Extreure metadades", - "metadata_extraction_job_description": "Extreu l'informació de metadades de cada element, com per exemple el GPS i la resolució", + "metadata_extraction_job_description": "Extreu la informació de metadades de cada element, com per exemple el GPS i la resolució", "migration_job": "Migració", "migration_job_description": "Migra les miniatures d'elements i cares cap a la nova estructura de carpetes", "no_paths_added": "Cap camí afegit", diff --git a/web/src/lib/i18n/cs.json b/web/src/lib/i18n/cs.json index ceb692a736685..ec97fe01b21b9 100644 --- a/web/src/lib/i18n/cs.json +++ b/web/src/lib/i18n/cs.json @@ -391,6 +391,7 @@ "asset_offline": "Offline položka", "asset_offline_description": "Tato položka je offline. Immich nemá přístup k jejímu umístění. Zkontrolujte, zda je položka dostupná, a poté knihovnu znovu prohledejte.", "asset_skipped": "Přeskočeno", + "asset_skipped_in_trash": "V koši", "asset_uploaded": "Nahráno", "asset_uploading": "Nahrávání...", "assets": "Položky", @@ -845,6 +846,7 @@ "license_trial_info_4": "Zvažte prosím zakoupení licence na podporu dalšího rozvoje služby", "light": "Světlý", "like_deleted": "Lajk smazán", + "link_motion_video": "Připojit pohyblivé video", "link_options": "Možnosti odkazu", "link_to_oauth": "Propojit s OAuth", "linked_oauth_account": "Propojený OAuth účet", @@ -1135,6 +1137,7 @@ "search_for_existing_person": "Vyhledat existující osobu", "search_no_people": "Žádní lidé", "search_no_people_named": "Žádní lidé se jménem \"{name}\"", + "search_options": "Možnosti vyhledávání", "search_people": "Vyhledat lidi", "search_places": "Vyhledat místa", "search_state": "Vyhledat stát...", @@ -1213,6 +1216,8 @@ "sign_up": "Zaregistrovat se", "size": "Velikost", "skip_to_content": "Přejít na obsah", + "skip_to_folders": "Přeskočit na složky", + "skip_to_tags": "Přeskočit na značky", "slideshow": "Prezentace", "slideshow_settings": "Nastavení prezentace", "sort_albums_by": "Seřadit alba podle...", @@ -1311,7 +1316,7 @@ "upload_status_uploaded": "Nahráno", "upload_success": "Nahrání proběhlo úspěšně, obnovením stránky se zobrazí nově nahrané položky.", "url": "URL", - "usage": "Použití", + "usage": "Využití", "use_custom_date_range": "Použít vlastní rozsah dat", "user": "Uživatel", "user_id": "ID uživatele", diff --git a/web/src/lib/i18n/de.json b/web/src/lib/i18n/de.json index ce51f11b66882..77b420db00ebb 100644 --- a/web/src/lib/i18n/de.json +++ b/web/src/lib/i18n/de.json @@ -391,6 +391,7 @@ "asset_offline": "Datei offline", "asset_offline_description": "Diese Datei ist nicht erreichbar. Immich kann nicht auf ihren Speicherort zugreifen. Bitte stelle sicher, dass die Datei verfügbar ist und scanne die Bibliothek erneut.", "asset_skipped": "Übersprungen", + "asset_skipped_in_trash": "Im Papierkorb", "asset_uploaded": "Hochgeladen", "asset_uploading": "Hochladen...", "assets": "Dateien", @@ -660,6 +661,7 @@ "unable_to_get_comments_number": "Anzahl der Kommentare konnte nicht abgerufen werden", "unable_to_get_shared_link": "Fehler beim Abrufen des Freigabelinks", "unable_to_hide_person": "Person kann nicht versteckt werden", + "unable_to_link_motion_video": "Bewegungsvideo kann nicht verlinkt werden", "unable_to_link_oauth_account": "OAuth-Konto kann nicht verknüpft werden", "unable_to_load_album": "Album kann nicht geladen werden", "unable_to_load_asset_activity": "Foto-Aktivität konnte nicht geladen werden", @@ -700,6 +702,7 @@ "unable_to_submit_job": "Auftrag konnte nicht übermittelt werden", "unable_to_trash_asset": "Objekte konnten nicht gelöscht werden", "unable_to_unlink_account": "Die Verknüpfung des Kontos kann nicht aufgehoben werden", + "unable_to_unlink_motion_video": "Verlinkung zum Bewegungsvideo kann nicht aufgehoben werden", "unable_to_update_album_cover": "Album-Cover konnte nicht aktualisiert werden", "unable_to_update_album_info": "Album-Info konnte nicht aktualisiert werden", "unable_to_update_library": "Die Bibliothek konnte nicht aktualisiert werden", @@ -845,6 +848,7 @@ "license_trial_info_4": "Bitte erwäge den Kauf einer Lizenz, um die kontinuierliche Weiterentwicklung des Dienstes zu unterstützen", "light": "Hell", "like_deleted": "Like gelöscht", + "link_motion_video": "Link Bewegungsvideo", "link_options": "Link-Optionen", "link_to_oauth": "Link zu OAuth", "linked_oauth_account": "Verknüpftes OAuth-Konto", @@ -1134,6 +1138,7 @@ "search_for_existing_person": "Suche nach vorhandener Person", "search_no_people": "Keine Personen", "search_no_people_named": "Keine Person mit dem Namen \"{name}\"", + "search_options": "Suchoptionen", "search_people": "Suche nach Personen", "search_places": "Suche nach Orten", "search_state": "Suche nach Bundesland / Provinz...", @@ -1212,6 +1217,8 @@ "sign_up": "Registrieren", "size": "Größe", "skip_to_content": "Zum Inhalt springen", + "skip_to_folders": "Springe zu Ordnern", + "skip_to_tags": "Springe zu Tags", "slideshow": "Diashow", "slideshow_settings": "Diashow Einstellungen", "sort_albums_by": "Alben sortieren nach...", @@ -1286,6 +1293,7 @@ "unknown_album": "Unbekanntes Album", "unknown_year": "Unbekanntes Jahr", "unlimited": "Unlimitiert", + "unlink_motion_video": "Verlinkung zum Bewegungsvideo aufheben", "unlink_oauth": "OAuth entfernen", "unlinked_oauth_account": "Nicht verknüpftes OAuth-Konto", "unnamed_album": "Unbenanntes Album", diff --git a/web/src/lib/i18n/el.json b/web/src/lib/i18n/el.json index 5ac37616ca01a..5f88772ea7d5f 100644 --- a/web/src/lib/i18n/el.json +++ b/web/src/lib/i18n/el.json @@ -12,6 +12,8 @@ "add_a_location": "Προσθήκη μιας τοποθεσίας", "add_a_name": "Προσθήκη Ονόματος", "add_a_title": "Προσθήκη τίτλου", + "add_exclusion_pattern": "Προσθήκη προτύπου αποκλεισμού", + "add_import_path": "Προσθήκη διαδρομής εισαγωγής", "add_location": "Προσθήκη τοποθεσίας", "add_more_users": "Προσθήκη επιπλέον χρηστών", "add_partner": "Προσθήκη συνεργάτη", @@ -24,16 +26,24 @@ "added_to_favorites": "Προστέθηκε στα αγαπημένα", "added_to_favorites_count": "Προστέθηκαν {count, number} στα αγαπημένα", "admin": { + "add_exclusion_pattern_description": "Προσθέστε πρότυπα αποκλεισμού. Υποστηρίζεται η επιλογή πολλών με *, **, και ?. Για να αγνοηθούν όλα τα αρχεία σε έναν φάκελο με το όνομα \"Raw\", χρησιμοποιήστε \"**/Raw/**\". Για να αγνοηθούν όλα τα αρχεία με κατάληξη \".tif\", χρησιμοποιήστε \"**/*.tif\". Για να αγνοηθεί μία απόλυτη διαδρομή, χρησιμοποιήστε \"/path/to/ignore/**\".", "authentication_settings": "Ρυθμίσεις ελέγχου ταυτότητας", "authentication_settings_description": "Διαχείριση κωδικού πρόσβασης, OAuth και άλλες ρυθμίσεις ελέγχου ταυτότητας", "authentication_settings_disable_all": "Είστε βέβαιοι ότι θέλετε να απενεργοποιήσετε όλες τις μεθόδους σύνδεσης; Η σύνδεση θα απενεργοποιηθεί πλήρως.", + "authentication_settings_reenable": "Για να επαναενεργοποιηθεί, χρησιμοποιήστε μία Server Command.", "background_task_job": "Εργασίες Παρασκηνίου", "check_all": "Έλεγχος Όλων", + "cleared_jobs": "Εκκαθάριση εργασιών για: {job}", + "config_set_by_file": "Η διαμόρφωση γίνεται προς το παρόν από ένα αρχείο config", "confirm_delete_library": "Είστε βέβαιοι ότι θέλετε να διαγράψετε τη βιβλιοθήκη {library};", + "confirm_delete_library_assets": "Είστε σίγουροι ότι θέλετε να διαγράψετε αυτή τη βιβλιοθήκη; Αυτό θα διαγράψει τα {count, plural, one {# contained asset} other {all # contained assets}} από το Immich και δεν μπορεί να αναιρεθεί. Τα αρχεία θα παραμείνουν στον δίσκο.", "confirm_email_below": "Για επιβεβαίωση, πληκτρολογήστε \"{email}\" παρακάτω", "confirm_reprocess_all_faces": "Είστε βέβαιοι ότι θέλετε να επεξεργαστείτε ξανά όλα τα πρόσωπα; Αυτό θα διαγράψει επίσης άτομα με όνομα.", "confirm_user_password_reset": "Είστε βέβαιοι ότι θέλετε να επαναφέρετε τον κωδικό πρόσβασης του χρήστη {user};", + "disable_login": "Απενεργοποίηση σύνδεσης κατά την είσοδο", "duplicate_detection_job_description": "Εκτελέστε τη εκμάθηση μηχανής σε στοιχεία για να εντοπίσετε παρόμοιες εικόνες. Βασίζεται στην Έξυπνη Αναζήτηση", + "exclusion_pattern_description": "Τα πρότυπα αποκλεισμού σας επιτρέπουν να αγνοείται αρχεία κκαι φακέλους όσο σαρώνεται η βιβλιοθήκη. Αυτό είναι χρήσιμο εάν εχετε φακέλους που περιέχουν αρχεία που δεν θέλετε να εισαγάγετε, όπως αρχεία RAW.", + "external_library_created_at": "Εξωτερική βιβλιοθήκη (δημιουργήθηκε {date})", "external_library_management": "Διαχείριση Εξωτερικών Βιβλιοθηκών", "face_detection": "Αναγνώριση προσώπου", "face_detection_description": "Εντοπίστε τα πρόσωπα σε στοιχεία χρησιμοποιώντας μηχανική εκμάθηση. Για βίντεο, λαμβάνεται υπόψη μόνο η μικρογραφία. Η επιλογή \"Όλα\" επεξεργάζεται εκ νέου όλα τα στοιχεία. Η επιλογή \"Όσα Λείπουν\" προσθέτει στην ουρά στοιχεία που δεν έχουν υποστεί ακόμη επεξεργασία. Τα πρόσωπα που έχουν εντοπιστεί θα μπουν στην ουρά για την Αναγνώριση Προσώπου μετά την ολοκλήρωση της Ανίχνευσης Προσώπου, ομαδοποιώντας τα σε υπάρχοντα ή νέα άτομα.", @@ -43,7 +53,9 @@ "forcing_refresh_library_files": "Επιβολή ανανέωσης όλων των αρχείων της βιβλιοθήκης", "image_format_description": "Η μορφή WebP παράγει μικρότερα αρχεία από τη μορφή JPEG, αλλά είναι πιο αργή στην κωδικοποίηση.", "image_prefer_embedded_preview": "Προτίμηση ενσωματωμένης προεπισκόπησης", + "image_prefer_embedded_preview_setting_description": "Χρησιμοποιήστε ενσωματωμένες προεπισκοπίσεις για εικόνες RAW ως εισαγωγή στην επεξεργασία εικόνας όταν είναι διαθέσιμο. Αυτό μπορεί να δημιουργήσει πιο ακριβή χρωματα για κάποιες εικόνες, αλλά η ποιότητα των προεπισκοπίσεων εξαρτάται από την κάμερα και ενδέχεται να υπάρχουν περισσότερα μπιμπίκια λόγω συμπίεσης.", "image_prefer_wide_gamut": "Προτίμηση ευρείας γκάμας", + "image_prefer_wide_gamut_setting_description": "Χρησιμοποιήστε Display P3 για τις μικρογραφίες. Αυτό διατηρεί την ζωντάνια των χρωμάτων σε εικόνες μεγάλου χρωματικού εύρους, αλλά ενδέχεται να εμφανίζονται αλλιώς σε παλαιότερες συσκευές με παλαιότερες εκδόσεις περιηγητών. Οι εικόνες sRGB μένουν ως έχουν για να αποφευχθούν χρωματικές αλλαγές.", "image_preview_format": "Μορφή προεπισκόπησης", "image_preview_resolution": "Ανάλυση προεπισκόπησης", "image_preview_resolution_description": "Χρησιμοποιείται κατά την προβολή μιας φωτογραφίας και για μηχανική εκμάθηση. Οι υψηλότερες αναλύσεις μπορούν να διατηρήσουν περισσότερες λεπτομέρειες, αλλά χρειάζονται περισσότερο χρόνο για την κωδικοποίηση, έχουν μεγαλύτερα μεγέθη αρχείων και μπορούν να μειώσουν την απόκριση της εφαρμογής.", @@ -54,10 +66,19 @@ "image_thumbnail_format": "Μορφή μικρογραφίας", "image_thumbnail_resolution": "Ανάλυση μικρογραφίας", "image_thumbnail_resolution_description": "Χρησιμοποιείται κατά την προβολή ομάδων φωτογραφιών (κύριο χρονολόγιο, προβολή άλμπουμ κλπ.). Υψηλότερες αναλύσεις μπορούν να διατηρήσουν περισσότερες λεπτομέρειες, αλλά χρειάζονται περισσότερο χρόνο για την κωδικοποίηση, έχουν μεγαλύτερα μεγέθη αρχείων και μπορούν να μειώσουν την απόκριση της εφαρμογής.", + "job_concurrency": "{job} συγχρονισμός", + "job_not_concurrency_safe": "Αυτή η εργασία δεν είναι ασφαλής για ταυτόχρονη εκτέλεση.", "job_settings": "Ρυθμίσεις Εργασιών", + "job_settings_description": "Διαχείρηση ταυτόχρονων εργασιών", "job_status": "Κατάσταση Εργασιών", + "jobs_delayed": "{jobCount, plural, other {# delayed}}", + "jobs_failed": "{jobCount, plural, other {# failed}}", "library_created": "Δημιουργήθηκε η βιβλιοθήκη: {library}", + "library_cron_expression": "Εκφράσεις Cron", + "library_cron_expression_description": "Ορισμός των διαστημάτων μεταξύ των σαρώσεων με χρήση cron μορφής. Για περισσότερες πληροφορίες παρακαλώ επισκεφθείτε το π.χ. Crontab Guru", + "library_cron_expression_presets": "Προκαθορισμένες εκφράσεις Cron", "library_deleted": "Η βιβλιοθήκη διαγράφηκε", + "library_import_path_description": "Καθορίστε έναν φάκελο για εισαγωγή. Αυτός ο φάκελος, συμπεριλαμβανομένων των υποφακέλων του, θα σαρωθεί για εικόνες και βίντεο.", "library_scanning": "Περιοδική Σάρωση", "library_scanning_description": "Διαμόρφωση περιοδικής σάρωσης βιβλιοθήκης", "library_scanning_enable_description": "Ενεργοποίηση περιοδικής σάρωσης βιβλιοθήκης", @@ -70,6 +91,7 @@ "logging_enable_description": "Ενεργοποίηση καταγραφής", "logging_level_description": "Όταν είναι ενεργοποιημένο, τι επίπεδο καταγραφής να εφαρμοστεί.", "logging_settings": "Καταγραφή", + "machine_learning_clip_model": "Μοντέλο CLIP", "machine_learning_duplicate_detection": "Εντοπισμός Διπλότυπων", "machine_learning_duplicate_detection_enabled": "Ενεργοποίηση εντοπισμού διπλότυπων", "machine_learning_enabled": "Ενεργοποίηση μηχανικής εκμάθησης", diff --git a/web/src/lib/i18n/es.json b/web/src/lib/i18n/es.json index 509c6d515f25c..f0c89ffb46adc 100644 --- a/web/src/lib/i18n/es.json +++ b/web/src/lib/i18n/es.json @@ -391,6 +391,7 @@ "asset_offline": "Archivos fuera de linea", "asset_offline_description": "Este archivo está offline. Immich no puede acceder a la ubicación de su archivo. Asegúrese de que el archivo esté disponible y luego vuelva a escanear la biblioteca.", "asset_skipped": "Omitido", + "asset_skipped_in_trash": "En la papelera", "asset_uploaded": "Subido", "asset_uploading": "Subiendo...", "assets": "elementos", @@ -1212,6 +1213,8 @@ "sign_up": "Registrarse", "size": "Tamaño", "skip_to_content": "Saltar al contenido", + "skip_to_folders": "Ir a las carpetas", + "skip_to_tags": "Ir a las etiquetas", "slideshow": "Diapositivas", "slideshow_settings": "Ajustes de diapositivas", "sort_albums_by": "Ordenar álbumes por...", diff --git a/web/src/lib/i18n/et.json b/web/src/lib/i18n/et.json index a37370f84ad98..25cdba4cb41a3 100644 --- a/web/src/lib/i18n/et.json +++ b/web/src/lib/i18n/et.json @@ -120,7 +120,7 @@ "map_settings": "Kaart", "map_settings_description": "Halda kaardi seadeid", "metadata_extraction_job": "Metaandmete eraldamine", - "metadata_extraction_job_description": "Eralda igast üksusest metaandmed, nagu GPS-koordinaadid ja resolutsioon", + "metadata_extraction_job_description": "Eralda igast üksusest metaandmed, nagu GPS-koordinaadid, näod ja resolutsioon", "migration_job": "Migratsioon", "migration_job_description": "Migreeri üksuste ja nägude pisipildid uusimale kaustastruktuurile", "note_cannot_be_changed_later": "MÄRKUS: Seda ei saa hiljem muuta!", diff --git a/web/src/lib/i18n/fr.json b/web/src/lib/i18n/fr.json index c9fe916c03cca..9edcb1fdd2807 100644 --- a/web/src/lib/i18n/fr.json +++ b/web/src/lib/i18n/fr.json @@ -1212,6 +1212,8 @@ "sign_up": "S'enregistrer", "size": "Taille", "skip_to_content": "Passer", + "skip_to_folders": "Passer vers les dossiers", + "skip_to_tags": "Passer vers les tags", "slideshow": "Diaporama", "slideshow_settings": "Paramètres du diaporama", "sort_albums_by": "Trier les albums par...", diff --git a/web/src/lib/i18n/hi.json b/web/src/lib/i18n/hi.json index 99f2ef24587fd..2f2aabfb7eccb 100644 --- a/web/src/lib/i18n/hi.json +++ b/web/src/lib/i18n/hi.json @@ -114,7 +114,7 @@ "machine_learning_max_recognition_distance_description": "एक ही व्यक्ति माने जाने वाले दो चेहरों के बीच अधिकतम दूरी 0-2 के बीच है।", "machine_learning_min_detection_score": "न्यूनतम पहचान स्कोर", "machine_learning_min_detection_score_description": "किसी चेहरे का पता लगाने के लिए न्यूनतम आत्मविश्वास स्कोर 0-1 होना चाहिए।", - "machine_learning_min_recognized_faces": "न्यूनतम पहचाने गए चेहरे", + "machine_learning_min_recognized_faces": "निम्नतम पहचाने चेहरे", "machine_learning_min_recognized_faces_description": "किसी व्यक्ति के लिए पहचाने जाने वाले चेहरों की न्यूनतम संख्या।", "machine_learning_settings": "मशीन लर्निंग सेटिंग्स", "machine_learning_settings_description": "मशीन लर्निंग सुविधाओं और सेटिंग्स को प्रबंधित करें", diff --git a/web/src/lib/i18n/hu.json b/web/src/lib/i18n/hu.json index 7869e956c7b01..753839c384b19 100644 --- a/web/src/lib/i18n/hu.json +++ b/web/src/lib/i18n/hu.json @@ -139,7 +139,8 @@ "map_settings_description": "Térkép beállítások kezelése", "map_style_description": "Egy style.json térképstílusra mutató URL", "metadata_extraction_job": "Metaadatok feldolgozása", - "metadata_extraction_job_description": "Metaadat-információk kinyerése minden tartalomból, például GPS és felbontás", + "metadata_extraction_job_description": "Metaadat-információk kinyerése minden tartalomból, például GPS, arcok és felbontás", + "metadata_settings": "Metaadat beállítások", "migration_job": "Migráció", "migration_job_description": "Az képi vagyon és arcok bélyegképeinek migrálása a legújabb mappastruktúrába", "no_paths_added": "Nincs megadva elérési útvonal", @@ -338,7 +339,8 @@ "album_added": "Albumhoz hozzáadva", "album_added_notification_setting_description": "Küldjön emailes értesítőt, amikor hozzáadnak egy megosztott albumhoz", "album_cover_updated": "Album borító frissítve", - "album_delete_confirmation": "Biztos, hogy ki szeretné törölni a {album} albumot?\nAmennyiben ez egy megosztott album, a többi felhasználó sem fog tudni hozzáférni.", + "album_delete_confirmation": "Biztos, hogy ki szeretné törölni a {album} albumot?", + "album_delete_confirmation_description": "Amennyiben ez egy megosztott album, a többi felhasználó sem fog tudni hozzáférni.", "album_info_updated": "Album infó frissítve", "album_leave": "Elhagyja az albumot?", "album_leave_confirmation": "Biztos, hogy el szeretné hagyni a {album} albumot?", @@ -448,6 +450,7 @@ "close": "Bezárás", "collapse": "Összecsuk", "collapse_all": "Mindet összecsuk", + "color": "Szín", "color_theme": "Szín stílus", "comment_deleted": "Megjegyzés törölve", "comment_options": "Megjegyzés beállítások", diff --git a/web/src/lib/i18n/id.json b/web/src/lib/i18n/id.json index 368d12855039c..ea5ad94b2719e 100644 --- a/web/src/lib/i18n/id.json +++ b/web/src/lib/i18n/id.json @@ -137,7 +137,11 @@ "map_settings_description": "Kelola pengaturan peta", "map_style_description": "URL ke tema peta style.json", "metadata_extraction_job": "Ekstrak metadata", - "metadata_extraction_job_description": "Ekstrak informasi metadata dari setiap aset, seperti GPS dan resolusi", + "metadata_extraction_job_description": "Ekstrak informasi metadata dari setiap aset, seperti GPS, wajah dan resolusi", + "metadata_faces_import_setting": "Aktifkan impor wajah", + "metadata_faces_import_setting_description": "Impor wajah dari data gambar EXIF dan berkas sidecar", + "metadata_settings": "Pengaturan Metadata", + "metadata_settings_description": "Kelola pengaturan metadata", "migration_job": "Migrasi", "migration_job_description": "Migrasikan gambar kecil untuk aset dan wajah ke struktur folder terkini", "no_paths_added": "Tidak ada jalur yang ditambahkan", @@ -384,6 +388,7 @@ "asset_offline": "Aset luring", "asset_offline_description": "Aset ini sedang luring. Immich tidak dapat mengakses lokasi berkasnya. Pastikan aset tersebut tersedia lalu pindai ulang pustaka.", "asset_skipped": "Dilewati", + "asset_skipped_in_trash": "Dalam sampah", "asset_uploaded": "Sudah diunggah", "asset_uploading": "Mengunggah...", "assets": "Aset", @@ -642,6 +647,7 @@ "unable_to_get_comments_number": "Tidak bisa mendapatkan jumlah komentar", "unable_to_get_shared_link": "Gagal mendapatkan tautan berbagi", "unable_to_hide_person": "Tidak dapat menyembunyikan orang", + "unable_to_link_motion_video": "Tidak dapat menautkan video gerak", "unable_to_link_oauth_account": "Tidak dapat menautkan akun OAuth", "unable_to_load_album": "Tidak dapat memuat album", "unable_to_load_asset_activity": "Tidak dapat memuat aktivitas aset", @@ -680,6 +686,7 @@ "unable_to_submit_job": "Tidak dapat mengirim tugas", "unable_to_trash_asset": "Tidak dapat membuang aset", "unable_to_unlink_account": "Tidak dapat memutuskan akun", + "unable_to_unlink_motion_video": "Tidak dapat membatalkan tautan video gerak", "unable_to_update_album_cover": "Tidak dapat memperbarui kover album", "unable_to_update_album_info": "Tidak dapat memperbarui info album", "unable_to_update_library": "Tidak dapat memperbarui pustaka", @@ -816,6 +823,7 @@ "license_trial_info_4": "Pertimbangkan membeli lisensi untuk mendukung keberlanjutan pengembangan layanan", "light": "Terang", "like_deleted": "Suka dihapus", + "link_motion_video": "Tautan video gerak", "link_options": "Opsi tautan", "link_to_oauth": "Tautkan ke OAuth", "linked_oauth_account": "Akun OAuth tertaut", @@ -1100,6 +1108,7 @@ "search_for_existing_person": "Cari orang yang sudah ada", "search_no_people": "Tidak ada orang", "search_no_people_named": "Tidak ada orang bernama \"{name}\"", + "search_options": "Pilihan pencarian", "search_people": "Cari orang", "search_places": "Cari tempat", "search_state": "Cari negara bagian...", @@ -1178,6 +1187,8 @@ "sign_up": "Daftar", "size": "Ukuran", "skip_to_content": "Lewati ke konten", + "skip_to_folders": "Lewati ke berkas", + "skip_to_tags": "Lewati ke tag", "slideshow": "Salindia", "slideshow_settings": "Pengaturan salindia", "sort_albums_by": "Urutkan album berdasarkan...", @@ -1229,6 +1240,7 @@ "to_change_password": "Ubah kata sandi", "to_favorite": "Favorit", "to_login": "Log masuk", + "to_parent": "Ke induk", "to_root": "Untuk melakukan root", "to_trash": "Sampah", "toggle_settings": "Saklar pengaturan", @@ -1250,6 +1262,7 @@ "unknown": "Tidak diketahui", "unknown_year": "Tahun Tidak Diketahui", "unlimited": "Tidak terbatas", + "unlink_motion_video": "Membatalkan tautan video gerak", "unlink_oauth": "Putuskan OAuth", "unlinked_oauth_account": "Akun OAuth terputus", "unnamed_album": "Album Tanpa Nama", diff --git a/web/src/lib/i18n/it.json b/web/src/lib/i18n/it.json index 773f3bc67ca9f..6782b8fbb9350 100644 --- a/web/src/lib/i18n/it.json +++ b/web/src/lib/i18n/it.json @@ -139,7 +139,11 @@ "map_settings_description": "Gestisci impostazioni mappa", "map_style_description": "URL per un tema della mappa style.json", "metadata_extraction_job": "Estrazione Metadata", - "metadata_extraction_job_description": "Estrai informazioni dai metadati di ciascun asset, ad esempio coordinate GPS e risoluzione", + "metadata_extraction_job_description": "Estrai informazioni dai metadati di ciascun asset, ad esempio coordinate GPS, volti e risoluzione", + "metadata_faces_import_setting": "Abilita l'importazione dei volti", + "metadata_faces_import_setting_description": "Importa i volti dai dati EXIF dell'immagine e dai file sidecar", + "metadata_settings": "Impostazioni Metadati", + "metadata_settings_description": "Gestisci le impostazioni dei metadati", "migration_job": "Migrazione", "migration_job_description": "Migra le anteprime per gli asset e volti alla struttura di cartelle più recente", "no_paths_added": "Nessun percorso aggiunto", @@ -387,6 +391,7 @@ "asset_offline": "Risorsa offline", "asset_offline_description": "Il media è offline. Immich non è in grado di accedere al percorso del file. Assicurarsi che il media sia disponibile e riscansionare la libreria.", "asset_skipped": "Saltato", + "asset_skipped_in_trash": "In cestino", "asset_uploaded": "Caricato", "asset_uploading": "Caricamento...", "assets": "Risorse", @@ -841,6 +846,7 @@ "license_trial_info_4": "Per favore considera sborsare soldi per una licenza e per sopportare il continuo sviluppo del servizio", "light": "Chiaro", "like_deleted": "Mi piace rimosso", + "link_motion_video": "Collega video in movimento", "link_options": "Impostazioni Collegamento", "link_to_oauth": "Collegamento a OAuth", "linked_oauth_account": "Account OAuth collegato", @@ -1048,6 +1054,7 @@ "range": "", "rating": "Valutazione a stelle", "rating_clear": "Crea valutazione", + "rating_count": "{count, plural, one {# stella} other {# stelle}}", "rating_description": "Visualizza la valutazione EXIF nel pannello informazioni", "raw": "", "reaction_options": "Impostazioni Reazioni", @@ -1081,6 +1088,7 @@ "removed_from_archive": "Rimosso dall'archivio", "removed_from_favorites": "Rimosso dai preferiti", "removed_from_favorites_count": "{count, plural, one {Rimosso } other {Rimossi #}} dai preferiti", + "removed_tagged_assets": "Rimossa etichetta {count, plural, one {# dall'asset} other {# dagli asset}}", "rename": "Rinomina", "repair": "Ripara", "repair_no_results_message": "I file mancanti e non tracciati saranno mostrati qui", @@ -1127,6 +1135,7 @@ "search_for_existing_person": "Cerca per persona esistente", "search_no_people": "Nessuna persona", "search_no_people_named": "Nessuna persona chiamate \"{name}\"", + "search_options": "Opzioni Ricerca", "search_people": "Cerca persone", "search_places": "Cerca luoghi", "search_state": "Cerca stato...", @@ -1205,6 +1214,8 @@ "sign_up": "Registrati", "size": "Dimensione", "skip_to_content": "Salta al contenuto", + "skip_to_folders": "Salta alle cartelle", + "skip_to_tags": "Salta alle etichette", "slideshow": "Presentazione", "slideshow_settings": "Impostazioni presentazione", "sort_albums_by": "Ordina album per...", @@ -1243,6 +1254,7 @@ "tag_feature_description": "Navigazione foto e video raggruppati per argomenti tag logici", "tag_not_found_question": "Non riesci a trovare una tag? Creane una qui", "tag_updated": "Tag {tag} aggiornata", + "tagged_assets": "{count, plural, one {# asset etichettato} other {# asset etichettati}}", "tags": "Tag", "template": "Modello", "theme": "Tema", @@ -1255,6 +1267,7 @@ "to_change_password": "Modifica password", "to_favorite": "Preferito", "to_login": "Login", + "to_parent": "Sali di un livello", "to_root": "Alla radice", "to_trash": "Cancella", "toggle_settings": "Attiva/disattiva impostazioni", diff --git a/web/src/lib/i18n/pl.json b/web/src/lib/i18n/pl.json index b9bb3f27e82ac..ff06e41233100 100644 --- a/web/src/lib/i18n/pl.json +++ b/web/src/lib/i18n/pl.json @@ -4,7 +4,7 @@ "account_settings": "Ustawienia Konta", "acknowledge": "Rozumiem", "action": "Akcja", - "actions": "Akcje", + "actions": "Akcje/i", "active": "Aktywne", "activity": "Aktywność", "activity_changed": "Aktywność jest {enabled, select, true {włączona} other {wyłączona}}", @@ -15,7 +15,7 @@ "add_a_title": "Dodaj tytuł", "add_exclusion_pattern": "Dodaj wzór wykluczający", "add_import_path": "Dodaj ścieżkę importu", - "add_location": "Dodaj lokacje", + "add_location": "Dodaj lokalizację", "add_more_users": "Dodaj więcej użytkowników", "add_partner": "Dodaj partnera", "add_path": "Dodaj ścieżkę", @@ -139,7 +139,11 @@ "map_settings_description": "Zarządzaj ustawieniami mapy", "map_style_description": "URL do pliku style.json z motywem mapy", "metadata_extraction_job": "Wyodrębnij metadane", - "metadata_extraction_job_description": "Wyodrębnij informacje o metadanych z każdego zasobu, takie jak GPS i rozdzielczość", + "metadata_extraction_job_description": "Wyodrębnij informacje o metadanych z każdego zasobu, takie jak GPS, twarze i rozdzielczość", + "metadata_faces_import_setting": "Włącz import twarzy", + "metadata_faces_import_setting_description": "Zaimportuj twarze z danych EXIF obrazu i plików towarzyszących", + "metadata_settings": "Ustawienia Metadanych", + "metadata_settings_description": "Zarządzaj ustawieniami metadanych", "migration_job": "Migracja", "migration_job_description": "Przenieś miniatury zasobów i twarzy do najnowszej struktury folderów", "no_paths_added": "Nie dodano ścieżki", @@ -509,7 +513,7 @@ "delete_link": "Usuń link", "delete_shared_link": "Usuń udostępniony link", "delete_tag": "Usuń etykietę", - "delete_tag_confirmation_prompt": "Czy jesteś pewny(a), że chcesz usunąć etykietę {tagName}?", + "delete_tag_confirmation_prompt": "Czy na pewno chcesz usunąć etykietę {tagName}?", "delete_user": "Usuń użytkownika", "deleted_shared_link": "Pomyślnie usunięto udostępniony link", "description": "Opis", @@ -1176,10 +1180,14 @@ "show_supporter_badge": "Odznaka wspierającego", "show_supporter_badge_description": "Pokaż odznakę wspierającego", "shuffle": "Losuj", + "sidebar": "Panel boczny", + "sidebar_display_description": "Wyświetl link do widoku w pasku bocznym", "sign_out": "Wyloguj się", "sign_up": "Zarejestruj się", "size": "Rozmiar", "skip_to_content": "Przejdź do treści", + "skip_to_folders": "Przejdź do folderów", + "skip_to_tags": "Przejdź do tagów", "slideshow": "Pokaz slajdów", "slideshow_settings": "Ustawienia pokazu slajdów", "sort_albums_by": "Sortuj albumy według...", @@ -1213,11 +1221,12 @@ "swap_merge_direction": "Zmień kierunek złączenia", "sync": "Synchronizuj", "tag": "Etykieta", - "tag_assets": "Zasoby etykiet", - "tag_created": "Utworzono etykietę: {tag}", - "tag_feature_description": "Przeglądanie zdjęć i filmów pogrupowanych według tematów z logiki etykiet", + "tag_assets": "Ustaw etykiety zasobów", + "tag_created": "Stworzono etykietę: {tag}", + "tag_feature_description": "Przeglądanie zdjęć i filmów pogrupowanych według logicznych etykiet wskazujących temat", "tag_not_found_question": "Nie możesz znaleźć etykiety? Utwórz ją tutaj", "tag_updated": "Uaktualniono etykietę: {tag}", + "tagged_assets": "Przypisano etykietę {count, plural, one {# zasobowi} other {# zasobom}}", "tags": "Etykiety", "template": "Szablon", "theme": "Motyw", diff --git a/web/src/lib/i18n/pt.json b/web/src/lib/i18n/pt.json index 943cde377dca9..24c13ef03d58d 100644 --- a/web/src/lib/i18n/pt.json +++ b/web/src/lib/i18n/pt.json @@ -139,7 +139,8 @@ "map_settings_description": "Gerenciar configurações do mapa", "map_style_description": "URL para um tema de mapa style.json", "metadata_extraction_job": "Extrair metadados", - "metadata_extraction_job_description": "Extraia informações de metadados de cada ativo, como GPS e resolução", + "metadata_extraction_job_description": "Extrai informações de metadados de cada ativo, como GPS e resolução", + "metadata_faces_import_setting": "Ativar a importação facial", "migration_job": "Migração", "migration_job_description": "Migre miniaturas de arquivos e rostos para a estrutura de pastas mais recente", "no_paths_added": "Nenhum caminho adicionado", diff --git a/web/src/lib/i18n/ru.json b/web/src/lib/i18n/ru.json index 6d28e90db0ea1..660f7bc84c952 100644 --- a/web/src/lib/i18n/ru.json +++ b/web/src/lib/i18n/ru.json @@ -391,6 +391,7 @@ "asset_offline": "Объект отключён", "asset_offline_description": "Этот объект находится в офлайн-режиме. Immich не может получить доступ к его расположению. Пожалуйста, убедитесь, что объект доступен, и затем пересканируйте библиотеку.", "asset_skipped": "Пропущено", + "asset_skipped_in_trash": "В корзине", "asset_uploaded": "Загружено", "asset_uploading": "Загрузка...", "assets": "Объекты", @@ -845,6 +846,7 @@ "license_trial_info_4": "Пожалуйста, рассмотрите возможность приобретения лицензии для поддержки дальнейшего развития проекта", "light": "Светлая", "like_deleted": "Лайк удален", + "link_motion_video": "Ссылка на движущееся видео", "link_options": "Настройки ссылки", "link_to_oauth": "Присоединение к OAuth", "linked_oauth_account": "Присоединённый аккаунт OAuth", @@ -1134,6 +1136,7 @@ "search_for_existing_person": "Поиск существующего человека", "search_no_people": "Нет людей", "search_no_people_named": "Нет людей с именем \"{name}\"", + "search_options": "Параметры поиска", "search_people": "Поиск людей", "search_places": "Поиск мест", "search_state": "Поиск региона...", @@ -1340,11 +1343,11 @@ "view_album": "Просмотреть альбом", "view_all": "Посмотреть всё", "view_all_users": "Показать всех пользователей", - "view_in_timeline": "Посмотреть на временной шкале", - "view_links": "Посмотреть ссылки", - "view_next_asset": "Посмотреть следующий объект", - "view_previous_asset": "Посмотреть предыдущий объект", - "view_stack": "Просмотреть Стек", + "view_in_timeline": "Показать на временной шкале", + "view_links": "Показать ссылки", + "view_next_asset": "Показать следующий объект", + "view_previous_asset": "Показать предыдущий объект", + "view_stack": "Показать стек", "viewer": "Наблюдатель", "visibility_changed": "Видимость изменена для {count, plural, one {# человека} other {# людей}}", "waiting": "В очереди", diff --git a/web/src/lib/i18n/sr_Cyrl.json b/web/src/lib/i18n/sr_Cyrl.json index d3569146d7209..6618aeab1dbc3 100644 --- a/web/src/lib/i18n/sr_Cyrl.json +++ b/web/src/lib/i18n/sr_Cyrl.json @@ -139,7 +139,11 @@ "map_settings_description": "Управљајте подешавањима мапе", "map_style_description": "УРЛ до style.json мапе тема изгледа", "metadata_extraction_job": "Извод метаподатака", - "metadata_extraction_job_description": "Извуците информације о метаподацима из сваке датотеке, као што су ГПС и резолуција", + "metadata_extraction_job_description": "Извуците информације о метаподацима из сваке датотеке, као што су GPS, лица и резолуција", + "metadata_faces_import_setting": "Омогући (enable) увоз лица", + "metadata_faces_import_setting_description": "Увезите лица из EXIF података слика и датотека са бочне траке", + "metadata_settings": "Подешавања метаподатака", + "metadata_settings_description": "Управљајте подешавањима метаподатака", "migration_job": "Миграције", "migration_job_description": "Пренесите сличице датотека и лица у најновију структуру директоријума", "no_paths_added": "Нема додатих путања", @@ -387,6 +391,7 @@ "asset_offline": "Датотека одсутна", "asset_offline_description": "Ова датотека је ван мреже. Immich не може да приступи локацији своје датотеке. Уверите се да је датотека доступна, а затим поново скенирајте библиотеку.", "asset_skipped": "Прескочено", + "asset_skipped_in_trash": "У отпад", "asset_uploaded": "Отпремљено (Уплоадед)", "asset_uploading": "Отпремање...", "assets": "Записи", @@ -488,7 +493,7 @@ "create_user": "Направи корисника", "created": "Направљен", "current_device": "Тренутни уређај", - "custom_locale": "Прилагођена локација (лоцале)", + "custom_locale": "Прилагођена локација (locale)", "custom_locale_description": "Форматирајте датуме и бројеве на основу језика и региона", "dark": "Тамно", "date_after": "Датум после", @@ -498,7 +503,7 @@ "date_range": "Распон датума", "day": "Дан", "deduplicate_all": "Де-дуплицирај све", - "default_locale": "Подразумевана локација (лоцале)", + "default_locale": "Подразумевана локација (locale)", "default_locale_description": "Форматирајте датуме и бројеве на основу локализације вашег претраживача", "delete": "Обриши", "delete_album": "Обриши албум", @@ -841,6 +846,7 @@ "license_trial_info_4": "Молимо вас да размислите о куповини лиценце за подршку континуираном развоју услуге", "light": "Светло", "like_deleted": "Лајкуј избрисано", + "link_motion_video": "Направи везу за видео запис", "link_options": "Опције везе", "link_to_oauth": "Веза до OAuth-a", "linked_oauth_account": "Повезани OAuth налог", @@ -1130,6 +1136,7 @@ "search_for_existing_person": "Потражите постојећу особу", "search_no_people": "Без особа", "search_no_people_named": "Нема особа са именом „{name}“", + "search_options": "Опције претраге", "search_people": "Претражи особе", "search_places": "Претражи места", "search_state": "Тражи регион...", @@ -1208,6 +1215,8 @@ "sign_up": "Пријави се", "size": "Величина", "skip_to_content": "Пређи на садржај", + "skip_to_folders": "Прескочи на фасцикле", + "skip_to_tags": "Прескочи на ознаке (tags)", "slideshow": "Слајдови", "slideshow_settings": "Подешавања слајдова", "sort_albums_by": "Сортирај албуме по...", diff --git a/web/src/lib/i18n/sr_Latn.json b/web/src/lib/i18n/sr_Latn.json index 8b0c53ded0a88..ea40525a81d6a 100644 --- a/web/src/lib/i18n/sr_Latn.json +++ b/web/src/lib/i18n/sr_Latn.json @@ -139,7 +139,11 @@ "map_settings_description": "Upravljajte podešavanjima mape", "map_style_description": "URL do style.json mape tema izgleda", "metadata_extraction_job": "Izvod metapodataka", - "metadata_extraction_job_description": "Izvucite informacije o metapodacima iz svake datoteke, kao što su GPS i rezolucija", + "metadata_extraction_job_description": "Izvucite informacije o metapodacima iz svake datoteke, kao što su GPS, lica i rezolucija", + "metadata_faces_import_setting": "Omogućite (enable) dodavanje lica", + "metadata_faces_import_setting_description": "Dodajte lica iz EXIF podataka slike i sličnih metapodataka", + "metadata_settings": "Podešavanje metapodataka", + "metadata_settings_description": "Upravljajte podešavanjima metapodataka", "migration_job": "Migracije", "migration_job_description": "Prenesite sličice datoteka i lica u najnoviju strukturu direktorijuma", "no_paths_added": "Nema dodatih putanja", @@ -387,6 +391,7 @@ "asset_offline": "Datoteka odsutna", "asset_offline_description": "Ova datoteka je van mreže. Immich ne može da pristupi lokaciji svoje datoteke. Uverite se da je datoteka dostupna, a zatim ponovo skenirajte biblioteku.", "asset_skipped": "Preskočeno", + "asset_skipped_in_trash": "U otpad", "asset_uploaded": "Otpremljeno (Uploaded)", "asset_uploading": "Otpremanje...", "assets": "Zapisi", @@ -841,6 +846,7 @@ "license_trial_info_4": "Molimo vas da razmislite o kupovini licence za podršku kontinuiranom razvoju usluge", "light": "Svetlo", "like_deleted": "Lajkuj izbrisano", + "link_motion_video": "Napravi vezu za video zapis", "link_options": "Opcije veze", "link_to_oauth": "Veza do OAuth-a", "linked_oauth_account": "Povezani OAuth nalog", @@ -1130,6 +1136,7 @@ "search_for_existing_person": "Potražite postojeću osobu", "search_no_people": "Bez osoba", "search_no_people_named": "Nema osoba sa imenom „{name}“", + "search_options": "Opcije pretrage", "search_people": "Pretraži osobe", "search_places": "Pretraži mesta", "search_state": "Traži region...", @@ -1208,6 +1215,8 @@ "sign_up": "Prijavi se", "size": "Veličina", "skip_to_content": "Pređi na sadržaj", + "skip_to_folders": "Preskoči do mapa (folders)", + "skip_to_tags": "Preskoči do oznaka (tags)", "slideshow": "Slajdovi", "slideshow_settings": "Podešavanja slajdova", "sort_albums_by": "Sortiraj albume po...", diff --git a/web/src/lib/i18n/sv.json b/web/src/lib/i18n/sv.json index 2a02ac7138f7f..cebb74377e4f2 100644 --- a/web/src/lib/i18n/sv.json +++ b/web/src/lib/i18n/sv.json @@ -192,7 +192,7 @@ "oauth_storage_quota_claim_description": "Sätter automatiskt angiven användares lagringskvot.", "oauth_storage_quota_default": "Standardlagringskvot (GiB)", "oauth_storage_quota_default_description": "Kvot i GiB som används när ingen fordran angetts (Ange 0 för obegränsad kvot).", - "offline_paths": "Offline-sökvägar", + "offline_paths": "Filer som inte kan hittas", "offline_paths_description": "Dessa resultat kan bero på manuell borttagning av filer som inte är en del av ett externt bibliotek.", "password_enable_description": "Logga in med epost och lösenord", "password_settings": "Lösenordsinloggning", @@ -235,10 +235,11 @@ "storage_template_onboarding_description": "Vid aktivering organiserar denna funktion automatiskt filer baserat på en användardefinierad mall. På grunda av stabilitetsproblem är denna funktion avstängd som standard, för mer information se dokumentation.", "storage_template_path_length": "Uppskattad längdbegränsning på sökväg: {length, number}/{limit, number}", "storage_template_settings": "Lagringsmall", - "storage_template_settings_description": "", + "storage_template_settings_description": "Hantera mappstruktur och filnamn för uppladdade resurser", + "storage_template_user_label": "{label} är användarens lagringsmärkning", "system_settings": "Systeminställningar", "theme_custom_css_settings": "Anpassad CSS", - "theme_custom_css_settings_description": "", + "theme_custom_css_settings_description": "Cascading Style Sheets möjliggör designanpassningar av Immich", "theme_settings": "Temainställningar", "theme_settings_description": "Hantera anpassningar av webbgränssnittet för Immich", "these_files_matched_by_checksum": "Dessa filer matchas av deras kontrollsummor", @@ -254,6 +255,7 @@ "transcoding_accepted_audio_codecs": "Accepterade ljud-codecs", "transcoding_accepted_audio_codecs_description": "Välj vilka ljud-codecs som inte behöver omkodas. Används endast för vissa omkodningspolicyer.", "transcoding_accepted_containers": "Accepterade behållare", + "transcoding_accepted_containers_description": "Välj vilka kontainerformat som inte behöver remuxas till MP4. Endast används för vissa transcoding-politischer.", "transcoding_accepted_video_codecs": "Accepterade video-codecs", "transcoding_accepted_video_codecs_description": "Välj vilka video-codecs som inte behöver omkodas. Används endast för vissa omkodningspolicyer.", "transcoding_advanced_options_description": "Val som de flesta användare inte bör behöva ändra", @@ -261,37 +263,37 @@ "transcoding_audio_codec_description": "Opus är bästa kvalitetsvalet, men är inte lika kompatibelt med äldre enheter eller mjukvara.", "transcoding_bitrate_description": "Videor som är i högre än max bithastighet eller inte i ett accepterat format", "transcoding_codecs_learn_more": "För att läsa mer om terminologin här se FFmpeg-dokumentationen för H.264 kodek, HEVC kodek och VP9 kodek.", - "transcoding_constant_quality_mode": "", - "transcoding_constant_quality_mode_description": "", - "transcoding_constant_rate_factor": "", - "transcoding_constant_rate_factor_description": "", - "transcoding_disabled_description": "", + "transcoding_constant_quality_mode": "Konstant kvalitetsläge", + "transcoding_constant_quality_mode_description": "ICQ är bättre än CQP, men vissa hårdvaruaccelerationsenheter stöder inte detta läge. Om det här alternativet är inställt föredras det angivna läget när kvalitetsbaserad kodning används. NVENC ignoreras eftersom det inte stöder ICQ.", + "transcoding_constant_rate_factor": "Konstant hastighetsfaktor (-crf)", + "transcoding_constant_rate_factor_description": "Nivå på videokvalitet. Typiska värden är 23 för H.264, 28 för HEVC, 31 för VP9 och 35 för AV1. Lägre är bättre, men producerar större filer.", + "transcoding_disabled_description": "Omkoda inte videofiler, detta kan störa uppspelning på vissa klienter", "transcoding_hardware_acceleration": "Hardvaruacceleration", - "transcoding_hardware_acceleration_description": "", + "transcoding_hardware_acceleration_description": "Forskningsmässig; betydligt snabbare men med lägre kvalitet vid samma biträtta", "transcoding_hardware_decoding": "Hårdvaruavkodning", - "transcoding_hardware_decoding_setting_description": "", + "transcoding_hardware_decoding_setting_description": "Tillämpas enbart på NVENC, QSV och RKMPP. Aktiverar end-to-end accelerering i stället för endast kodningsacceleration. Fungerar inte med alla videor.", "transcoding_hevc_codec": "HEVC-codec", - "transcoding_max_b_frames": "", + "transcoding_max_b_frames": "Max B-ramar", "transcoding_max_b_frames_description": "Högre värden förbättrar kompressionseffektiviteten, men saktar ner kodningen. Kan vara inkompatibel med hårdvaruacceleration på äldre enheter. 0 avaktiverar B-frames, medan -1 anger detta värde automatiskt.", "transcoding_max_bitrate": "Max bithastighet", - "transcoding_max_bitrate_description": "", + "transcoding_max_bitrate_description": "En maximal bitrate kan göra filstorlekar mer förutsägbara till en liten kostnad på kvalitet. Vid 720p är typiska värden 2600k för VP9 eller HEVC, eller 4500k för H.264. Inaktiverad om satt till 0.", "transcoding_max_keyframe_interval": "Max nyckelbildruteintervall", - "transcoding_max_keyframe_interval_description": "", - "transcoding_optimal_description": "", - "transcoding_preferred_hardware_device": "", - "transcoding_preferred_hardware_device_description": "", + "transcoding_max_keyframe_interval_description": "Sätter det maximala bildruteavståndet mellan nyckelbildrutor. Lägre värden försämrar kompressionseffektiviteten, men förbättrar söktiderna och kan förbättra kvaliteten i scener med snabb rörelse. 0 ställer in detta värde automatiskt.", + "transcoding_optimal_description": "Videor som är högre än mållösning eller inte i ett accepterat format", + "transcoding_preferred_hardware_device": "Föredragen hårdvaruenhet", + "transcoding_preferred_hardware_device_description": "Gäller enbart VAAPI och QSV. Ställer in dri-läget som används för hårdvaruomkodning.", "transcoding_preset_preset": "Förinställning (-preset)", - "transcoding_preset_preset_description": "", - "transcoding_reference_frames": "", - "transcoding_reference_frames_description": "", - "transcoding_required_description": "", - "transcoding_settings": "", - "transcoding_settings_description": "", + "transcoding_preset_preset_description": "Kompressionshastighet. Långsammare preset ger mindre filer och högre kvalitet för en given bitrate. VP9 ignorerar hastigheter högre än 'faster'.", + "transcoding_reference_frames": "Referensbildrutor", + "transcoding_reference_frames_description": "Antalet bildrutor som tas i beaktande när en given bildruta ska komprimeras. Högre värden ger effektivare kompression på bekostnad av långsammare kodning. 0 ställer in detta värde automatiskt.", + "transcoding_required_description": "Enbart videos som inte är ett accepterat format", + "transcoding_settings": "Inställningar för omkodning av video", + "transcoding_settings_description": "Hantera upplösningen och kodningen av videofiler", "transcoding_target_resolution": "", - "transcoding_target_resolution_description": "", + "transcoding_target_resolution_description": "En högre upplösning kan bevara fler detaljer men kan ta längre tid at koda, ha större fil storlek och kan försämra appens svarstid.", "transcoding_temporal_aq": "", "transcoding_temporal_aq_description": "", - "transcoding_threads": "", + "transcoding_threads": "Trådar", "transcoding_threads_description": "", "transcoding_tone_mapping": "", "transcoding_tone_mapping_description": "", diff --git a/web/src/lib/i18n/uk.json b/web/src/lib/i18n/uk.json index 1d5fe69dd325d..64411ef7586f4 100644 --- a/web/src/lib/i18n/uk.json +++ b/web/src/lib/i18n/uk.json @@ -139,7 +139,11 @@ "map_settings_description": "Управління налаштуваннями мапи", "map_style_description": "URL до теми мапи у форматі style.json", "metadata_extraction_job": "Витягнути метадані", - "metadata_extraction_job_description": "Витягнення метаданих інформації з кожного ресурсу, таких як GPS-координати та роздільність", + "metadata_extraction_job_description": "Витягни метадані з кожного об'єкта, таку як GPS, обличчя та роздільна здатність", + "metadata_faces_import_setting": "Увімкни імпорт облич", + "metadata_faces_import_setting_description": "Імпортуй обличчя з EXIF-даних зображень та додаткових файлів", + "metadata_settings": "Налаштування метаданих", + "metadata_settings_description": "Керуй налаштуваннями метаданих", "migration_job": "Міграція", "migration_job_description": "Перемістіть мініатюри для ресурсів та обличчя до оновленої структури папок", "no_paths_added": "Шляхи не додано", @@ -387,6 +391,7 @@ "asset_offline": "Актив вимкнено", "asset_offline_description": "Цей ресурс відключений. Immich не може отримати доступ до його місцезнаходження файлів. Будь ласка, переконайтеся, що ресурс доступний, а потім знову проскануйте бібліотеку.", "asset_skipped": "Пропущено", + "asset_skipped_in_trash": "У смітнику", "asset_uploaded": "Завантажено", "asset_uploading": "Завантаження...", "assets": "елементи", @@ -1128,6 +1133,7 @@ "search_for_existing_person": "Пошук існуючої особи", "search_no_people": "Немає людей", "search_no_people_named": "Немає осіб з іменем \"{name}\"", + "search_options": "Опції пошуку", "search_people": "Шукати людей", "search_places": "Пошук місць", "search_state": "Пошук регіону...", @@ -1206,6 +1212,8 @@ "sign_up": "Зареєструватися", "size": "Розмір", "skip_to_content": "Перейти до вмісту", + "skip_to_folders": "Перейти до папок", + "skip_to_tags": "Перейти до тегів", "slideshow": "Слайдшоу", "slideshow_settings": "Налаштування слайд-шоу", "sort_albums_by": "Сортувати альбоми за...", diff --git a/web/src/lib/i18n/vi.json b/web/src/lib/i18n/vi.json index 4192e7e5f06a7..c4f23ec273520 100644 --- a/web/src/lib/i18n/vi.json +++ b/web/src/lib/i18n/vi.json @@ -391,6 +391,7 @@ "asset_offline": "Ảnh ngoại tuyến", "asset_offline_description": "Tập tin này đang ngoại tuyến. Immich không thể truy cập vị trí tập tin của nó. Vui lòng đảm bảo tập tin có sẵn và sau đó quét lại thư viện.", "asset_skipped": "Đã bỏ qua", + "asset_skipped_in_trash": "Trong thùng rác", "asset_uploaded": "Đã tải lên", "asset_uploading": "Đang tải lên...", "assets": "Các tập tin", @@ -816,6 +817,7 @@ "library_options": "Tùy chọn thư viện", "light": "Sáng", "like_deleted": "Đã xoá thích", + "link_motion_video": "Liên kết video chuyển động", "link_options": "Tùy chọn liên kết", "link_to_oauth": "Liên kết đến OAuth", "linked_oauth_account": "Tài khoản OAuth đã liên kết", @@ -1104,6 +1106,7 @@ "search_for_existing_person": "Tìm kiếm người hiện có", "search_no_people": "Không có người", "search_no_people_named": "Không có người tên \"{name}\"", + "search_options": "Tùy chọn tìm kiếm", "search_people": "Tìm kiếm người", "search_places": "Tìm kiếm địa điểm", "search_state": "Tìm kiếm tỉnh...", diff --git a/web/src/lib/i18n/zh_Hant.json b/web/src/lib/i18n/zh_Hant.json index d2aa589da001e..30e32f60c9ed1 100644 --- a/web/src/lib/i18n/zh_Hant.json +++ b/web/src/lib/i18n/zh_Hant.json @@ -39,7 +39,7 @@ "confirm_delete_library": "確定要刪除「{library}」(圖庫)嗎?", "confirm_delete_library_assets": "您確定要刪除此圖庫嗎?這將從 Immich 中刪除{count, plural, one {個項目} other {個項目}},且無法復原。檔案仍會保留在硬碟中。", "confirm_email_below": "請在底下輸入 {email} 來確認", - "confirm_reprocess_all_faces": "您確定要重新處理所有面孔嗎?這將清除已命名的面孔。", + "confirm_reprocess_all_faces": "確定要重新處理所有臉孔嗎?這會清除已命名的人物。", "confirm_user_password_reset": "您確定要重設 {user} 的密碼嗎?", "crontab_guru": "", "disable_login": "停用登入", @@ -48,7 +48,7 @@ "exclusion_pattern_description": "排除規則讓您在掃描資料庫時忽略特定文件和文件夾。用於當您有不想導入的文件(例如 RAW 文件)或文件夾。", "external_library_created_at": "外部圖庫(於 {date} 建立)", "external_library_management": "外部圖庫管理", - "face_detection": "面孔偵測", + "face_detection": "臉孔偵測", "face_detection_description": "使用機器學習檢測資料中的人臉。影片檔只會偵測縮圖。選擇「全部」將重新處理所有資料。選擇「缺失」將把尚未處理的資料加入處理佇列中。被檢測到的人臉將在所有人臉檢測完成後,排入人臉識別佇列中,並將它們分配到現有或新的人物中。", "facial_recognition_job_description": "將檢測到的人臉分組到人物中。此步驟將在人臉檢測完成後運行。選擇「全部」將重新分類所有人臉。選擇「缺失」將把沒有分配人物的人臉排入佇列。", "failed_job_command": "{job} 任務的 {command} 指令執行失敗", @@ -103,7 +103,7 @@ "machine_learning_enabled": "啟用機器學習", "machine_learning_enabled_description": "若停用,則無視下方的設定,所有機器學習的功能都將停用。", "machine_learning_facial_recognition": "臉部辨識", - "machine_learning_facial_recognition_description": "針測、分辨、規類影像中的人臉", + "machine_learning_facial_recognition_description": "偵測、認出並對圖片中的臉孔分組", "machine_learning_facial_recognition_model": "人臉辨識模型", "machine_learning_facial_recognition_model_description": "模型順序由大至小排列。大的模型較慢且使用較多記憶體,但成效較嘉。更換模型後須對所有影像重新執行「人臉辨識」。", "machine_learning_facial_recognition_setting": "啟用人臉辨識", @@ -114,7 +114,7 @@ "machine_learning_max_recognition_distance_description": "若兩張人臉間的距離小於此將被判斷為相同人物,範圍為 0-2。數值降低能減少兩人被混在一起的可能性,數值提升能減少同一人被當作不同臉的可能性。由於合並比拆分容易,建議將數值調小。", "machine_learning_min_detection_score": "最低檢測分數", "machine_learning_min_detection_score_description": "最低信任分辨率,從0到1。低值會偵測更多的面孔,但可能導致誤報。", - "machine_learning_min_recognized_faces": "最少認出的臉", + "machine_learning_min_recognized_faces": "最少被認出的臉孔", "machine_learning_min_recognized_faces_description": "要創建一個人的最低認可面數。 增加此項數目使面部識別更為準確,但以增加可能不把面孔識別於任何人的機會為代價.", "machine_learning_settings": "機器學習設定", "machine_learning_settings_description": "管理機器學習的功能和設定", @@ -139,7 +139,11 @@ "map_settings_description": "管理地圖設定", "map_style_description": "地圖主題(style.json)的網址", "metadata_extraction_job": "擷取元資料", - "metadata_extraction_job_description": "擷取每個檔案的 GPS、解析度等元資料資訊", + "metadata_extraction_job_description": "擷取每個檔案的 GPS、臉孔、解析度等元資料資訊", + "metadata_faces_import_setting": "啟用臉孔匯入", + "metadata_faces_import_setting_description": "從圖片的 EXIF 資料和側接檔案匯入臉孔", + "metadata_settings": "元資料設定", + "metadata_settings_description": "管理元資料設定", "migration_job": "遷移", "migration_job_description": "將照片和人臉的縮圖遷移到最新的文件夾結構", "no_paths_added": "未添加路徑", @@ -180,7 +184,7 @@ "oauth_scope": "範圍", "oauth_settings": "OAuth", "oauth_settings_description": "管理 OAuth 登入設定", - "oauth_settings_more_details": "欲瞭解此功能,請參閱文件。", + "oauth_settings_more_details": "欲瞭解此功能,請參閱說明書。", "oauth_signing_algorithm": "簽名算法", "oauth_storage_label_claim": "儲存標籤宣告", "oauth_storage_label_claim_description": "自動將使用者的儲存標籤定爲此宣告之值。", @@ -258,7 +262,7 @@ "transcoding_audio_codec": "音頻編解碼器", "transcoding_audio_codec_description": "Opus 是音質最高的選擇,但會與舊設備或軟件有較低的兼容性。", "transcoding_bitrate_description": "比特率高於最大比特率或格式不被接受的視頻", - "transcoding_codecs_learn_more": "欲了解此處使用的術語,請參閱 FFmpeg 文檔中的 H.264 編解碼器HEVC 編解碼器VP9 編解碼器。", + "transcoding_codecs_learn_more": "欲瞭解此處使用的術語,請參閱 FFmpeg 說明書中的 H.264 編解碼器HEVC 編解碼器VP9 編解碼器。", "transcoding_constant_quality_mode": "恆定質量模式", "transcoding_constant_quality_mode_description": "ICQ 比 CQP 更好,但某些硬件加速設備不支持此模式。設置此選項時,會在使用基於質量的編碼時偏好指定的模式。由於 NVENC 不支持 ICQ,此選項對其無效。", "transcoding_constant_rate_factor": "恆定速率因子(-crf)", @@ -387,6 +391,7 @@ "asset_offline": "檔案離線", "asset_offline_description": "此檔案己離線。Immich 無法訪問其文件位置。請確保資產可用,然後重新掃描資料庫。", "asset_skipped": "已略過", + "asset_skipped_in_trash": "已丟掉", "asset_uploaded": "已上傳", "asset_uploading": "上傳中…", "assets": "檔案", @@ -549,7 +554,7 @@ "edit_date": "編輯日期", "edit_date_and_time": "編輯日期與時間", "edit_exclusion_pattern": "編輯排除模式", - "edit_faces": "編輯人面", + "edit_faces": "編輯臉孔", "edit_import_path": "編輯匯入路徑", "edit_import_paths": "編輯匯入路徑", "edit_key": "編輯密鑰", @@ -584,7 +589,7 @@ "cant_change_activity": "無法{enabled, select, true {禁用} other {啟用}}活動", "cant_change_asset_favorite": "無法更改檔案的收藏狀態", "cant_change_metadata_assets_count": "無法更改 {count, plural, other {# 個檔案}}的元資料", - "cant_get_faces": "無法獲取面孔", + "cant_get_faces": "無法取得臉孔", "cant_get_number_of_comments": "無法獲取評論數量", "cant_search_people": "無法搜尋人", "cant_search_places": "無法搜尋地點", @@ -655,6 +660,7 @@ "unable_to_get_comments_number": "無法獲取評論數量", "unable_to_get_shared_link": "取得分享鏈結失敗", "unable_to_hide_person": "無法隱藏人物", + "unable_to_link_motion_video": "無法鏈結動態影片", "unable_to_link_oauth_account": "無法連結 OAuth 帳戶", "unable_to_load_album": "無法載入相簿", "unable_to_load_asset_activity": "無法載入檔案活動", @@ -695,6 +701,7 @@ "unable_to_submit_job": "無法提交作業", "unable_to_trash_asset": "無法將檔案丟進垃圾桶", "unable_to_unlink_account": "無法對帳號取消連接", + "unable_to_unlink_motion_video": "無法取消鏈結動態影片", "unable_to_update_album_cover": "無法更新相簿封面", "unable_to_update_album_info": "無法更新相簿資訊", "unable_to_update_library": "無法更新資料庫", @@ -811,6 +818,7 @@ "library_options": "資料庫選項", "light": "淺色", "like_deleted": "已刪除的收藏", + "link_motion_video": "鏈結動態影片", "link_options": "鏈結選項", "link_to_oauth": "連接 OAuth", "linked_oauth_account": "已連接 OAuth 帳號", @@ -850,7 +858,7 @@ "menu": "選單", "merge": "合併", "merge_people": "合併人物", - "merge_people_limit": "您一次最多只能合併 5 張臉部", + "merge_people_limit": "一次最多只能合併 5 張臉孔", "merge_people_prompt": "您要合併這些人物嗎?此操作無法撤銷。", "merge_people_successfully": "成功合併人物", "merged_people_count": "合併了 {count, plural, one {# 位人士} other {# 位人士}}", @@ -1098,6 +1106,7 @@ "search_for_existing_person": "搜尋現有的人物", "search_no_people": "沒有人找到", "search_no_people_named": "沒有名爲「{name}」的人物", + "search_options": "搜尋選項", "search_people": "搜尋人物", "search_places": "搜尋地點", "search_state": "搜尋地區…", @@ -1176,6 +1185,8 @@ "sign_up": "註冊", "size": "用量", "skip_to_content": "跳至內容", + "skip_to_folders": "跳到資料夾", + "skip_to_tags": "跳到標記", "slideshow": "幻燈片", "slideshow_settings": "幻燈片設定", "sort_albums_by": "相簿排序方式", @@ -1227,6 +1238,7 @@ "to_change_password": "更改密碼", "to_favorite": "收藏", "to_login": "登入", + "to_parent": "到上一級", "to_root": "到根", "to_trash": "垃圾桶", "toggle_settings": "切換設定", @@ -1249,6 +1261,7 @@ "unknown_album": "", "unknown_year": "不知年份", "unlimited": "不限制", + "unlink_motion_video": "取消鏈結動態影片", "unlink_oauth": "取消連接 OAuth", "unlinked_oauth_account": "已解除連接 OAuth 帳號", "unnamed_album": "未命名相簿", diff --git a/web/src/lib/i18n/zh_SIMPLIFIED.json b/web/src/lib/i18n/zh_SIMPLIFIED.json index afe01754c16b1..b56c2d29ebd94 100644 --- a/web/src/lib/i18n/zh_SIMPLIFIED.json +++ b/web/src/lib/i18n/zh_SIMPLIFIED.json @@ -20,27 +20,27 @@ "add_partner": "添加同伴", "add_path": "添加路径", "add_photos": "添加照片", - "add_to": "添加至...", - "add_to_album": "添加至相册", - "add_to_shared_album": "添加至共享相册", - "added_to_archive": "添加至归档", - "added_to_favorites": "添加至收藏", - "added_to_favorites_count": "添加{count, number}项至收藏", + "add_to": "添加到...", + "add_to_album": "添加到相册", + "add_to_shared_album": "添加到共享相册", + "added_to_archive": "添加到归档", + "added_to_favorites": "添加到收藏", + "added_to_favorites_count": "添加{count, number}项到收藏", "admin": { - "add_exclusion_pattern_description": "添加排除规则。支持使用 *、** 和 ? 进行通配。要忽略名为 “Raw” 的任何目录中的所有文件,请使用 “**/Raw/**”。要忽略所有以 “.tif” 结尾的文件,请使用 “**/*.tif”。要忽略绝对路径,请使用 “/path/to/ignore/**”。", + "add_exclusion_pattern_description": "添加排除规则。支持使用 *、** 和 ? 通配符。比如要忽略名为 “Raw” 的任何目录中的所有文件,请使用 “**/Raw/**”;要忽略所有以 “.tif” 结尾的文件,请使用 “**/*.tif”;要忽略绝对路径,请使用 “/path/to/ignore/**”。", "authentication_settings": "认证设置", "authentication_settings_description": "管理密码、OAuth 和其它认证设置", - "authentication_settings_disable_all": "确定要禁用所有的登录方式?此操作将完全禁用登录。", + "authentication_settings_disable_all": "确定要禁用所有的登录方式?此操作将完全禁止登录。", "authentication_settings_reenable": "如需再次启用,使用 服务器指令。", "background_task_job": "后台任务", "check_all": "检查全部", "cleared_jobs": "已清理作业:{job}", "config_set_by_file": "当前配置已通过配置文件设置", - "confirm_delete_library": "是否确定要删除图库{library} ?", - "confirm_delete_library_assets": "是否确定要删除该图库?这将删除所有包含在Immich中的{count, plural, one {#个项目} other {#个项目}},且无法撤销。文件仍将保留在磁盘中。", + "confirm_delete_library": "确定要删除图库“{library}”吗?", + "confirm_delete_library_assets": "确定要删除该图库吗?这将删除所有包含在Immich中的{count, plural, one {#个项目} other {#个项目}},且无法撤销。但文件仍将保留在磁盘中。", "confirm_email_below": "输入“{email}”来确认", - "confirm_reprocess_all_faces": "是否确定对全部照片重新进行面部识别?这将同时清除所有已命名人物。", - "confirm_user_password_reset": "是否确定重置用户{user}的密码?", + "confirm_reprocess_all_faces": "确定要对全部照片重新进行面部识别吗?这将同时清除所有已命名人物。", + "confirm_user_password_reset": "确定要重置用户{user}的密码吗?", "crontab_guru": "Crontab Guru", "disable_login": "禁用登录", "disabled": "已禁用", @@ -81,10 +81,10 @@ "library_cron_expression_description": "使用 cron 格式设置扫描间隔。关于 cron 格式请参阅Crontab Guru", "library_cron_expression_presets": "Cron 表达式预设", "library_deleted": "图库已删除", - "library_import_path_description": "指定一个要导入的文件夹。该文件夹及其子文件夹将被扫描以查找图片和视频。", + "library_import_path_description": "指定一个要导入的文件夹。将扫描此文件夹(包括子文件夹)中的图像和视频。", "library_scanning": "定期扫描", - "library_scanning_description": "配置定期图库扫描", - "library_scanning_enable_description": "启用定期图库扫描", + "library_scanning_description": "配置定期扫描图库", + "library_scanning_enable_description": "启用定期扫描图库", "library_settings": "外部图库", "library_settings_description": "管理外部图库设置", "library_tasks_description": "执行图库任务", @@ -101,12 +101,12 @@ "machine_learning_duplicate_detection_enabled_description": "如果禁用此功能,完全相同的项目仍将被去重。", "machine_learning_duplicate_detection_setting_description": "使用CLIP向量匹配(关键词相似度)来查找可能的重复项", "machine_learning_enabled": "启用机器学习", - "machine_learning_enabled_description": "如果禁用,不论以下设置如何,所有机器学习功能将被禁用。", + "machine_learning_enabled_description": "如果禁用,无论以下如何设置,所有机器学习功能将被禁用。", "machine_learning_facial_recognition": "人脸识别", - "machine_learning_facial_recognition_description": "检测、识别并分组图像中的人脸", + "machine_learning_facial_recognition_description": "检测、识别并将图像中的人脸分组", "machine_learning_facial_recognition_model": "人脸识别模型", "machine_learning_facial_recognition_model_description": "机器学习模型按规模大小降序排列。更大的模型速度更慢,占用的内存更多,但效果更好。请注意,在更换模型后,必须对所有图像重新运行人脸检测。", - "machine_learning_facial_recognition_setting": "启用面部识别", + "machine_learning_facial_recognition_setting": "启用人脸识别", "machine_learning_facial_recognition_setting_description": "如果禁用此功能,图片将不会被编码并用于人脸识别,也不会在探索页面显示人物列表。", "machine_learning_max_detection_distance": "最大检测距离", "machine_learning_max_detection_distance_description": "两张图片被认为是重复的最大距离范围是0.001到0.1。较高的值将检测出更多的重复图片,但可能导致误报。", @@ -115,7 +115,7 @@ "machine_learning_min_detection_score": "最低检测分数", "machine_learning_min_detection_score_description": "面部被检测到的最小置信度分数范围是0到1。较低的值将检测到更多的面孔,但可能导致误报。", "machine_learning_min_recognized_faces": "识别的最少人脸数", - "machine_learning_min_recognized_faces_description": "创建新人物所需最少面部识别等数量。提高这个数字可以使面部识别更精确,但也增加了面孔未能被分配到对应人物的可能性。", + "machine_learning_min_recognized_faces_description": "创建新人物所需最少识别的数量。提高这个值可以使面部识别更精确,但也增加了面孔未能被分配到对应人物的可能性。", "machine_learning_settings": "机器学习设置", "machine_learning_settings_description": "管理机器学习功能和设置", "machine_learning_smart_search": "智能搜索", @@ -139,7 +139,7 @@ "map_settings_description": "管理地图设置", "map_style_description": "地图主题 style.json 的 URL", "metadata_extraction_job": "提取元数据", - "metadata_extraction_job_description": "从每个项目中提取元数据信息,如GPS和分辨率", + "metadata_extraction_job_description": "从每个项目中提取元数据信息,如GPS、人脸和分辨率", "metadata_faces_import_setting": "启用人脸导入", "metadata_faces_import_setting_description": "从图片的EXIF和辅助元数据中导入人脸", "metadata_settings": "元数据设置", @@ -149,10 +149,10 @@ "no_paths_added": "无已添加路径", "no_pattern_added": "无已添加规则", "note_apply_storage_label_previous_assets": "提示:要将存储标签应用于之前上传的项目,运行以下命令", - "note_cannot_be_changed_later": "注意:此项一旦设定,后续无法更改!", - "note_unlimited_quota": "提示:输入0表示无限制配额", + "note_cannot_be_changed_later": "注意:此项一旦设定,以后无法更改!", + "note_unlimited_quota": "提示:输入0表示无限制", "notification_email_from_address": "发件人地址", - "notification_email_from_address_description": "发件人邮箱地址,例如“Immich Photo Server ”", + "notification_email_from_address_description": "发件人邮箱地址,例如“Immich 服务器 ”", "notification_email_host_description": "邮件服务器主机(例如 smtp.immich.app)", "notification_email_ignore_certificate_errors": "忽略证书错误", "notification_email_ignore_certificate_errors_description": "忽略TLS证书验证错误(不建议)", @@ -161,8 +161,8 @@ "notification_email_sent_test_email_button": "发送测试邮件并保存", "notification_email_setting_description": "发送邮件通知设置", "notification_email_test_email": "发送测试邮件", - "notification_email_test_email_failed": "发送测试邮件失败,检查你的输入", - "notification_email_test_email_sent": "已向{email}发送了一封测试邮件。请检查您的收件箱。", + "notification_email_test_email_failed": "发送测试邮件失败,请检查你输入的信息", + "notification_email_test_email_sent": "已向{email}发送了一封测试邮件,请注意查收。", "notification_email_username_description": "与邮件服务器进行身份验证时使用的用户名", "notification_enable_email_notifications": "启用邮件通知", "notification_settings": "通知设置", @@ -173,9 +173,9 @@ "oauth_auto_register_description": "使用OAuth登录后自动注册新用户", "oauth_button_text": "按钮文本", "oauth_client_id": "客户端ID", - "oauth_client_secret": "客户端密匙", + "oauth_client_secret": "客户端密钥", "oauth_enable_description": "使用OAuth登录", - "oauth_issuer_url": "发行方的网址", + "oauth_issuer_url": "发行方网址", "oauth_mobile_redirect_uri": "移动端重定向 URI", "oauth_mobile_redirect_uri_override": "移动端重定向 URI 覆盖", "oauth_mobile_redirect_uri_override_description": "当 OAuth 提供商不允许使用移动 URI 时启用,如“'{callback}'”", @@ -184,14 +184,14 @@ "oauth_scope": "范围", "oauth_settings": "OAuth", "oauth_settings_description": "管理OAuth登录设置", - "oauth_settings_more_details": "关于本功能的更多详细信息,请见文档。", + "oauth_settings_more_details": "关于本功能的更多详细信息,请查看相关文档。", "oauth_signing_algorithm": "签名算法", "oauth_storage_label_claim": "存储标签声明", "oauth_storage_label_claim_description": "自动将用户的存储标签设置为此项的值。", "oauth_storage_quota_claim": "存储配额声明", "oauth_storage_quota_claim_description": "自动将用户的存储配额设置为此项的值。", "oauth_storage_quota_default": "默认存储配额(GB)", - "oauth_storage_quota_default_description": "没有提供声明时,要使用的GB配额(输入0表示无限制配额)。", + "oauth_storage_quota_default_description": "没有提供声明时,要使用的配额大小(GB)(输入0表示无限制)。", "offline_paths": "离线文件", "offline_paths_description": "这可能是由于手动删除了不属于外部图库的文件。", "password_enable_description": "使用邮箱和密码登录", @@ -201,16 +201,16 @@ "quota_size_gib": "配额大小(GB)", "refreshing_all_libraries": "刷新所有图库", "registration": "注册管理员", - "registration_description": "因为您是本系统的第一个用户,您被指派为管理员,负责相关管理工作,并且由您来创建新的用户。", + "registration_description": "由于您是系统上的第一个用户,您将被指定为管理员并负责管理任务,由您来创建新的用户。", "removing_offline_files": "移除离线文件", "repair_all": "修复所有", "repair_matched_items": "匹配到 {count, plural, one {#个项目} other {#个项目}}", "repaired_items": "已修复{count, plural, one {#个项目} other {#个项目}}", - "require_password_change_on_login": "要求用户第一次登录后修改密码", + "require_password_change_on_login": "要求用户首次登录时更改密码", "reset_settings_to_default": "恢复默认设置", "reset_settings_to_recent_saved": "恢复到最近保存的设置", - "scanning_library_for_changed_files": "扫描图库文件变更", - "scanning_library_for_new_files": "扫描图库文件增添", + "scanning_library_for_changed_files": "扫描图库变更的文件", + "scanning_library_for_new_files": "扫描图库新增的文件", "send_welcome_email": "发送欢迎邮件", "server_external_domain_settings": "外部域名", "server_external_domain_settings_description": "共享链接域名,包括 http(s)://", @@ -223,7 +223,7 @@ "slideshow_duration_description": "显示每张图像的秒数", "smart_search_job_description": "对项目进行机器学习处理以用于智能搜索", "storage_template_date_time_description": "使用项目的创建时间戳作为日期时间信息", - "storage_template_date_time_sample": "取样时间{date}", + "storage_template_date_time_sample": "采样时间{date}", "storage_template_enable_description": "启用存储模板", "storage_template_hash_verification_enabled": "哈希校验已启用", "storage_template_hash_verification_enabled_description": "启用哈希校验,如果您不知道此项的作用请不要禁用此功能", @@ -249,7 +249,7 @@ "transcoding_acceleration_api": "加速器API", "transcoding_acceleration_api_description": "这个API将与您的设备交互,以加速转码过程。此设置为“尽力而为”——如果转码失败,将回到软件转码。VP9是否工作取决于您的硬件配置。", "transcoding_acceleration_nvenc": "NVENC(需要 NVIDIA GPU)", - "transcoding_acceleration_qsv": "快速同步(需要 7代及以上的 Intel CPU)", + "transcoding_acceleration_qsv": "快速同步(需要Intel 7代及以上的 CPU)", "transcoding_acceleration_rkmpp": "RKMPP(仅适用于 Rockchip SOCs)", "transcoding_acceleration_vaapi": "VAAPI", "transcoding_accepted_audio_codecs": "支持的音频编解码器", @@ -274,18 +274,18 @@ "transcoding_hardware_decoding_setting_description": "仅适用于NVENC、QSV和RKMPP。启用端到端加速,而不仅仅是加速编码。可能并不适用于所有视频。", "transcoding_hevc_codec": "HEVC 编解码器", "transcoding_max_b_frames": "最大B帧数", - "transcoding_max_b_frames_description": "较高的值可以提高压缩效率,但会减慢编码速度。可能与旧设备上的硬件加速不兼容。0值将禁用B帧,而-1值将自动设置此参数。", + "transcoding_max_b_frames_description": "较高的值可以提高压缩效率,但会减慢编码速度。可能与旧设备上的硬件加速不兼容。0表示将禁用B帧,-1表示将自动设置此参数。", "transcoding_max_bitrate": "最高码率", "transcoding_max_bitrate_description": "设置最大比特率可以使文件大小更可控,而对质量的影响很小。在720p时,VP9或HEVC的典型值为2600k,而H.264的典型值则为4500k。如果此项设置为0,则不限制最大比特率。", "transcoding_max_keyframe_interval": "最大关键帧间隔", - "transcoding_max_keyframe_interval_description": "设置关键帧之间的最大帧距离。较低的值会降低压缩效率,但可以提高搜索速度,并且可能在快速运动的场景中提高画质。值设为0将自动设置此参数。", + "transcoding_max_keyframe_interval_description": "设置关键帧之间的最大帧距离。较低的值会降低压缩效率,但可以提高搜索速度,并且可能在快速运动的场景中提高画质。0表示将自动设置此参数。", "transcoding_optimal_description": "视频超过目标分辨率或格式不支持", "transcoding_preferred_hardware_device": "首选硬件设备", "transcoding_preferred_hardware_device_description": "仅适用于VAAPI和QSV。设置用于硬件转码的dri节点。", "transcoding_preset_preset": "预设(-preset)", "transcoding_preset_preset_description": "压缩速度。较慢的预设会产生更小的文件,并在目标特定比特率时提高质量。VP9请忽略faster以上的速度。", "transcoding_reference_frames": "参考帧", - "transcoding_reference_frames_description": "在压缩给定帧时参考的帧数。较高的值可以提高压缩效率,但会减慢编码速度。此项设置为0时将自动设置此参数。", + "transcoding_reference_frames_description": "在压缩给定帧时参考的帧数。较高的值可以提高压缩效率,但会减慢编码速度。0表示将自动设置此参数。", "transcoding_required_description": "仅限不兼容格式的视频", "transcoding_settings": "视频转码设置", "transcoding_settings_description": "管理视频文件的分辨率和编码信息", @@ -294,11 +294,11 @@ "transcoding_temporal_aq": "Temporal AQ", "transcoding_temporal_aq_description": "仅适用于NVENC。提高高细节、低动态场景的质量。可能与旧设备不兼容。", "transcoding_threads": "线程数", - "transcoding_threads_description": "设定值越高,编码速度越快,留给其它任务(Docker外宿主机的任务等)的计算能力越少。此值不应大于CPU核心的数量。如果值设置为0,则最大限度地提高利用率。", + "transcoding_threads_description": "设定值越高,编码速度越快,留给其它任务(Docker外宿主机的任务等)的计算能力越少。此值不应大于CPU核心的数量。0表示最大限度地提高利用率。", "transcoding_tone_mapping": "色调映射", "transcoding_tone_mapping_description": "在将HDR视频转换为SDR时,尝试保持其外观。每种算法在颜色、细节和亮度方面做出了不同的权衡。Hable算法保留细节,Mobius算法保留颜色,而Reinhard算法保留亮度。", "transcoding_tone_mapping_npl": "NPL色调映射", - "transcoding_tone_mapping_npl_description": "对于这种亮度的显示器,颜色将被调整到显示正常。与直觉相反,较低的值会增加视频的亮度,反之亦然,因为它会补偿显示器的亮度。填0将自动设置此值。", + "transcoding_tone_mapping_npl_description": "对于这种亮度的显示器,颜色将被调整到显示正常。与直觉相反,较低的值会增加视频的亮度,反之亦然,因为它会补偿显示器的亮度。0表示将自动设置此值。", "transcoding_transcode_policy": "转码策略", "transcoding_transcode_policy_description": "视频转码策略。HDR视频将始终进行转码(除非禁用了转码功能)。", "transcoding_two_pass_encoding": "二次编码", @@ -314,7 +314,7 @@ "untracked_files_description": "这些文件未被系统追踪。 这可能是移动失败、上传中断或因bug而落下", "user_delete_delay": "{user}的账户及项目将在{delay, plural, one {#天} other {#天}}后自动永久删除。", "user_delete_delay_settings": "延期删除", - "user_delete_delay_settings_description": "移除用户后永久删除账户及其所有项目的天数。用户删除作业在午夜运行,检查是否有用户可以删除。对该设置的更改将在下次执行时开始计算。", + "user_delete_delay_settings_description": "删除用户后永久删除账户及其所有项目的天数。用户删除作业在午夜运行,检查是否有用户可以删除。对该设置的更改将在下次执行时开始计算。", "user_delete_immediately": "{user}的账户及项目将立即永久删除。", "user_delete_immediately_checkbox": "立即删除检索到的用户及项目", "user_management": "用户管理", @@ -342,15 +342,15 @@ "album_added": "相册已添加", "album_added_notification_setting_description": "当您被添加到共享相册时,接收电子邮件通知", "album_cover_updated": "相册封面已更新", - "album_delete_confirmation": "是否确定要删除相册{album}?", + "album_delete_confirmation": "确定要删除相册“{album}”吗?", "album_delete_confirmation_description": "如果该相册是共享的,其他用户将无法再访问它。", "album_info_updated": "相册信息已更新", "album_leave": "退出相册?", - "album_leave_confirmation": "确定要退出相册{album}?", + "album_leave_confirmation": "确定要退出相册{album}吗?", "album_name": "相册名称", "album_options": "相册设置", "album_remove_user": "移除用户?", - "album_remove_user_confirmation": "你确定要移除{user}?", + "album_remove_user_confirmation": "你确定要移除{user}吗?", "album_share_no_users": "看起来您已与所有用户共享了此相册,或者您根本没有任何用户可共享。", "album_updated": "相册已更新", "album_updated_setting_description": "当共享相册有新项目时接收邮件通知", @@ -377,20 +377,21 @@ "archive": "归档", "archive_or_unarchive_photo": "归档或取消归档照片", "archive_size": "归档大小", - "archive_size_description": "配置下载归档大小(以GB为单位)", + "archive_size_description": "配置下载归档大小(GB)", "archived": "已归档", "archived_count": "{count, plural, other {已归档 # 项}}", - "are_these_the_same_person": "是否是同一个人?", + "are_these_the_same_person": "是同一个人吗?", "are_you_sure_to_do_this": "确定要这样做吗?", "asset_added_to_album": "已添加至相册", "asset_adding_to_album": "正在添加至相册...", "asset_description_updated": "项目描述已更新", "asset_filename_is_offline": "项目{filename}已离线", - "asset_has_unassigned_faces": "项目中有未认领的人脸", + "asset_has_unassigned_faces": "项目中有未分配的人脸", "asset_hashing": "哈希校验中...", "asset_offline": "项目离线", "asset_offline_description": "项目已离线。Immich无法访问该文件。请确保项目可读并重新扫描项目库。", "asset_skipped": "已跳过", + "asset_skipped_in_trash": "已回收", "asset_uploaded": "已上传", "asset_uploading": "上传中...", "assets": "项目", @@ -487,7 +488,7 @@ "create_new_person": "创建新人物", "create_new_person_hint": "指派已选择项目到新的人物", "create_new_user": "创建新用户", - "create_tag": "新建标签", + "create_tag": "创建标签", "create_tag_description": "创建一个新标签。对于嵌套标签,请输入标签的完整路径,包括正斜杠(/)。", "create_user": "创建用户", "created": "已创建", @@ -506,14 +507,14 @@ "default_locale_description": "根据您的浏览器地区设置日期和数字显示格式", "delete": "删除", "delete_album": "删除相册", - "delete_api_key_prompt": "是否确定删除此API key?", - "delete_duplicates_confirmation": "你是否希望永久删除这些重复项?", - "delete_key": "删除秘钥", + "delete_api_key_prompt": "确定删除此API key吗?", + "delete_duplicates_confirmation": "你要永久删除这些重复项吗?", + "delete_key": "删除密钥", "delete_library": "删除图库", "delete_link": "删除链接", "delete_shared_link": "删除共享链接", "delete_tag": "删除标签", - "delete_tag_confirmation_prompt": "您确定要删除{tagName}标签吗?", + "delete_tag_confirmation_prompt": "您确定要删除“{tagName}”标签吗?", "delete_user": "删除用户", "deleted_shared_link": "共享链接已删除", "description": "描述", @@ -575,13 +576,13 @@ "empty": "空", "empty_album": "清空相册", "empty_trash": "清空回收站", - "empty_trash_confirmation": "确定要清空回收站?这将从Immich永久移除回收站中的所有项目。\n该操作无法撤消!", + "empty_trash_confirmation": "确定要清空回收站?这将永久删除回收站中的所有项目。\n注意:该操作无法撤消!", "enable": "启用", "enabled": "已启用", "end_date": "结束日期", "error": "错误", "error_loading_image": "加载图片时出错", - "error_title": "错误 - 我们遇到了一些问题", + "error_title": "错误 - 出了点问题", "errors": { "cannot_navigate_next_asset": "无法导航到下一个项目", "cannot_navigate_previous_asset": "无法导航到上一个项目", @@ -597,7 +598,7 @@ "error_adding_assets_to_album": "添加项目到相册时出错", "error_adding_users_to_album": "添加用户到相册时出错", "error_deleting_shared_user": "删除共享用户时出错", - "error_downloading": "下载{filename}时出错", + "error_downloading": "下载“{filename}”时出错", "error_hiding_buy_button": "隐藏购买按钮时出错", "error_removing_assets_from_album": "从相册中移除项目时出错,请到控制台获取更详细信息", "error_selecting_all_assets": "选择所有项目时出错", @@ -614,7 +615,7 @@ "failed_to_stack_assets": "无法堆叠项目", "failed_to_unstack_assets": "无法取消堆叠项目", "import_path_already_exists": "此导入路径已存在。", - "incorrect_email_or_password": "错误的邮箱或密码", + "incorrect_email_or_password": "邮箱或密码错误", "paths_validation_failed": "{paths, plural, one {#条路径} other {#条路径}} 校验失败", "profile_picture_transparent_pixels": "个人资料图片不可以包含透明像素。请放大或移动此图片。", "quota_higher_than_disk_size": "设置的配额大于磁盘容量", @@ -625,8 +626,8 @@ "unable_to_add_exclusion_pattern": "无法添加排除规则", "unable_to_add_import_path": "无法添加导入路径", "unable_to_add_partners": "无法添加同伴", - "unable_to_add_remove_archive": "无法{archived, select, true {从归档中移除} other {添加项目至归档}}", - "unable_to_add_remove_favorites": "无法{favorite, select, true {添加项目至收藏} other {从收藏中移除}}", + "unable_to_add_remove_archive": "无法{archived, select, true {从归档中移除} other {添加项目到归档}}", + "unable_to_add_remove_favorites": "无法{favorite, select, true {添加项目到收藏} other {从收藏中移除}}", "unable_to_archive_unarchive": "无法{archived, select, true {归档} other {取消归档}}", "unable_to_change_album_user_role": "无法更改相册用户规则", "unable_to_change_date": "无法更改日期", @@ -660,6 +661,7 @@ "unable_to_get_comments_number": "无法获取评论数量", "unable_to_get_shared_link": "获取共享链接失败", "unable_to_hide_person": "无法隐藏人物", + "unable_to_link_motion_video": "无法链接到动态视频", "unable_to_link_oauth_account": "无法关联OAuth账户", "unable_to_load_album": "无法加载相册", "unable_to_load_asset_activity": "无法加载项目活动", @@ -697,9 +699,10 @@ "unable_to_scan_library": "无法扫描库", "unable_to_set_feature_photo": "无法设置人物头像", "unable_to_set_profile_picture": "无法设置个人资料图片", - "unable_to_submit_job": "无法提交作业", + "unable_to_submit_job": "无法提交任务", "unable_to_trash_asset": "无法放入回收站", "unable_to_unlink_account": "无法取消账户链接", + "unable_to_unlink_motion_video": "无法取消链接动态视频", "unable_to_update_album_cover": "无法更新相册封面", "unable_to_update_album_info": "无法更新相册信息", "unable_to_update_library": "无法更新库", @@ -762,7 +765,7 @@ "hi_user": "你好,{name}({email})", "hide_all_people": "隐藏所有人物", "hide_gallery": "隐藏相册", - "hide_named_person": "隐藏人物{name}", + "hide_named_person": "隐藏人物“{name}”", "hide_password": "隐藏密码", "hide_person": "隐藏人物", "hide_unnamed_people": "隐藏未命名的人物", @@ -806,7 +809,7 @@ "job_settings_description": "管理任务并发", "jobs": "任务", "keep": "保留", - "keep_all": "保留", + "keep_all": "保留所有", "keyboard_shortcuts": "键盘快捷键", "language": "语言", "language_setting_description": "选择您的语言偏好", @@ -845,6 +848,7 @@ "license_trial_info_4": "请考虑购买授权来支持此服务的持续开发", "light": "浅色", "like_deleted": "已删除的收藏", + "link_motion_video": "链接动态视频", "link_options": "链接选项", "link_to_oauth": "链接到OAuth", "linked_oauth_account": "绑定OAuth账户", @@ -852,15 +856,15 @@ "loading": "加载中", "loading_search_results_failed": "加载搜索结果失败", "log_out": "注销", - "log_out_all_devices": "登出所有设备", - "logged_out_all_devices": "从所有设备登出", - "logged_out_device": "从设备登出", + "log_out_all_devices": "注销所有设备", + "logged_out_all_devices": "从所有设备注销", + "logged_out_device": "从设备注销", "login": "登录", "login_has_been_disabled": "登录已禁用。", - "logout_all_device_confirmation": "确定要从所有设备登出?", - "logout_this_device_confirmation": "确定要从本设备登出?", + "logout_all_device_confirmation": "确定要从所有设备注销?", + "logout_this_device_confirmation": "确定要从本设备注销?", "longitude": "经度", - "look": "查看", + "look": "样式", "loop_videos": "循环视频", "loop_videos_description": "启用在详细信息中自动循环播放视频。", "make": "品牌", @@ -885,7 +889,7 @@ "merge": "合并", "merge_people": "合并人物", "merge_people_limit": "每次最多只能合并 5 个人", - "merge_people_prompt": "你想合并这些人吗?此操作不可逆转。", + "merge_people_prompt": "你想合并这些人吗?此操作不可逆。", "merge_people_successfully": "合并人物成功", "merged_people_count": "已合并{count, plural, one {#个人} other {#个人}}", "minimize": "最小化", @@ -941,7 +945,7 @@ "onboarding_privacy_description": "以下(可选)功能依赖外部服务,可随时在管理设置中禁用。", "onboarding_theme_description": "选择服务的颜色主题。稍后可以在设置中进行修改。", "onboarding_welcome_description": "我们在启用服务前先做一些通用设置。", - "onboarding_welcome_user": "欢迎,{user}", + "onboarding_welcome_user": "欢迎你,{user}", "online": "在线", "only_favorites": "仅显示已收藏", "only_refreshes_modified_files": "仅刷新修改的文件", @@ -981,7 +985,7 @@ "people": "人物", "people_edits_count": "{count, plural, one {#个人物} other {#个人物}}已编辑", "people_feature_description": "按人物分组进行浏览照片和视频", - "people_sidebar_description": "在侧边栏中显示指向人物的链接", + "people_sidebar_description": "在侧边栏中显示“人物”链接", "perform_library_tasks": "", "permanent_deletion_warning": "永久删除警告", "permanent_deletion_warning_setting_description": "当永久删除项目时显示警告", @@ -1031,7 +1035,7 @@ "purchase_button_select": "选择", "purchase_failed_activation": "激活失败!请检查您的邮箱以获取正确的产品密钥!", "purchase_individual_description_1": "适用于个人", - "purchase_individual_description_2": "Supporter 状态", + "purchase_individual_description_2": "支持者状态", "purchase_individual_title": "个人", "purchase_input_suggestion": "已有一个产品密钥?请在下方输入密钥", "purchase_license_subtitle": "购买 Immich 以支持此项目的持续发展", @@ -1100,7 +1104,7 @@ "reset_people_visibility": "重置人物可见性", "reset_settings_to_default": "恢复到默认设置", "reset_to_default": "恢复默认值", - "resolve_duplicates": "处理查复项", + "resolve_duplicates": "处理重复项", "resolved_all_duplicates": "解决所有重复问题", "restore": "恢复", "restore_all": "恢复所有", @@ -1134,6 +1138,7 @@ "search_for_existing_person": "搜索已有人物", "search_no_people": "找不到人物", "search_no_people_named": "人物“{name}”不存在", + "search_options": "搜索选项", "search_people": "搜索人物", "search_places": "搜索地点", "search_state": "搜索省份...", @@ -1240,7 +1245,7 @@ "stop_sharing_photos_with_user": "停止与此用户共享照片", "storage": "存储空间", "storage_label": "存储标签", - "storage_usage": "总容量:{available},已使用{used}", + "storage_usage": "总量:{available},已用{used}", "submit": "提交", "suggestions": "建议", "sunrise_on_the_beach": "海滩上的日出", @@ -1288,6 +1293,7 @@ "unknown_album": "未知相册", "unknown_year": "未知年份", "unlimited": "无限制", + "unlink_motion_video": "取消链接动态视频", "unlink_oauth": "解绑OAuth", "unlinked_oauth_account": "解绑OAuth账户", "unnamed_album": "未命名相册", From 22dc9bcebb354145a8c9a19bb339a54b38f20918 Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Wed, 11 Sep 2024 23:21:12 -0400 Subject: [PATCH 012/123] fix(ml): batch axis not being added for recognition model (#12588) * fix has_batch_axis * fix typing --- .../app/models/facial_recognition/recognition.py | 3 +-- machine-learning/app/sessions/__init__.py | 5 ----- 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/machine-learning/app/models/facial_recognition/recognition.py b/machine-learning/app/models/facial_recognition/recognition.py index d9ceb12b6d590..c060bdd61634f 100644 --- a/machine-learning/app/models/facial_recognition/recognition.py +++ b/machine-learning/app/models/facial_recognition/recognition.py @@ -13,7 +13,6 @@ from app.config import log from app.models.base import InferenceModel from app.models.transforms import decode_cv2 from app.schemas import FaceDetectionOutput, FacialRecognitionOutput, ModelFormat, ModelSession, ModelTask, ModelType -from app.sessions import has_batch_axis class FaceRecognizer(InferenceModel): @@ -27,7 +26,7 @@ class FaceRecognizer(InferenceModel): def _load(self) -> ModelSession: session = self._make_session(self.model_path) - if self.batch and not has_batch_axis(session): + if self.batch and str(session.get_inputs()[0].shape[0]) != "batch": self._add_batch_axis(self.model_path) session = self._make_session(self.model_path) self.model = ArcFaceONNX( diff --git a/machine-learning/app/sessions/__init__.py b/machine-learning/app/sessions/__init__.py index e0c00ea4a0472..e69de29bb2d1d 100644 --- a/machine-learning/app/sessions/__init__.py +++ b/machine-learning/app/sessions/__init__.py @@ -1,5 +0,0 @@ -from app.schemas import ModelSession - - -def has_batch_axis(session: ModelSession) -> bool: - return not isinstance(session.get_inputs()[0].shape[0], int) or session.get_inputs()[0].shape[0] < 0 From a68e6be7e11f0cfdafe48b2d01817189f8ee4383 Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Wed, 11 Sep 2024 23:21:33 -0400 Subject: [PATCH 013/123] chore(ml): remove deprecated kwarg when downloading models (#12589) remove local_dir_use_symlinks --- machine-learning/app/models/base.py | 1 - machine-learning/app/test_main.py | 2 -- 2 files changed, 3 deletions(-) diff --git a/machine-learning/app/models/base.py b/machine-learning/app/models/base.py index 1c019969b4790..3bbd1a02892d4 100644 --- a/machine-learning/app/models/base.py +++ b/machine-learning/app/models/base.py @@ -71,7 +71,6 @@ class InferenceModel(ABC): f"immich-app/{clean_name(self.model_name)}", cache_dir=self.cache_dir, local_dir=self.cache_dir, - local_dir_use_symlinks=False, ignore_patterns=ignore_patterns, ) diff --git a/machine-learning/app/test_main.py b/machine-learning/app/test_main.py index 17fdb5b1fadd2..5f8e5b9e9c0f9 100644 --- a/machine-learning/app/test_main.py +++ b/machine-learning/app/test_main.py @@ -124,7 +124,6 @@ class TestBase: "immich-app/ViT-B-32__openai", cache_dir=encoder.cache_dir, local_dir=encoder.cache_dir, - local_dir_use_symlinks=False, ignore_patterns=["*.armnn"], ) @@ -136,7 +135,6 @@ class TestBase: "immich-app/ViT-B-32__openai", cache_dir=encoder.cache_dir, local_dir=encoder.cache_dir, - local_dir_use_symlinks=False, ignore_patterns=[], ) From c3ff1b54af53b8a8cb7c42303fe4ab061d5b01cc Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Thu, 12 Sep 2024 09:45:38 -0400 Subject: [PATCH 014/123] fix(server): missing case break (#12595) * fix(server): missing break statement * fix(server): missing break statement --- server/src/services/job.service.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/server/src/services/job.service.ts b/server/src/services/job.service.ts index 58eba6245b7f9..aa61ccf3cb229 100644 --- a/server/src/services/job.service.ts +++ b/server/src/services/job.service.ts @@ -231,6 +231,7 @@ export class JobService { name: JobName.METADATA_EXTRACTION, data: { id: item.data.id, source: 'sidecar-write' }, }); + break; } case JobName.METADATA_EXTRACTION: { From 230eff4e1a537de9db40c2b2a2ad7fb5de487da0 Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 12 Sep 2024 15:37:59 +0000 Subject: [PATCH 015/123] chore: version v1.115.0 --- cli/package-lock.json | 6 +++--- cli/package.json | 2 +- docs/static/archived-versions.json | 4 ++++ e2e/package-lock.json | 8 ++++---- e2e/package.json | 2 +- machine-learning/pyproject.toml | 2 +- mobile/android/fastlane/Fastfile | 4 ++-- mobile/ios/fastlane/Fastfile | 2 +- mobile/openapi/README.md | 2 +- mobile/pubspec.yaml | 2 +- open-api/immich-openapi-specs.json | 2 +- open-api/typescript-sdk/package-lock.json | 4 ++-- open-api/typescript-sdk/package.json | 2 +- open-api/typescript-sdk/src/fetch-client.ts | 2 +- server/package-lock.json | 4 ++-- server/package.json | 2 +- web/package-lock.json | 8 ++++---- web/package.json | 2 +- 18 files changed, 32 insertions(+), 28 deletions(-) diff --git a/cli/package-lock.json b/cli/package-lock.json index 15a7ccd185122..3c187295525fc 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -1,12 +1,12 @@ { "name": "@immich/cli", - "version": "2.2.18", + "version": "2.2.19", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@immich/cli", - "version": "2.2.18", + "version": "2.2.19", "license": "GNU Affero General Public License version 3", "dependencies": { "fast-glob": "^3.3.2", @@ -52,7 +52,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.114.0", + "version": "1.115.0", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { diff --git a/cli/package.json b/cli/package.json index 34424f2957cec..287974e49b5f4 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@immich/cli", - "version": "2.2.18", + "version": "2.2.19", "description": "Command Line Interface (CLI) for Immich", "type": "module", "exports": "./dist/index.js", diff --git a/docs/static/archived-versions.json b/docs/static/archived-versions.json index c16413f4c5f49..18f3b0e40f3d5 100644 --- a/docs/static/archived-versions.json +++ b/docs/static/archived-versions.json @@ -1,4 +1,8 @@ [ + { + "label": "v1.115.0", + "url": "https://v1.115.0.archive.immich.app" + }, { "label": "v1.114.0", "url": "https://v1.114.0.archive.immich.app" diff --git a/e2e/package-lock.json b/e2e/package-lock.json index 575388b1f6284..8347bb12c6bb6 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich-e2e", - "version": "1.114.0", + "version": "1.115.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich-e2e", - "version": "1.114.0", + "version": "1.115.0", "license": "GNU Affero General Public License version 3", "devDependencies": { "@eslint/eslintrc": "^3.1.0", @@ -45,7 +45,7 @@ }, "../cli": { "name": "@immich/cli", - "version": "2.2.18", + "version": "2.2.19", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { @@ -92,7 +92,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.114.0", + "version": "1.115.0", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { diff --git a/e2e/package.json b/e2e/package.json index 6e98431aaac0f..6a6f2d9b76721 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -1,6 +1,6 @@ { "name": "immich-e2e", - "version": "1.114.0", + "version": "1.115.0", "description": "", "main": "index.js", "type": "module", diff --git a/machine-learning/pyproject.toml b/machine-learning/pyproject.toml index a69fb33a8d50e..cc7f74dfa9bf5 100644 --- a/machine-learning/pyproject.toml +++ b/machine-learning/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "machine-learning" -version = "1.114.0" +version = "1.115.0" description = "" authors = ["Hau Tran "] readme = "README.md" diff --git a/mobile/android/fastlane/Fastfile b/mobile/android/fastlane/Fastfile index c127032b19ee2..18243f55023bb 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" => 158, - "android.injected.version.name" => "1.114.0", + "android.injected.version.code" => 159, + "android.injected.version.name" => "1.115.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/ios/fastlane/Fastfile b/mobile/ios/fastlane/Fastfile index c1740771d98c4..870c9b8e31f44 100644 --- a/mobile/ios/fastlane/Fastfile +++ b/mobile/ios/fastlane/Fastfile @@ -19,7 +19,7 @@ platform :ios do desc "iOS Release" lane :release do increment_version_number( - version_number: "1.114.0" + version_number: "1.115.0" ) increment_build_number( build_number: latest_testflight_build_number + 1, diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index bb845157979b6..36b2c7bbf4613 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.114.0 +- API version: 1.115.0 - Generator version: 7.8.0 - Build package: org.openapitools.codegen.languages.DartClientCodegen diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 3db5457c8c1c1..0061f563d2984 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.114.0+158 +version: 1.115.0+159 environment: sdk: '>=3.3.0 <4.0.0' diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 77c2ab127f87a..b4ec4505b9e2d 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -7394,7 +7394,7 @@ "info": { "title": "Immich", "description": "Immich API", - "version": "1.114.0", + "version": "1.115.0", "contact": {} }, "tags": [], diff --git a/open-api/typescript-sdk/package-lock.json b/open-api/typescript-sdk/package-lock.json index da50429cc52e7..170ec83d7abbd 100644 --- a/open-api/typescript-sdk/package-lock.json +++ b/open-api/typescript-sdk/package-lock.json @@ -1,12 +1,12 @@ { "name": "@immich/sdk", - "version": "1.114.0", + "version": "1.115.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@immich/sdk", - "version": "1.114.0", + "version": "1.115.0", "license": "GNU Affero General Public License version 3", "dependencies": { "@oazapfts/runtime": "^1.0.2" diff --git a/open-api/typescript-sdk/package.json b/open-api/typescript-sdk/package.json index 46c5ac8c56260..7773f3b71c537 100644 --- a/open-api/typescript-sdk/package.json +++ b/open-api/typescript-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@immich/sdk", - "version": "1.114.0", + "version": "1.115.0", "description": "Auto-generated TypeScript SDK for the Immich API", "type": "module", "main": "./build/index.js", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 021551d947a52..9350bd5604507 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -1,6 +1,6 @@ /** * Immich - * 1.114.0 + * 1.115.0 * DO NOT MODIFY - This file has been generated using oazapfts. * See https://www.npmjs.com/package/oazapfts */ diff --git a/server/package-lock.json b/server/package-lock.json index 048c51c520375..1f9514fddefa3 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich", - "version": "1.114.0", + "version": "1.115.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "immich", - "version": "1.114.0", + "version": "1.115.0", "license": "GNU Affero General Public License version 3", "dependencies": { "@nestjs/bullmq": "^10.0.1", diff --git a/server/package.json b/server/package.json index f9fcbde550d5d..a1b5a6b269c2f 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "immich", - "version": "1.114.0", + "version": "1.115.0", "description": "", "author": "", "private": true, diff --git a/web/package-lock.json b/web/package-lock.json index 0bf82f26b7585..09e1f49f12d67 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich-web", - "version": "1.114.0", + "version": "1.115.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich-web", - "version": "1.114.0", + "version": "1.115.0", "license": "GNU Affero General Public License version 3", "dependencies": { "@formatjs/icu-messageformat-parser": "^2.7.8", @@ -74,13 +74,13 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.114.0", + "version": "1.115.0", "license": "GNU Affero General Public License version 3", "dependencies": { "@oazapfts/runtime": "^1.0.2" }, "devDependencies": { - "@types/node": "^20.16.2", + "@types/node": "^20.16.5", "typescript": "^5.3.3" } }, diff --git a/web/package.json b/web/package.json index b59835b80c7da..46b4af599b054 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "immich-web", - "version": "1.114.0", + "version": "1.115.0", "license": "GNU Affero General Public License version 3", "scripts": { "dev": "vite dev --host 0.0.0.0 --port 3000", From d03e97f65081447b84a07dd4d874b8affb614a88 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Thu, 12 Sep 2024 13:54:34 -0400 Subject: [PATCH 016/123] fix(web): better merge direction (#12601) --- .../lib/components/faces-page/merge-face-selector.svelte | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/web/src/lib/components/faces-page/merge-face-selector.svelte b/web/src/lib/components/faces-page/merge-face-selector.svelte index 71358361ce257..75f3420424c20 100644 --- a/web/src/lib/components/faces-page/merge-face-selector.svelte +++ b/web/src/lib/components/faces-page/merge-face-selector.svelte @@ -47,7 +47,7 @@ await goto(`${AppRoute.PEOPLE}/${person.id}?${$page.url.searchParams.toString()}`); }; - const onSelect = (selected: PersonResponseDto) => { + const onSelect = async (selected: PersonResponseDto) => { if (selectedPeople.includes(selected)) { selectedPeople = selectedPeople.filter((person) => person.id !== selected.id); return; @@ -62,6 +62,10 @@ } selectedPeople = [selected, ...selectedPeople]; + + if (selectedPeople.length === 1 && !person.name && selected.name) { + await handleSwapPeople(); + } }; const handleMerge = async () => { From 7b737786b3039254cedc1e8759a5de9adceefe6c Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Thu, 12 Sep 2024 13:56:38 -0400 Subject: [PATCH 017/123] fix(server): include partner assets in random endpoint (#12599) --- server/src/interfaces/asset.interface.ts | 2 +- server/src/repositories/asset.repository.ts | 11 +++-------- server/src/services/asset.service.ts | 7 ++++++- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/server/src/interfaces/asset.interface.ts b/server/src/interfaces/asset.interface.ts index 9f7213de82b80..0d37b64ebbc1f 100644 --- a/server/src/interfaces/asset.interface.ts +++ b/server/src/interfaces/asset.interface.ts @@ -175,7 +175,7 @@ export interface IAssetRepository { libraryId?: string, withDeleted?: boolean, ): Paginated; - getRandom(userId: string, count: number): Promise; + getRandom(userIds: string[], count: number): Promise; getFirstAssetForAlbumId(albumId: string): Promise; getLastUpdatedAssetForAlbumId(albumId: string): Promise; getExternalLibraryAssetPaths(pagination: PaginationOptions, libraryId: string): Paginated; diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index 059a05f9e770d..ac9dab6fbc69f 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -623,14 +623,9 @@ export class AssetRepository implements IAssetRepository { return result; } - @GenerateSql({ params: [DummyValue.UUID, DummyValue.NUMBER] }) - getRandom(ownerId: string, count: number): Promise { - const builder = this.getBuilder({ - userIds: [ownerId], - exifInfo: true, - }); - - return builder.orderBy('RANDOM()').limit(count).getMany(); + @GenerateSql({ params: [[DummyValue.UUID], DummyValue.NUMBER] }) + getRandom(userIds: string[], count: number): Promise { + return this.getBuilder({ userIds, exifInfo: true }).orderBy('RANDOM()').limit(count).getMany(); } @GenerateSql({ params: [{ size: TimeBucketSize.MONTH }] }) diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index 1d8d7d05d328f..06ca3af7d5db8 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -98,7 +98,12 @@ export class AssetService { } async getRandom(auth: AuthDto, count: number): Promise { - const assets = await this.assetRepository.getRandom(auth.user.id, count); + const partnerIds = await getMyPartnerIds({ + userId: auth.user.id, + repository: this.partnerRepository, + timelineEnabled: true, + }); + const assets = await this.assetRepository.getRandom([auth.user.id, ...partnerIds], count); return assets.map((a) => mapAsset(a, { auth })); } From ba57646f9f59ffaec221ec3ef6d67c4e539fa677 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Thu, 12 Sep 2024 14:12:39 -0400 Subject: [PATCH 018/123] refactor(server): client emit events (#12606) * refactor(server): client emit events * chore: test coverage --- server/src/interfaces/event.interface.ts | 14 ++++ server/src/services/asset-media.service.ts | 5 +- server/src/services/asset.service.ts | 7 +- .../src/services/notification.service.spec.ts | 73 +++++++++++++++++++ server/src/services/notification.service.ts | 41 ++++++++++- server/src/services/stack.service.ts | 12 ++- server/src/services/trash.service.spec.ts | 6 +- server/src/services/trash.service.ts | 4 +- 8 files changed, 142 insertions(+), 20 deletions(-) diff --git a/server/src/interfaces/event.interface.ts b/server/src/interfaces/event.interface.ts index 0cd0207155853..eced261dbefe5 100644 --- a/server/src/interfaces/event.interface.ts +++ b/server/src/interfaces/event.interface.ts @@ -22,10 +22,24 @@ type EmitEventMap = { 'asset.untag': [{ assetId: string }]; 'asset.hide': [{ assetId: string; userId: string }]; 'asset.show': [{ assetId: string; userId: string }]; + 'asset.trash': [{ assetId: string; userId: string }]; + 'asset.delete': [{ assetId: string; userId: string }]; + + // asset bulk events + 'assets.trash': [{ assetIds: string[]; userId: string }]; + 'assets.restore': [{ assetIds: string[]; userId: string }]; // session events 'session.delete': [{ sessionId: string }]; + // stack events + 'stack.create': [{ stackId: string; userId: string }]; + 'stack.update': [{ stackId: string; userId: string }]; + 'stack.delete': [{ stackId: string; userId: string }]; + + // stack bulk events + 'stacks.delete': [{ stackIds: string[]; userId: string }]; + // user events 'user.signup': [{ notify: boolean; id: string; tempPassword?: string }]; }; diff --git a/server/src/services/asset-media.service.ts b/server/src/services/asset-media.service.ts index 111d222c160c8..df3b183442ab5 100644 --- a/server/src/services/asset-media.service.ts +++ b/server/src/services/asset-media.service.ts @@ -30,7 +30,7 @@ import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity } from 'src/entities/asset.entit import { AssetType, Permission } from 'src/enum'; import { IAccessRepository } from 'src/interfaces/access.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface'; +import { IEventRepository } from 'src/interfaces/event.interface'; import { IJobRepository, JobName } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; @@ -194,8 +194,7 @@ export class AssetMediaService { const copiedPhoto = await this.createCopy(asset); // and immediate trash it await this.assetRepository.softDeleteAll([copiedPhoto.id]); - - this.eventRepository.clientSend(ClientEvent.ASSET_TRASH, auth.user.id, [copiedPhoto.id]); + await this.eventRepository.emit('asset.trash', { assetId: copiedPhoto.id, userId: auth.user.id }); await this.userRepository.updateUsage(auth.user.id, file.size); diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index 06ca3af7d5db8..98d3dd14599e1 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -23,7 +23,7 @@ import { AssetEntity } from 'src/entities/asset.entity'; import { Permission } from 'src/enum'; import { IAccessRepository } from 'src/interfaces/access.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface'; +import { IEventRepository } from 'src/interfaces/event.interface'; import { IAssetDeleteJob, IJobRepository, @@ -273,7 +273,8 @@ export class AssetService { if (!asset.libraryId) { await this.userRepository.updateUsage(asset.ownerId, -(asset.exifInfo?.fileSizeInByte || 0)); } - this.eventRepository.clientSend(ClientEvent.ASSET_DELETE, asset.ownerId, id); + + await this.eventRepository.emit('asset.delete', { assetId: id, userId: asset.ownerId }); // delete the motion if it is not used by another asset if (asset.livePhotoVideoId) { @@ -311,7 +312,7 @@ export class AssetService { ); } else { await this.assetRepository.softDeleteAll(ids); - this.eventRepository.clientSend(ClientEvent.ASSET_TRASH, auth.user.id, ids); + await this.eventRepository.emit('assets.trash', { assetIds: ids, userId: auth.user.id }); } } diff --git a/server/src/services/notification.service.spec.ts b/server/src/services/notification.service.spec.ts index 9d9f8f5fcfe2f..9ef1310bfbaca 100644 --- a/server/src/services/notification.service.spec.ts +++ b/server/src/services/notification.service.spec.ts @@ -144,6 +144,23 @@ describe(NotificationService.name, () => { }); }); + describe('onAssetHide', () => { + it('should send connected clients an event', () => { + sut.onAssetHide({ assetId: 'asset-id', userId: 'user-id' }); + expect(eventMock.clientSend).toHaveBeenCalledWith('on_asset_hidden', 'user-id', 'asset-id'); + }); + }); + + describe('onAssetShow', () => { + it('should queue the generate thumbnail job', async () => { + await sut.onAssetShow({ assetId: 'asset-id', userId: 'user-id' }); + expect(jobMock.queue).toHaveBeenCalledWith({ + name: JobName.GENERATE_THUMBNAIL, + data: { id: 'asset-id', notify: true }, + }); + }); + }); + describe('onUserSignupEvent', () => { it('skips when notify is false', async () => { await sut.onUserSignup({ id: '', notify: false }); @@ -179,6 +196,62 @@ describe(NotificationService.name, () => { }); }); + describe('onAssetTrash', () => { + it('should send connected clients an event', () => { + sut.onAssetTrash({ assetId: 'asset-id', userId: 'user-id' }); + expect(eventMock.clientSend).toHaveBeenCalledWith('on_asset_trash', 'user-id', ['asset-id']); + }); + }); + + describe('onAssetDelete', () => { + it('should send connected clients an event', () => { + sut.onAssetDelete({ assetId: 'asset-id', userId: 'user-id' }); + expect(eventMock.clientSend).toHaveBeenCalledWith('on_asset_delete', 'user-id', 'asset-id'); + }); + }); + + describe('onAssetsTrash', () => { + it('should send connected clients an event', () => { + sut.onAssetsTrash({ assetIds: ['asset-id'], userId: 'user-id' }); + expect(eventMock.clientSend).toHaveBeenCalledWith('on_asset_trash', 'user-id', ['asset-id']); + }); + }); + + describe('onAssetsRestore', () => { + it('should send connected clients an event', () => { + sut.onAssetsRestore({ assetIds: ['asset-id'], userId: 'user-id' }); + expect(eventMock.clientSend).toHaveBeenCalledWith('on_asset_restore', 'user-id', ['asset-id']); + }); + }); + + describe('onStackCreate', () => { + it('should send connected clients an event', () => { + sut.onStackCreate({ stackId: 'stack-id', userId: 'user-id' }); + expect(eventMock.clientSend).toHaveBeenCalledWith('on_asset_stack_update', 'user-id', []); + }); + }); + + describe('onStackUpdate', () => { + it('should send connected clients an event', () => { + sut.onStackUpdate({ stackId: 'stack-id', userId: 'user-id' }); + expect(eventMock.clientSend).toHaveBeenCalledWith('on_asset_stack_update', 'user-id', []); + }); + }); + + describe('onStackDelete', () => { + it('should send connected clients an event', () => { + sut.onStackDelete({ stackId: 'stack-id', userId: 'user-id' }); + expect(eventMock.clientSend).toHaveBeenCalledWith('on_asset_stack_update', 'user-id', []); + }); + }); + + describe('onStacksDelete', () => { + it('should send connected clients an event', () => { + sut.onStacksDelete({ stackIds: ['stack-id'], userId: 'user-id' }); + expect(eventMock.clientSend).toHaveBeenCalledWith('on_asset_stack_update', 'user-id', []); + }); + }); + describe('sendTestEmail', () => { it('should throw error if user could not be found', async () => { await expect(sut.sendTestEmail('', configs.smtpTransport.notifications.smtp)).rejects.toThrow('User not found'); diff --git a/server/src/services/notification.service.ts b/server/src/services/notification.service.ts index 01da235bf0086..4eef49c631511 100644 --- a/server/src/services/notification.service.ts +++ b/server/src/services/notification.service.ts @@ -60,7 +60,6 @@ export class NotificationService { @OnEmit({ event: 'asset.hide' }) onAssetHide({ assetId, userId }: ArgOf<'asset.hide'>) { - // Notify clients to hide the linked live photo asset this.eventRepository.clientSend(ClientEvent.ASSET_HIDDEN, userId, assetId); } @@ -69,6 +68,46 @@ export class NotificationService { await this.jobRepository.queue({ name: JobName.GENERATE_THUMBNAIL, data: { id: assetId, notify: true } }); } + @OnEmit({ event: 'asset.trash' }) + onAssetTrash({ assetId, userId }: ArgOf<'asset.trash'>) { + this.eventRepository.clientSend(ClientEvent.ASSET_TRASH, userId, [assetId]); + } + + @OnEmit({ event: 'asset.delete' }) + onAssetDelete({ assetId, userId }: ArgOf<'asset.delete'>) { + this.eventRepository.clientSend(ClientEvent.ASSET_DELETE, userId, assetId); + } + + @OnEmit({ event: 'assets.trash' }) + onAssetsTrash({ assetIds, userId }: ArgOf<'assets.trash'>) { + this.eventRepository.clientSend(ClientEvent.ASSET_TRASH, userId, assetIds); + } + + @OnEmit({ event: 'assets.restore' }) + onAssetsRestore({ assetIds, userId }: ArgOf<'assets.restore'>) { + this.eventRepository.clientSend(ClientEvent.ASSET_RESTORE, userId, assetIds); + } + + @OnEmit({ event: 'stack.create' }) + onStackCreate({ userId }: ArgOf<'stack.create'>) { + this.eventRepository.clientSend(ClientEvent.ASSET_STACK_UPDATE, userId, []); + } + + @OnEmit({ event: 'stack.update' }) + onStackUpdate({ userId }: ArgOf<'stack.update'>) { + this.eventRepository.clientSend(ClientEvent.ASSET_STACK_UPDATE, userId, []); + } + + @OnEmit({ event: 'stack.delete' }) + onStackDelete({ userId }: ArgOf<'stack.delete'>) { + this.eventRepository.clientSend(ClientEvent.ASSET_STACK_UPDATE, userId, []); + } + + @OnEmit({ event: 'stacks.delete' }) + onStacksDelete({ userId }: ArgOf<'stacks.delete'>) { + this.eventRepository.clientSend(ClientEvent.ASSET_STACK_UPDATE, userId, []); + } + @OnEmit({ event: 'user.signup' }) async onUserSignup({ notify, id, tempPassword }: ArgOf<'user.signup'>) { if (notify) { diff --git a/server/src/services/stack.service.ts b/server/src/services/stack.service.ts index bebc8517d6b7a..29a598d4b413a 100644 --- a/server/src/services/stack.service.ts +++ b/server/src/services/stack.service.ts @@ -4,7 +4,7 @@ import { AuthDto } from 'src/dtos/auth.dto'; import { StackCreateDto, StackResponseDto, StackSearchDto, StackUpdateDto, mapStack } from 'src/dtos/stack.dto'; import { Permission } from 'src/enum'; import { IAccessRepository } from 'src/interfaces/access.interface'; -import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface'; +import { IEventRepository } from 'src/interfaces/event.interface'; import { IStackRepository } from 'src/interfaces/stack.interface'; import { requireAccess } from 'src/utils/access'; @@ -30,7 +30,7 @@ export class StackService { const stack = await this.stackRepository.create({ ownerId: auth.user.id, assetIds: dto.assetIds }); - this.eventRepository.clientSend(ClientEvent.ASSET_STACK_UPDATE, auth.user.id, []); + await this.eventRepository.emit('stack.create', { stackId: stack.id, userId: auth.user.id }); return mapStack(stack, { auth }); } @@ -50,7 +50,7 @@ export class StackService { const updatedStack = await this.stackRepository.update({ id, primaryAssetId: dto.primaryAssetId }); - this.eventRepository.clientSend(ClientEvent.ASSET_STACK_UPDATE, auth.user.id, []); + await this.eventRepository.emit('stack.update', { stackId: id, userId: auth.user.id }); return mapStack(updatedStack, { auth }); } @@ -58,15 +58,13 @@ export class StackService { async delete(auth: AuthDto, id: string): Promise { await requireAccess(this.access, { auth, permission: Permission.STACK_DELETE, ids: [id] }); await this.stackRepository.delete(id); - - this.eventRepository.clientSend(ClientEvent.ASSET_STACK_UPDATE, auth.user.id, []); + await this.eventRepository.emit('stack.delete', { stackId: id, userId: auth.user.id }); } async deleteAll(auth: AuthDto, dto: BulkIdsDto): Promise { await requireAccess(this.access, { auth, permission: Permission.STACK_DELETE, ids: dto.ids }); await this.stackRepository.deleteAll(dto.ids); - - this.eventRepository.clientSend(ClientEvent.ASSET_STACK_UPDATE, auth.user.id, []); + await this.eventRepository.emit('stacks.delete', { stackIds: dto.ids, userId: auth.user.id }); } private async findOrFail(id: string) { diff --git a/server/src/services/trash.service.spec.ts b/server/src/services/trash.service.spec.ts index 73a4f3d57b95f..5c0609956abc5 100644 --- a/server/src/services/trash.service.spec.ts +++ b/server/src/services/trash.service.spec.ts @@ -1,6 +1,6 @@ import { BadRequestException } from '@nestjs/common'; import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface'; +import { IEventRepository } from 'src/interfaces/event.interface'; import { IJobRepository, JobName } from 'src/interfaces/job.interface'; import { TrashService } from 'src/services/trash.service'; import { assetStub } from 'test/fixtures/asset.stub'; @@ -62,9 +62,7 @@ describe(TrashService.name, () => { assetMock.getByUserId.mockResolvedValue({ items: [assetStub.image], hasNextPage: false }); await expect(sut.restore(authStub.user1)).resolves.toBeUndefined(); expect(assetMock.restoreAll).toHaveBeenCalledWith([assetStub.image.id]); - expect(eventMock.clientSend).toHaveBeenCalledWith(ClientEvent.ASSET_RESTORE, authStub.user1.user.id, [ - assetStub.image.id, - ]); + expect(eventMock.emit).toHaveBeenCalledWith('assets.restore', { assetIds: ['asset-id'], userId: 'user-id' }); }); }); diff --git a/server/src/services/trash.service.ts b/server/src/services/trash.service.ts index ac141521ddc18..712b9e50f25f0 100644 --- a/server/src/services/trash.service.ts +++ b/server/src/services/trash.service.ts @@ -5,7 +5,7 @@ import { AuthDto } from 'src/dtos/auth.dto'; import { Permission } from 'src/enum'; import { IAccessRepository } from 'src/interfaces/access.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface'; +import { IEventRepository } from 'src/interfaces/event.interface'; import { IJobRepository, JOBS_ASSET_PAGINATION_SIZE, JobName } from 'src/interfaces/job.interface'; import { requireAccess } from 'src/utils/access'; import { usePagination } from 'src/utils/pagination'; @@ -64,6 +64,6 @@ export class TrashService { } await this.assetRepository.restoreAll(ids); - this.eventRepository.clientSend(ClientEvent.ASSET_RESTORE, auth.user.id, ids); + await this.eventRepository.emit('assets.restore', { assetIds: ids, userId: auth.user.id }); } } From 6cbdb4c90d4e07e47a30eb5940f9fd9db35d65e3 Mon Sep 17 00:00:00 2001 From: bo0tzz Date: Thu, 12 Sep 2024 21:15:38 +0200 Subject: [PATCH 019/123] docs: scaling immich guide (#12593) --- docs/docs/guides/scaling-immich.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 docs/docs/guides/scaling-immich.md diff --git a/docs/docs/guides/scaling-immich.md b/docs/docs/guides/scaling-immich.md new file mode 100644 index 0000000000000..a8d916ae2a807 --- /dev/null +++ b/docs/docs/guides/scaling-immich.md @@ -0,0 +1,19 @@ +# Scaling Immich + +Immich is built with modern deployment practices in mind, and the backend is designed to be able to run multiple instances in parallel. When doing this, the only requirement you need to be aware of is that every instance needs to be connected to the shared infrastructure. That means they should all have access to the same Postgres and Redis instances, and have the same files mounted into the containers. + +Scaling can be useful for many reasons. Maybe you have a gaming PC that you want to use for transcoding and thumbnail generation, or perhaps you run a Kubernetes cluster across a handful of powerful servers that you want to make use of. + +:::info +If you only have a single machine to run Immich on, scaling to multiple containers is unlikely to provide any benefit. An Immich container will run multiple background tasks at once, and you can increase their number from the admin panel. +::: + +The details of how to scale across multiple machines will vary widely between different environments and require some knowledge to set up, and as such this guide gives no specific instructions. In some cases scaling up can be as easy as incrementing the amount of replicas on a Kubernetes deployment, in others it might need you to configure network tunnels or NFS mounts. The details are left as an exercise for the reader ;) + +## Workers + +By default, each running `immich-server` container comes with multiple internal workers. If you're scaling up only to handle more background tasks, you can choose to disable the worker responsible for the API. See [workers](../administration/jobs-workers.md) for more detail. + +## Scaling down + +In the same way you can scale up to multiple containers, you can also choose to scale down. All state is stored in Postgres, Redis, and the filesystem so there is no risk in stopping a running immich-server container, for example if you want to use your GPU to play some games. As long as there is an API worker running you will still be able to browse Immich, and jobs will wait to be processed until there is a worker available for them. From 92811190a89eed8738debf270075bef4af39f532 Mon Sep 17 00:00:00 2001 From: Saschl <19493808+Saschl@users.noreply.github.com> Date: Thu, 12 Sep 2024 21:30:21 +0200 Subject: [PATCH 020/123] fix(web): load original panorama if specified in user settings (#12123) * fix: load original panorama if specified in user settings * fixes after merge * chore: cleanup --------- Co-authored-by: Saschl Co-authored-by: Jason Rasmussen --- .../components/asset-viewer/panorama-viewer.svelte | 13 +++++++++---- .../asset-viewer/photo-sphere-viewer-adapter.svelte | 7 ++++--- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/web/src/lib/components/asset-viewer/panorama-viewer.svelte b/web/src/lib/components/asset-viewer/panorama-viewer.svelte index dee9a5f8ec542..396685e351eaa 100644 --- a/web/src/lib/components/asset-viewer/panorama-viewer.svelte +++ b/web/src/lib/components/asset-viewer/panorama-viewer.svelte @@ -1,11 +1,13 @@
diff --git a/web/src/lib/components/asset-viewer/photo-sphere-viewer-adapter.svelte b/web/src/lib/components/asset-viewer/photo-sphere-viewer-adapter.svelte index 30a2018febc80..da8febc3d94c3 100644 --- a/web/src/lib/components/asset-viewer/photo-sphere-viewer-adapter.svelte +++ b/web/src/lib/components/asset-viewer/photo-sphere-viewer-adapter.svelte @@ -1,10 +1,11 @@ canGoForward && toNext() }, - { shortcut: { key: 'd' }, onShortcut: () => canGoForward && toNext() }, - { shortcut: { key: 'ArrowLeft' }, onShortcut: () => canGoBack && toPrevious() }, - { shortcut: { key: 'a' }, onShortcut: () => canGoBack && toPrevious() }, - { shortcut: { key: 'Escape' }, onShortcut: () => goto(AppRoute.PHOTOS) }, - ]} + use:shortcuts={$isViewing + ? [] + : [ + { shortcut: { key: 'ArrowRight' }, onShortcut: () => handleNextAsset() }, + { shortcut: { key: 'd' }, onShortcut: () => handleNextAsset() }, + { shortcut: { key: 'ArrowLeft' }, onShortcut: () => handlePreviousAsset() }, + { shortcut: { key: 'a' }, onShortcut: () => handlePreviousAsset() }, + { shortcut: { key: 'Escape' }, onShortcut: () => handleEscape() }, + ]} /> {#if isMultiSelectionMode} @@ -172,61 +235,56 @@ - + - - + +
{/if}
- {#if currentMemory} + {#if current && current.memory.assets.length > 0} goto(AppRoute.PHOTOS)} forceDark>

- {$memoryLaneTitle(currentMemory.yearsAgo)} + {$memoryLaneTitle(current.memory.yearsAgo)}

- {#if canGoForward} -
- (paused = !paused)} - class="hover:text-black" - /> +
+ handleAction(paused ? 'play' : 'pause')} + class="hover:text-black" + /> - {#each currentMemory.assets as _, index} - - - {#await resetPromise} - - {:then} - assetIndex ? 0 : $progress * 100}%`} - /> - {/await} - - {/each} + {#each current.memory.assets as asset, index} + + + {#await resetPromise} + + {:then} + current.assetIndex ? 0 : $progress * 100}%`} + /> + {/await} + + {/each} -
-

- {(assetIndex + 1).toLocaleString($locale)}/{currentMemory.assets.length.toLocaleString($locale)} -

-
+
+

+ {(current.assetIndex + 1).toLocaleString($locale)}/{current.memory.assets.length.toLocaleString($locale)} +

- {/if} +
{#if galleryInView} @@ -250,22 +308,17 @@ class="ml-[-100%] box-border flex h-[calc(100vh_-_180px)] w-[300%] items-center justify-center gap-10 overflow-hidden" > -
+
@@ -293,12 +346,12 @@ class="main-view relative flex h-full w-[70vw] place-content-center place-items-center rounded-2xl bg-black" >
- {#key currentAsset.id} + {#key current.asset.id} {currentAsset.exifInfo?.description} {/key} @@ -309,59 +362,59 @@ class:opacity-100={!galleryInView} >
- {#if canGoBack} + {#if current.previous}
{/if} - {#if canGoForward} + {#if current.next}
- +
{/if}

- {fromLocalDateTime(currentMemory.assets[0].localDateTime).toLocaleString(DateTime.DATE_FULL)} + {fromLocalDateTime(current.memory.assets[0].localDateTime).toLocaleString(DateTime.DATE_FULL)}

- {currentAsset.exifInfo?.city || ''} - {currentAsset.exifInfo?.country || ''} + {current.asset.exifInfo?.city || ''} + {current.asset.exifInfo?.country || ''}

-
+
@@ -411,7 +464,13 @@ use:resizeObserver={({ height, width }) => ((viewport.height = height), (viewport.width = width))} bind:this={memoryGallery} > - +
{/if} diff --git a/web/src/lib/components/photos-page/memory-lane.svelte b/web/src/lib/components/photos-page/memory-lane.svelte index 5bc55796aecb2..459c7a61182cd 100644 --- a/web/src/lib/components/photos-page/memory-lane.svelte +++ b/web/src/lib/components/photos-page/memory-lane.svelte @@ -69,11 +69,11 @@
{/if}
(innerWidth = width)}> - {#each $memoryStore as memory, index (memory.yearsAgo)} + {#each $memoryStore as memory (memory.yearsAgo)} {#if memory.assets.length > 0} void) | undefined = undefined; export let showAssetName = false; + export let onPrevious: (() => Promise) | undefined = undefined; + export let onNext: (() => Promise) | undefined = undefined; let { isViewing: isViewerOpen, asset: viewingAsset, setAsset } = assetViewingStore; @@ -50,8 +52,9 @@ const handleNext = async () => { try { - if (currentViewAssetIndex < assets.length - 1) { - setAsset(assets[++currentViewAssetIndex]); + const asset = onNext ? await onNext() : assets[++currentViewAssetIndex]; + if (asset) { + setAsset(asset); await navigate({ targetRoute: 'current', assetId: $viewingAsset.id }); } } catch (error) { @@ -61,8 +64,9 @@ const handlePrevious = async () => { try { - if (currentViewAssetIndex > 0) { - setAsset(assets[--currentViewAssetIndex]); + const asset = onPrevious ? await onPrevious() : assets[--currentViewAssetIndex]; + if (asset) { + setAsset(asset); await navigate({ targetRoute: 'current', assetId: $viewingAsset.id }); } } catch (error) { diff --git a/web/src/lib/constants.ts b/web/src/lib/constants.ts index 05011680dcc41..7af4635a842a1 100644 --- a/web/src/lib/constants.ts +++ b/web/src/lib/constants.ts @@ -71,9 +71,8 @@ export const dateFormats = { export enum QueryParameter { ACTION = 'action', - ASSET_INDEX = 'assetIndex', + ID = 'id', IS_OPEN = 'isOpen', - MEMORY_INDEX = 'memoryIndex', ONBOARDING_STEP = 'step', OPEN_SETTING = 'openSetting', PREVIOUS_ROUTE = 'previousRoute', From c717fd213111c4717a0c4f408b6f42725024ab64 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Fri, 13 Sep 2024 12:33:16 -0400 Subject: [PATCH 025/123] fix(server): increase person search limit (#12619) --- server/src/queries/person.repository.sql | 7 +------ server/src/repositories/person.repository.ts | 5 +---- web/src/lib/constants.ts | 2 +- 3 files changed, 3 insertions(+), 11 deletions(-) diff --git a/server/src/queries/person.repository.sql b/server/src/queries/person.repository.sql index 57969e4989c91..95374b136d162 100644 --- a/server/src/queries/person.repository.sql +++ b/server/src/queries/person.repository.sql @@ -215,19 +215,14 @@ SELECT "person"."isHidden" AS "person_isHidden" FROM "person" "person" - LEFT JOIN "asset_faces" "face" ON "face"."personId" = "person"."id" WHERE "person"."ownerId" = $1 AND ( LOWER("person"."name") LIKE $2 OR LOWER("person"."name") LIKE $3 ) -GROUP BY - "person"."id" -ORDER BY - COUNT("face"."assetId") DESC LIMIT - 20 + 1000 -- PersonRepository.getDistinctNames SELECT DISTINCT diff --git a/server/src/repositories/person.repository.ts b/server/src/repositories/person.repository.ts index c0bfee53987dc..2247195cc3ce7 100644 --- a/server/src/repositories/person.repository.ts +++ b/server/src/repositories/person.repository.ts @@ -184,14 +184,11 @@ export class PersonRepository implements IPersonRepository { getByName(userId: string, personName: string, { withHidden }: PersonNameSearchOptions): Promise { const queryBuilder = this.personRepository .createQueryBuilder('person') - .leftJoin('person.faces', 'face') .where( 'person.ownerId = :userId AND (LOWER(person.name) LIKE :nameStart OR LOWER(person.name) LIKE :nameAnywhere)', { userId, nameStart: `${personName.toLowerCase()}%`, nameAnywhere: `% ${personName.toLowerCase()}%` }, ) - .groupBy('person.id') - .orderBy('COUNT(face.assetId)', 'DESC') - .limit(20); + .limit(1000); if (!withHidden) { queryBuilder.andWhere('person.isHidden = false'); diff --git a/web/src/lib/constants.ts b/web/src/lib/constants.ts index 7af4635a842a1..3abea669e6013 100644 --- a/web/src/lib/constants.ts +++ b/web/src/lib/constants.ts @@ -93,7 +93,7 @@ export enum ActionQueryParameterValue { MERGE = 'merge', } -export const maximumLengthSearchPeople: number = 20; +export const maximumLengthSearchPeople = 1000; // time to load the map before displaying the loading spinner export const timeToLoadTheMap: number = 100; From 7893dca733621934ce6517cabb0171ee2eac8edc Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Fri, 13 Sep 2024 12:33:58 -0400 Subject: [PATCH 026/123] chore: add date/time issue to template (#12651) --- .github/ISSUE_TEMPLATE/config.yml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index ae0368861abfe..0b0cfbafd918f 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,11 +1,14 @@ blank_issues_enabled: false contact_links: - - name: I have a question or need support + - name: ✋ I have a question or need support url: https://discord.immich.app about: We use GitHub for tracking bugs, please check out our Discord channel for freaky fast support. - - name: Feature Request + - name: 📷 My photo or video has a date, time, or timezone problem + url: https://github.com/immich-app/immich/discussions/12650 + about: Upload a sample file to this discussion and we will take a look + - name: 🌟 Feature request url: https://github.com/immich-app/immich/discussions/new?category=feature-request about: Please use our GitHub Discussion for making feature requests. - - name: I'm unsure where to go + - name: 🫣 I'm unsure where to go url: https://discord.immich.app about: If you are unsure where to go, then joining our Discord is recommended; Just ask! From f22338f36fcdf6e4b7de577a6a08ac5b307f5d92 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 13 Sep 2024 22:20:40 -0500 Subject: [PATCH 027/123] fix(web): scrollbar shows when not need (#12659) --- .../lib/components/shared-components/full-screen-modal.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/lib/components/shared-components/full-screen-modal.svelte b/web/src/lib/components/shared-components/full-screen-modal.svelte index 2cecdf74e8424..0b5e52b2bc6b4 100644 --- a/web/src/lib/components/shared-components/full-screen-modal.svelte +++ b/web/src/lib/components/shared-components/full-screen-modal.svelte @@ -75,7 +75,7 @@ aria-labelledby={titleId} >
From e73dc3dc7227bac1e1f7f0e8a2037123d824f149 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 13 Sep 2024 22:30:06 -0500 Subject: [PATCH 028/123] fix(server): fix modify date extraction (#12658) * fix(server): fix modify date extraction * add unit test --- server/src/services/metadata.service.spec.ts | 12 ++++++++++++ server/src/services/metadata.service.ts | 7 ++++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index ea7254f53f4d8..19aaa2ea1a323 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -1095,6 +1095,18 @@ describe(MetadataService.name, () => { expect(personMock.updateAll).toHaveBeenCalledWith([]); expect(jobMock.queueAll).toHaveBeenCalledWith([]); }); + + it('should handle invalid modify date', async () => { + assetMock.getByIds.mockResolvedValue([assetStub.image]); + metadataMock.readTags.mockResolvedValue({ ModifyDate: '00:00:00.000' }); + + await sut.handleMetadataExtraction({ id: assetStub.image.id }); + expect(assetMock.upsertExif).toHaveBeenCalledWith( + expect.objectContaining({ + modifyDate: expect.any(Date), + }), + ); + }); }); describe('handleQueueSidecar', () => { diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index 4ffbd7f09b4f6..eaa491c3ee7d8 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -629,11 +629,16 @@ export class MetadataService { this.logger.debug(`Asset ${asset.id} local time is offset by ${offsetMinutes} minutes`); } + let modifyDate = asset.fileModifiedAt; + try { + modifyDate = (exifTags.ModifyDate as ExifDateTime)?.toDate() ?? modifyDate; + } catch {} + return { dateTimeOriginal, timeZone, localDateTime, - modifyDate: (exifTags.ModifyDate as ExifDateTime)?.toDate() ?? asset.fileModifiedAt, + modifyDate, }; } From e6bc831c9757c4d09ebf2b5cb3267b8d40f5441e Mon Sep 17 00:00:00 2001 From: aryiu <130826840+aryiu@users.noreply.github.com> Date: Sun, 15 Sep 2024 18:11:52 +0200 Subject: [PATCH 029/123] fix(mobile): fix mn-MN.json file name (#12558) * Update and rename ca.json to ca-CA.json * Add mn-MN.json * Delete mobile/assets/i18n/mn.json * Update mn-MN.json * Update localizely.yml --- localizely.yml | 2 +- mobile/assets/i18n/{mn.json => mn-MN.json} | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename mobile/assets/i18n/{mn.json => mn-MN.json} (99%) diff --git a/localizely.yml b/localizely.yml index 343464284a876..5d119fe9d8e49 100644 --- a/localizely.yml +++ b/localizely.yml @@ -52,7 +52,7 @@ download: locale_code: nb-NO - file: mobile/assets/i18n/sv-SE.json locale_code: sv-SE - - file: mobile/assets/i18n/mn.json + - file: mobile/assets/i18n/mn-MN.json locale_code: mn - file: mobile/assets/i18n/ko-KR.json locale_code: ko-KR diff --git a/mobile/assets/i18n/mn.json b/mobile/assets/i18n/mn-MN.json similarity index 99% rename from mobile/assets/i18n/mn.json rename to mobile/assets/i18n/mn-MN.json index cf951cea0b50b..54697af5da324 100644 --- a/mobile/assets/i18n/mn.json +++ b/mobile/assets/i18n/mn-MN.json @@ -589,4 +589,4 @@ "viewer_remove_from_stack": "Remove from Stack", "viewer_stack_use_as_main_asset": "Use as Main Asset", "viewer_unstack": "Un-Stack" -} \ No newline at end of file +} From b06ea687b457fb3f0801c7387b2e2eca783f340d Mon Sep 17 00:00:00 2001 From: Alex Date: Sun, 15 Sep 2024 11:23:35 -0500 Subject: [PATCH 030/123] chore(web): small cleanup for full screen modal (#12680) --- .../lib/components/shared-components/full-screen-modal.svelte | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/lib/components/shared-components/full-screen-modal.svelte b/web/src/lib/components/shared-components/full-screen-modal.svelte index 0b5e52b2bc6b4..b5b21f0c23516 100644 --- a/web/src/lib/components/shared-components/full-screen-modal.svelte +++ b/web/src/lib/components/shared-components/full-screen-modal.svelte @@ -80,12 +80,12 @@ class:sm:scroll-p-24={isStickyBottom} > -
+
{#if isStickyBottom}
From 4735db8e796afee98880d44e5a64f4cd176ba1b7 Mon Sep 17 00:00:00 2001 From: Tom Vincent Date: Sun, 15 Sep 2024 20:20:09 +0100 Subject: [PATCH 031/123] chore(mobile): add isar lock file (#12705) --- mobile/.isar-cargo.lock | 859 +++++++++++++++++++++++++++ mobile/scripts/fdroid_build_isar.sh | 6 +- mobile/scripts/fdroid_update_isar.sh | 14 + 3 files changed, 877 insertions(+), 2 deletions(-) create mode 100644 mobile/.isar-cargo.lock mode change 100644 => 100755 mobile/scripts/fdroid_build_isar.sh create mode 100755 mobile/scripts/fdroid_update_isar.sh diff --git a/mobile/.isar-cargo.lock b/mobile/.isar-cargo.lock new file mode 100644 index 0000000000000..a7b1dd37b9fbe --- /dev/null +++ b/mobile/.isar-cargo.lock @@ -0,0 +1,859 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "autocfg" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0" + +[[package]] +name = "bindgen" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36d860121800b2a9a94f9b5604b332d5cffb234ce17609ea479d723dbc9d3885" +dependencies = [ + "bitflags 1.3.2", + "cexpr", + "clang-sys", + "lazy_static", + "lazycell", + "peeking_take_while", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex", + "syn 1.0.109", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" + +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8318a53db07bb3f8dca91a600466bdb3f2eaadeedfdbcf02e1accbad9271ba50" + +[[package]] +name = "cc" +version = "1.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d74707dde2ba56f86ae90effb3b43ddd369504387e718014de010cec7959800" +dependencies = [ + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "cmake" +version = "0.1.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb1e43aa7fd152b1f968787f7dbcdeb306d1867ff373c69955211876c053f91a" +dependencies = [ + "cc", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33480d6946193aa8033910124896ca395333cae7e2d1113d1fef6c3272217df2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22ec99545bb0ed0ea7bb9b8e1e9122ea386ff8a48c0922e43f36d45ab09e0e80" + +[[package]] +name = "dirs" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6" +dependencies = [ + "libc", + "redox_users", + "winapi", +] + +[[package]] +name = "doc-comment" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fea41bba32d969b513997752735605054bc0dfa92b4c56bf1189f2e174be7a10" + +[[package]] +name = "either" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" + +[[package]] +name = "enum_dispatch" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa18ce2bc66555b3218614519ac839ddb759a7d6720732f979ef8d13be147ecd" +dependencies = [ + "once_cell", + "proc-macro2", + "quote", + "syn 2.0.77", +] + +[[package]] +name = "float_next_after" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fc612c5837986b7104a87a0df74a5460931f1c5274be12f8d0f40aa2f30d632" +dependencies = [ + "num-traits", +] + +[[package]] +name = "getrandom" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "glob" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "hermit-abi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" + +[[package]] +name = "intmap" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee87fd093563344074bacf24faa0bb0227fb6969fb223e922db798516de924d6" + +[[package]] +name = "isar" +version = "0.0.0" +dependencies = [ + "dirs", + "intmap", + "isar-core", + "itertools", + "jni", + "ndk-context", + "objc", + "objc-foundation", + "once_cell", + "paste", + "serde_json", + "threadpool", + "unicode-segmentation", +] + +[[package]] +name = "isar-core" +version = "0.0.0" +dependencies = [ + "byteorder", + "cfg-if", + "crossbeam-channel", + "enum_dispatch", + "float_next_after", + "intmap", + "itertools", + "libc", + "mdbx-sys", + "once_cell", + "paste", + "rand", + "serde", + "serde_json", + "snafu", + "widestring", + "xxhash-rust", +] + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" + +[[package]] +name = "jni" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "039022cdf4d7b1cf548d31f60ae783138e5fd42013f6271049d7df7afadef96c" +dependencies = [ + "cesu8", + "combine", + "jni-sys", + "log", + "thiserror", + "walkdir", +] + +[[package]] +name = "jni-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "lazycell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" + +[[package]] +name = "libc" +version = "0.2.158" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8adc4bb1803a324070e64a98ae98f38934d91957a99cfb3a43dcbc01bc56439" + +[[package]] +name = "libloading" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4979f22fdb869068da03c9f7528f8297c6fd2606bc3a4affe42e6a823fdb8da4" +dependencies = [ + "cfg-if", + "windows-targets", +] + +[[package]] +name = "libredox" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" +dependencies = [ + "bitflags 2.6.0", + "libc", +] + +[[package]] +name = "log" +version = "0.4.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" + +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + +[[package]] +name = "mdbx-sys" +version = "0.0.0" +dependencies = [ + "bindgen", + "cc", + "cmake", + "libc", +] + +[[package]] +name = "memchr" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_cpus" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", +] + +[[package]] +name = "objc-foundation" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1add1b659e36c9607c7aab864a76c7a4c2760cd0cd2e120f3fb8b952c7e22bf9" +dependencies = [ + "block", + "objc", + "objc_id", +] + +[[package]] +name = "objc_id" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92d4ddb4bd7b50d730c215ff871754d0da6b2178849f8a2a2ab69712d0c073b" +dependencies = [ + "objc", +] + +[[package]] +name = "once_cell" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ea5043e58958ee56f3e15a90aee535795cd7dfd319846288d93c5b57d85cbe" + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "peeking_take_while" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b17cddbe7ec3f8bc800887bab5e717348c95ea2ca0b1bf0837fb964dc67099" + +[[package]] +name = "ppv-lite86" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77957b295656769bb8ad2b6a6b09d897d94f05c41b069aede1fcdaa675eaea04" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.86" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e719e8df665df0d1c8fbfd238015744736151d4445ec0836b8e628aae103b77" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom", + "libredox", + "thiserror", +] + +[[package]] +name = "regex" +version = "1.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4219d74c6b67a3654a9fbebc4b419e22126d13d2f3c4a07ee0cb61ff79a79619" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38caf58cc5ef2fed281f89292ef23f6365465ed9a41b7a7754eb4e26496c92df" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a66a03ae7c801facd77a29370b4faec201768915ac14a721ba36f20bc9c209b" + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "ryu" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "serde" +version = "1.0.210" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8e3592472072e6e22e0a54d5904d9febf8508f65fb8552499a1abc7d1078c3a" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.210" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "243902eda00fad750862fc144cea25caca5e20d615af0a81bee94ca738f1df1f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.77", +] + +[[package]] +name = "serde_json" +version = "1.0.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ff5456707a1de34e7e37f2a6fd3d3f808c318259cbd01ab6377795054b483d8" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "snafu" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4de37ad025c587a29e8f3f5605c00f70b98715ef90b9061a815b9e59e9042d6" +dependencies = [ + "doc-comment", + "snafu-derive", +] + +[[package]] +name = "snafu-derive" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990079665f075b699031e9c08fd3ab99be5029b96f3b78dc0709e8f77e4efebf" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f35bcdf61fd8e7be6caf75f429fdca8beb3ed76584befb503b1569faee373ed" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0342370b38b6a11b6cc11d6a805569958d54cfa061a29969c3b5ce2ea405724" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4558b58466b9ad7ca0f102865eccc95938dca1a74a856f2b57b6629050da261" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.77", +] + +[[package]] +name = "threadpool" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d050e60b33d41c19108b32cea32164033a9013fe3b46cbd4457559bfbf77afaa" +dependencies = [ + "num_cpus", +] + +[[package]] +name = "unicode-ident" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "widestring" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7219d36b6eac893fa81e84ebe06485e7dcbb616177469b142df14f1f4deb1311" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "xxhash-rust" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a5cbf750400958819fb6178eaa83bee5cd9c29a26a40cc241df8c70fdd46984" + +[[package]] +name = "zerocopy" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" +dependencies = [ + "byteorder", + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.77", +] diff --git a/mobile/scripts/fdroid_build_isar.sh b/mobile/scripts/fdroid_build_isar.sh old mode 100644 new mode 100755 index 44f59c69aee0e..41517737c94f5 --- a/mobile/scripts/fdroid_build_isar.sh +++ b/mobile/scripts/fdroid_build_isar.sh @@ -1,6 +1,8 @@ #!/usr/bin/env sh -cd .isar || exit +test -d .isar || exit +cp .isar-cargo.lock .isar/Cargo.lock +(cd .isar || exit bash tool/build_android.sh x86 bash tool/build_android.sh x64 bash tool/build_android.sh armv7 @@ -13,4 +15,4 @@ mv libisar_android_x64.so libisar.so mv libisar.so ../.pub-cache/hosted/pub.dev/isar_flutter_libs-*/android/src/main/jniLibs/x86_64/ mv libisar_android_x86.so libisar.so mv libisar.so ../.pub-cache/hosted/pub.dev/isar_flutter_libs-*/android/src/main/jniLibs/x86/ -cd .. \ No newline at end of file +) \ No newline at end of file diff --git a/mobile/scripts/fdroid_update_isar.sh b/mobile/scripts/fdroid_update_isar.sh new file mode 100755 index 0000000000000..814f50a8a15bc --- /dev/null +++ b/mobile/scripts/fdroid_update_isar.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env sh + +isar_version="$(awk '/isar: /{gsub(/\^/, "", $2); print $2}' pubspec.yaml)" +checked_out_version="$(git -C .isar describe --tags)" + +if [ "$isar_version" = "$checked_out_version" ]; then + echo "isar is up-to-date." + exit 0 +fi +echo "Updating from version $checked_out_version to $isar_version." + +git -C .isar checkout "$isar_version" +cargo generate-lockfile --manifest-path .isar/Cargo.toml +mv .isar/Cargo.lock .isar-cargo.lock From 3e12b108667994394a7efb569243286d788049dd Mon Sep 17 00:00:00 2001 From: bo0tzz Date: Mon, 16 Sep 2024 18:05:34 +0200 Subject: [PATCH 032/123] fix: remove bad examples of 'from' domain for emails (#12728) * fix: use example.com domain for from_address_description * fix: remove unnecessary screenshot from docs --- .../docs/administration/email-notification.mdx | 8 +++----- .../docs/administration/img/email-settings.png | Bin 222808 -> 0 bytes web/src/lib/i18n/ar.json | 2 +- web/src/lib/i18n/bg.json | 2 +- web/src/lib/i18n/ca.json | 2 +- web/src/lib/i18n/cs.json | 2 +- web/src/lib/i18n/da.json | 2 +- web/src/lib/i18n/de.json | 2 +- web/src/lib/i18n/en.json | 2 +- web/src/lib/i18n/es.json | 2 +- web/src/lib/i18n/et.json | 2 +- web/src/lib/i18n/fa.json | 2 +- web/src/lib/i18n/fi.json | 2 +- web/src/lib/i18n/he.json | 2 +- web/src/lib/i18n/hi.json | 2 +- web/src/lib/i18n/hr.json | 2 +- web/src/lib/i18n/hu.json | 2 +- web/src/lib/i18n/id.json | 2 +- web/src/lib/i18n/it.json | 2 +- web/src/lib/i18n/ja.json | 2 +- web/src/lib/i18n/ko.json | 2 +- web/src/lib/i18n/nb_NO.json | 2 +- web/src/lib/i18n/nl.json | 2 +- web/src/lib/i18n/pl.json | 2 +- web/src/lib/i18n/pt.json | 2 +- web/src/lib/i18n/pt_BR.json | 2 +- web/src/lib/i18n/ro.json | 2 +- web/src/lib/i18n/ru.json | 2 +- web/src/lib/i18n/sr_Cyrl.json | 2 +- web/src/lib/i18n/sr_Latn.json | 2 +- web/src/lib/i18n/sv.json | 2 +- web/src/lib/i18n/ta.json | 2 +- web/src/lib/i18n/th.json | 2 +- web/src/lib/i18n/tr.json | 2 +- web/src/lib/i18n/uk.json | 2 +- web/src/lib/i18n/vi.json | 2 +- web/src/lib/i18n/zh_Hant.json | 2 +- web/src/lib/i18n/zh_SIMPLIFIED.json | 2 +- 38 files changed, 39 insertions(+), 41 deletions(-) delete mode 100644 docs/docs/administration/img/email-settings.png diff --git a/docs/docs/administration/email-notification.mdx b/docs/docs/administration/email-notification.mdx index 4a2a0b5a837f7..93b1051053069 100644 --- a/docs/docs/administration/email-notification.mdx +++ b/docs/docs/administration/email-notification.mdx @@ -8,13 +8,11 @@ Immich supports the option to send notifications via Email for the following eve ## SMTP settings -You can access the settings panel from the web at `Administration -> Settings -> Notification settings` +You can access the settings panel from the web at `Administration -> Settings -> Notification settings`. -Under Email, enter the following details to connect with SMTP servers. +Under Email, enter the required details to connect with an SMTP server. -You can use the following [guide](/docs/guides/smtp-gmail) to use Gmail's SMTP server. - - +You can use [this guide](/docs/guides/smtp-gmail) to use Gmail's SMTP server. ## User's notifications settings diff --git a/docs/docs/administration/img/email-settings.png b/docs/docs/administration/img/email-settings.png deleted file mode 100644 index a0d71354267fba88f6194cadac1b5912d82fb493..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 222808 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%0DA8$(FY-(vXtEBy1H3YA~Ix#1WX?9 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\"", + "notification_email_from_address_description": "Електронна поща на изпращача, например: \"Immich Photo Server \"", "notification_email_host_description": "Хост на сървъра за електронна поща (например: smtp.immich.app)", "notification_email_ignore_certificate_errors": "Игорнорирайте сертификационни грешки", "notification_email_ignore_certificate_errors_description": "Игнорирай грешки свързани с валидация на TLS сертификат (не се препоръчва)", diff --git a/web/src/lib/i18n/ca.json b/web/src/lib/i18n/ca.json index a0fd6ff437f7f..ba33c9b15606b 100644 --- a/web/src/lib/i18n/ca.json +++ b/web/src/lib/i18n/ca.json @@ -147,7 +147,7 @@ "note_cannot_be_changed_later": "NOTA: Això és irreversible!", "note_unlimited_quota": "Nota: Intruduïu 0 per a quota il·limitada", "notification_email_from_address": "Des de l'adreça", - "notification_email_from_address_description": "Adreça de correu electrònic del remitent, per exemple: \"Immich Photo Server \"", + "notification_email_from_address_description": "Adreça de correu electrònic del remitent, per exemple: \"Immich Photo Server \"", "notification_email_host_description": "Amfitrió del servidor de correu electrònic (p.ex. smtp.immich.app)", "notification_email_ignore_certificate_errors": "Ignora els errors de certificat", "notification_email_ignore_certificate_errors_description": "Ignora els errors de validació de certificat TLS (no recomanat)", diff --git a/web/src/lib/i18n/cs.json b/web/src/lib/i18n/cs.json index ec97fe01b21b9..e49d3700ee94f 100644 --- a/web/src/lib/i18n/cs.json +++ b/web/src/lib/i18n/cs.json @@ -152,7 +152,7 @@ "note_cannot_be_changed_later": "UPOZORNĚNÍ: Toto nelze později změnit!", "note_unlimited_quota": "Upozornění: Pro neomezenou kvótu zadejte 0", "notification_email_from_address": "Adresa Od", - "notification_email_from_address_description": "E-mailová adresa odesílatele, např.: \"Immich Photo Server \"", + "notification_email_from_address_description": "E-mailová adresa odesílatele, např.: \"Immich Photo Server \"", "notification_email_host_description": "Adresa e-mailového serveru (např. smtp.immich.app)", "notification_email_ignore_certificate_errors": "Ignorovat chyby certifikátů", "notification_email_ignore_certificate_errors_description": "Ignorovat chyby ověření certifikátu TLS (nedoporučuje se)", diff --git a/web/src/lib/i18n/da.json b/web/src/lib/i18n/da.json index eb9d99d074c10..ab1d57d48e347 100644 --- a/web/src/lib/i18n/da.json +++ b/web/src/lib/i18n/da.json @@ -148,7 +148,7 @@ "note_cannot_be_changed_later": "BEMÆRK: Dette kan ikke ændres senere!", "note_unlimited_quota": "Bemærk: Indsæt 0 for uendelig kvote", "notification_email_from_address": "Fra adressse", - "notification_email_from_address_description": "Afsenderemailadresse, for eksempel: \"Immich Billedserver \"", + "notification_email_from_address_description": "Afsenderemailadresse, for eksempel: \"Immich Billedserver \"", "notification_email_host_description": "Host af emailserver (fx smtp.immich.app)", "notification_email_ignore_certificate_errors": "Ignorér certifikatfejl", "notification_email_ignore_certificate_errors_description": "Ignorér TLS-certifikatgodkendelsesfejl (ikke anbefalet)", diff --git a/web/src/lib/i18n/de.json b/web/src/lib/i18n/de.json index 77b420db00ebb..d519352862c2b 100644 --- a/web/src/lib/i18n/de.json +++ b/web/src/lib/i18n/de.json @@ -152,7 +152,7 @@ "note_cannot_be_changed_later": "HINWEIS: Dies kann später nicht mehr geändert werden!", "note_unlimited_quota": "Hinweis: 0 eingeben für unlimitiertes Kontingent", "notification_email_from_address": "Von", - "notification_email_from_address_description": "E-Mail-Adresse des Senders, zum Beispiel: \"Immich Photo Server \"", + "notification_email_from_address_description": "E-Mail-Adresse des Senders, zum Beispiel: \"Immich Photo Server \"", "notification_email_host_description": "Host des E-Mail-Servers (z.B. smtp.immich.app)", "notification_email_ignore_certificate_errors": "Ignoriere Zertifikats-Fehler", "notification_email_ignore_certificate_errors_description": "TLS-Zertifikatsvalidierungsfehler ignorieren (nicht empfohlen)", diff --git a/web/src/lib/i18n/en.json b/web/src/lib/i18n/en.json index e27cc54d52156..f880dab34737a 100644 --- a/web/src/lib/i18n/en.json +++ b/web/src/lib/i18n/en.json @@ -150,7 +150,7 @@ "note_cannot_be_changed_later": "NOTE: This cannot be changed later!", "note_unlimited_quota": "Note: Enter 0 for unlimited quota", "notification_email_from_address": "From address", - "notification_email_from_address_description": "Sender email address, for example: \"Immich Photo Server \"", + "notification_email_from_address_description": "Sender email address, for example: \"Immich Photo Server \"", "notification_email_host_description": "Host of the email server (e.g. smtp.immich.app)", "notification_email_ignore_certificate_errors": "Ignore certificate errors", "notification_email_ignore_certificate_errors_description": "Ignore TLS certificate validation errors (not recommended)", diff --git a/web/src/lib/i18n/es.json b/web/src/lib/i18n/es.json index f0c89ffb46adc..31c613dcbdb6e 100644 --- a/web/src/lib/i18n/es.json +++ b/web/src/lib/i18n/es.json @@ -152,7 +152,7 @@ "note_cannot_be_changed_later": "NOTA: No se puede cambiar posteriormente!", "note_unlimited_quota": "Nota: usa 0 para espacio sin límites", "notification_email_from_address": "Desde", - "notification_email_from_address_description": "Dirección de correo electrónico del remitente, por ejemplo: \"Immich Photo Server \"", + "notification_email_from_address_description": "Dirección de correo electrónico del remitente, por ejemplo: \"Immich Photo Server \"", "notification_email_host_description": "Host del servidor de correo electrónico (por ejemplo: smtp.immich.app)", "notification_email_ignore_certificate_errors": "Ignorar errores de certificado", "notification_email_ignore_certificate_errors_description": "Ignorar los errores de validación del certificado TLS (no recomendado)", diff --git a/web/src/lib/i18n/et.json b/web/src/lib/i18n/et.json index 25cdba4cb41a3..49b60cd0529de 100644 --- a/web/src/lib/i18n/et.json +++ b/web/src/lib/i18n/et.json @@ -125,7 +125,7 @@ "migration_job_description": "Migreeri üksuste ja nägude pisipildid uusimale kaustastruktuurile", "note_cannot_be_changed_later": "MÄRKUS: Seda ei saa hiljem muuta!", "notification_email_from_address": "Saatja aadress", - "notification_email_from_address_description": "Saatja e-posti aadress, näiteks: \"Immich Photo Server \"", + "notification_email_from_address_description": "Saatja e-posti aadress, näiteks: \"Immich Photo Server \"", "notification_email_host_description": "E-posti serveri host (nt. smtp.immich.app)", "notification_email_ignore_certificate_errors": "Ignoreeri sertifikaadi vigu", "notification_email_ignore_certificate_errors_description": "Ignoreeri TLS sertifikaadi valideerimise vigu (mittesoovituslik)", diff --git a/web/src/lib/i18n/fa.json b/web/src/lib/i18n/fa.json index 0eb6b7b014f19..6cd77c157fbcd 100644 --- a/web/src/lib/i18n/fa.json +++ b/web/src/lib/i18n/fa.json @@ -144,7 +144,7 @@ "note_cannot_be_changed_later": "توجه: این را نمی توان بعداً تغییر داد!", "note_unlimited_quota": "توجه: برای سهمیه نامحدود، عدد 0 را وارد کنید", "notification_email_from_address": "آدرس فرستنده", - "notification_email_from_address_description": "آدرس ایمیل فرستنده، به عنوان مثال:\"Immich سرور عکس \"", + "notification_email_from_address_description": "آدرس ایمیل فرستنده، به عنوان مثال:\"Immich سرور عکس \"", "notification_email_host_description": "میزبان سرور ایمیل (مثلاً smtp.immich.app)", "notification_email_ignore_certificate_errors": "خطاهای گواهی را نادیده بگیر", "notification_email_ignore_certificate_errors_description": "خطاهای اعتبارسنجی گواهی TLS را نادیده بگیر (توصیه نمی‌شود)", diff --git a/web/src/lib/i18n/fi.json b/web/src/lib/i18n/fi.json index da9a71379cfe5..6d951b93f9e89 100644 --- a/web/src/lib/i18n/fi.json +++ b/web/src/lib/i18n/fi.json @@ -148,7 +148,7 @@ "note_cannot_be_changed_later": "Huom: Tätä ei voi enää myöhemmin vaihtaa!", "note_unlimited_quota": "Huom: Määritä 0 rajoittamattomaksi kiintiöksi", "notification_email_from_address": "Lähettäjän osoite", - "notification_email_from_address_description": "Lähettäjän sähköpostiosoite. Esimerkiksi \"Immich Kuvapalvelin \"", + "notification_email_from_address_description": "Lähettäjän sähköpostiosoite. Esimerkiksi \"Immich Kuvapalvelin \"", "notification_email_host_description": "Sähköpostipalvelin (esim. smtp.immich.app)", "notification_email_ignore_certificate_errors": "Älä huomioi sertifikaattivirheitä", "notification_email_ignore_certificate_errors_description": "Älä huomioi TLS sertifikaattien validointivirheitä (ei suositeltu)", diff --git a/web/src/lib/i18n/he.json b/web/src/lib/i18n/he.json index aefa831897de7..e2b836256801d 100644 --- a/web/src/lib/i18n/he.json +++ b/web/src/lib/i18n/he.json @@ -148,7 +148,7 @@ "note_cannot_be_changed_later": "הערה: אי אפשר לשנות זאת מאוחר יותר!", "note_unlimited_quota": "הערה: הזן 0 עבור מכסת אחסון בלתי מוגבלת", "notification_email_from_address": "מכתובת", - "notification_email_from_address_description": "כתובת דוא\"ל של השולח, לדוגמה: \"Immich שרת תמונות \"", + "notification_email_from_address_description": "כתובת דוא\"ל של השולח, לדוגמה: \"Immich שרת תמונות \"", "notification_email_host_description": "מארח שרת הדוא\"ל (למשל smtp.immich.app)", "notification_email_ignore_certificate_errors": "התעלם משגיאות תעודה", "notification_email_ignore_certificate_errors_description": "התעלם משגיאות אימות תעודת TLS (לא מומלץ)", diff --git a/web/src/lib/i18n/hi.json b/web/src/lib/i18n/hi.json index 2f2aabfb7eccb..d84c612224a4d 100644 --- a/web/src/lib/i18n/hi.json +++ b/web/src/lib/i18n/hi.json @@ -147,7 +147,7 @@ "note_cannot_be_changed_later": "नोट: इसे बाद में बदला नहीं जा सकता!", "note_unlimited_quota": "नोट: असीमित कोटा के लिए 0 दर्ज करें", "notification_email_from_address": "इस पते से", - "notification_email_from_address_description": "प्रेषक का ईमेल पता, उदाहरण के लिए: \"इमिच फोटो सर्वर \"", + "notification_email_from_address_description": "प्रेषक का ईमेल पता, उदाहरण के लिए: \"इमिच फोटो सर्वर \"", "notification_email_host_description": "ईमेल सर्वर का होस्ट (उदा. smtp.immitch.app)", "notification_email_ignore_certificate_errors": "प्रमाणपत्र त्रुटियों पर ध्यान न दें", "notification_email_ignore_certificate_errors_description": "टीएलएस प्रमाणपत्र सत्यापन त्रुटियों पर ध्यान न दें (अनुशंसित नहीं)", diff --git a/web/src/lib/i18n/hr.json b/web/src/lib/i18n/hr.json index 291abaa6908d5..16d08bbfca211 100644 --- a/web/src/lib/i18n/hr.json +++ b/web/src/lib/i18n/hr.json @@ -146,7 +146,7 @@ "note_cannot_be_changed_later": "NAPOMENA: Ovo se ne može promijeniti kasnije!", "note_unlimited_quota": "Napomena: Unesite 0 za neograničenu kvotu", "notification_email_from_address": "Od adrese", - "notification_email_from_address_description": "E-mail adresa pošiljatelja, na primjer: \"Immich Photo Server \"", + "notification_email_from_address_description": "E-mail adresa pošiljatelja, na primjer: \"Immich Photo Server \"", "notification_email_host_description": "Poslužitelja e-pošte (npr. smtp.immich.app)", "notification_email_ignore_certificate_errors": "Ignoriraj pogreške certifikata", "notification_email_ignore_certificate_errors_description": "Ignoriraj pogreške provjere valjanosti TLS certifikata (nije preporučeno)", diff --git a/web/src/lib/i18n/hu.json b/web/src/lib/i18n/hu.json index 753839c384b19..249b663a773ef 100644 --- a/web/src/lib/i18n/hu.json +++ b/web/src/lib/i18n/hu.json @@ -149,7 +149,7 @@ "note_cannot_be_changed_later": "FIGYELEM: ezt később nem lehet megváltoztatni!", "note_unlimited_quota": "Megjegyzés: 0 - korlátlan kvóta", "notification_email_from_address": "Feladó cím", - "notification_email_from_address_description": "Küldő email címe, például: \"Immich Fotószerver \"", + "notification_email_from_address_description": "Küldő email címe, például: \"Immich Fotószerver \"", "notification_email_host_description": "Email szerver kiszolgálója (pl. smtp.immich.app)", "notification_email_ignore_certificate_errors": "Tanúsítvány hibák figyelmen kívül hagyása", "notification_email_ignore_certificate_errors_description": "TLS tanúsítvány érvényességi hibák figyelmen kívül hagyása (nem ajánlott)", diff --git a/web/src/lib/i18n/id.json b/web/src/lib/i18n/id.json index ea5ad94b2719e..1321bd358bd1e 100644 --- a/web/src/lib/i18n/id.json +++ b/web/src/lib/i18n/id.json @@ -150,7 +150,7 @@ "note_cannot_be_changed_later": "CATATAN: Ini tidak akan dapat diubah lagi!", "note_unlimited_quota": "Catatan: Masukkan 0 untuk kuota tidak terbatas", "notification_email_from_address": "Dari alamat", - "notification_email_from_address_description": "Alamat surel pengirim, misalnya: \"Server Foto Immich \"", + "notification_email_from_address_description": "Alamat surel pengirim, misalnya: \"Server Foto Immich \"", "notification_email_host_description": "Hos server surel (mis. smtp.immich.app)", "notification_email_ignore_certificate_errors": "Abaikan eror sertifikat", "notification_email_ignore_certificate_errors_description": "Abaikan eror validasi sertifikat TLS (tidak disarankan)", diff --git a/web/src/lib/i18n/it.json b/web/src/lib/i18n/it.json index 6782b8fbb9350..cbe3651927edb 100644 --- a/web/src/lib/i18n/it.json +++ b/web/src/lib/i18n/it.json @@ -152,7 +152,7 @@ "note_cannot_be_changed_later": "NOTA: Non potrà essere modificato in futuro!", "note_unlimited_quota": "Nota: Inserisci 0 per una quota illimitata", "notification_email_from_address": "Indirizzo mittente", - "notification_email_from_address_description": "Indirizzo email mittente, ad esempio: \"Server Foto Immich \"", + "notification_email_from_address_description": "Indirizzo email mittente, ad esempio: \"Server Foto Immich \"", "notification_email_host_description": "Host del server email (es. smtp.immich.app)", "notification_email_ignore_certificate_errors": "Ignora errori di certificato", "notification_email_ignore_certificate_errors_description": "Ignora errori di validazione del certificato TLS (sconsigliato)", diff --git a/web/src/lib/i18n/ja.json b/web/src/lib/i18n/ja.json index 017d52fb30857..53dbde9a9e5f8 100644 --- a/web/src/lib/i18n/ja.json +++ b/web/src/lib/i18n/ja.json @@ -148,7 +148,7 @@ "note_cannot_be_changed_later": "注意: 後から変更できません!", "note_unlimited_quota": "注意: 無制限にする場合は0を入力してください", "notification_email_from_address": "送信メールアドレス", - "notification_email_from_address_description": "送信メールアドレスを設定します(例: \"Immich Photo Server \" )", + "notification_email_from_address_description": "送信メールアドレスを設定します(例: \"Immich Photo Server \" )", "notification_email_host_description": "送信メールサーバーを設定します(例:smtp.immich.app)", "notification_email_ignore_certificate_errors": "証明書エラーを無視", "notification_email_ignore_certificate_errors_description": "TLS証明書の検証エラーを無視します(非推奨)", diff --git a/web/src/lib/i18n/ko.json b/web/src/lib/i18n/ko.json index cd3db1310267c..df46923b5ef73 100644 --- a/web/src/lib/i18n/ko.json +++ b/web/src/lib/i18n/ko.json @@ -148,7 +148,7 @@ "note_cannot_be_changed_later": "주의: 추후 변경할 수 없습니다!", "note_unlimited_quota": "참고: 할당량을 설정하지 않으려면 0을 입력하세요.", "notification_email_from_address": "보낸 사람 이메일", - "notification_email_from_address_description": "보낸 사람의 이메일 주소, 예: \"Immich Photo Server \"", + "notification_email_from_address_description": "보낸 사람의 이메일 주소, 예: \"Immich Photo Server \"", "notification_email_host_description": "이메일 서버의 호스트 (예: smtp.immich.app)", "notification_email_ignore_certificate_errors": "인증서 오류 무시", "notification_email_ignore_certificate_errors_description": "TLS 인증서 유효성 검사 오류 무시 (권장되지 않음)", diff --git a/web/src/lib/i18n/nb_NO.json b/web/src/lib/i18n/nb_NO.json index df56d27a237bf..1c0e2f5eef116 100644 --- a/web/src/lib/i18n/nb_NO.json +++ b/web/src/lib/i18n/nb_NO.json @@ -146,7 +146,7 @@ "note_cannot_be_changed_later": "MERK: Dette kan ikke endres senere!", "note_unlimited_quota": "Merk: Skriv inn 0 for ubegrenset kvote", "notification_email_from_address": "Fra adresse", - "notification_email_from_address_description": "Avsenderens e-postadresse, for eksempel: \"Immich Photo Server \"", + "notification_email_from_address_description": "Avsenderens e-postadresse, for eksempel: \"Immich Photo Server \"", "notification_email_host_description": "Verten til e-posts serveren (f.eks. smtp.immich.app)", "notification_email_ignore_certificate_errors": "Ignorer sertifikatfeil", "notification_email_ignore_certificate_errors_description": "Ignorer valideringsfeil for TLS-sertifikat (ikke anbefalt)", diff --git a/web/src/lib/i18n/nl.json b/web/src/lib/i18n/nl.json index d6b3373152c63..786a1627febbb 100644 --- a/web/src/lib/i18n/nl.json +++ b/web/src/lib/i18n/nl.json @@ -152,7 +152,7 @@ "note_cannot_be_changed_later": "LET OP: Dit kan later niet meer worden gewijzigd!", "note_unlimited_quota": "Opmerking: voer 0 in voor onbeperkt", "notification_email_from_address": "Adres afzender", - "notification_email_from_address_description": "E-mailadres van de afzender, bijvoorbeeld: \"Immich Foto Server \"", + "notification_email_from_address_description": "E-mailadres van de afzender, bijvoorbeeld: \"Immich Foto Server \"", "notification_email_host_description": "Host van de e-mailserver (bijv. smtp.immich.app)", "notification_email_ignore_certificate_errors": "Negeer certificaatfouten", "notification_email_ignore_certificate_errors_description": "Negeer TLS certificaat validatiefouten (niet aanbevolen)", diff --git a/web/src/lib/i18n/pl.json b/web/src/lib/i18n/pl.json index ff06e41233100..089a6550cd28f 100644 --- a/web/src/lib/i18n/pl.json +++ b/web/src/lib/i18n/pl.json @@ -152,7 +152,7 @@ "note_cannot_be_changed_later": "UWAŻAJ: Nie można tego później zmienić!", "note_unlimited_quota": "Wpisz by wyłączyć limit", "notification_email_from_address": "Z adresu", - "notification_email_from_address_description": "Adres e-mail nadawcy, na przykład: „Immich Photo Server ”", + "notification_email_from_address_description": "Adres e-mail nadawcy, na przykład: „Immich Photo Server ”", "notification_email_host_description": "Host serwera e-mail (np. smtp.immich.app)", "notification_email_ignore_certificate_errors": "Ignoruj niepoprawny certyfikat", "notification_email_ignore_certificate_errors_description": "Ignoruj błąd walidacji certyfikatu TLS (nie zalecane)", diff --git a/web/src/lib/i18n/pt.json b/web/src/lib/i18n/pt.json index 24c13ef03d58d..ebe1e85729148 100644 --- a/web/src/lib/i18n/pt.json +++ b/web/src/lib/i18n/pt.json @@ -149,7 +149,7 @@ "note_cannot_be_changed_later": "NOTA: Isto não pode ser alterado posteriormente!", "note_unlimited_quota": "Observação: insira 0 para cota ilimitada", "notification_email_from_address": "A partir do endereço", - "notification_email_from_address_description": "Endereço de e-mail do remetente, por exemplo: \"Immich Photo Server \"", + "notification_email_from_address_description": "Endereço de e-mail do remetente, por exemplo: \"Immich Photo Server \"", "notification_email_host_description": "Host do servidor de e-mail (por exemplo, smtp.immich.app)", "notification_email_ignore_certificate_errors": "Ignorar erros de certificado", "notification_email_ignore_certificate_errors_description": "Ignorar erros de validação de certificado TLS (não recomendado)", diff --git a/web/src/lib/i18n/pt_BR.json b/web/src/lib/i18n/pt_BR.json index 1e0de69aeda59..05f7bee7b0c9d 100644 --- a/web/src/lib/i18n/pt_BR.json +++ b/web/src/lib/i18n/pt_BR.json @@ -148,7 +148,7 @@ "note_cannot_be_changed_later": "NOTA: Isto não pode ser alterado posteriormente!", "note_unlimited_quota": "Observação: insira 0 para cota ilimitada", "notification_email_from_address": "A partir do endereço", - "notification_email_from_address_description": "Endereço de e-mail do remetente, por exemplo: \"Immich Photo Server \"", + "notification_email_from_address_description": "Endereço de e-mail do remetente, por exemplo: \"Immich Photo Server \"", "notification_email_host_description": "Host do servidor de e-mail (por exemplo, smtp.immich.app)", "notification_email_ignore_certificate_errors": "Ignorar erros de certificado", "notification_email_ignore_certificate_errors_description": "Ignorar erros de validação de certificado TLS (não recomendado)", diff --git a/web/src/lib/i18n/ro.json b/web/src/lib/i18n/ro.json index 534794b6d312c..29acdd03ce225 100644 --- a/web/src/lib/i18n/ro.json +++ b/web/src/lib/i18n/ro.json @@ -152,7 +152,7 @@ "note_cannot_be_changed_later": "NOTĂ: Nu se va mai putea modifica ulterior!", "note_unlimited_quota": "Notă: Introduceți 0 pentru cotă nelimitată", "notification_email_from_address": "De la adresa", - "notification_email_from_address_description": "Adresa expeditorului, spre exemplu: „Immich Photo Server ”", + "notification_email_from_address_description": "Adresa expeditorului, spre exemplu: „Immich Photo Server ”", "notification_email_host_description": "Adresa serverului de email (e.g. smtp.immich.app)", "notification_email_ignore_certificate_errors": "Ingnoră erorile de certificat", "notification_email_ignore_certificate_errors_description": "Ignoră erorile de validare a certificatului TLS (nerecomandat)", diff --git a/web/src/lib/i18n/ru.json b/web/src/lib/i18n/ru.json index 660f7bc84c952..bd725a11cfa34 100644 --- a/web/src/lib/i18n/ru.json +++ b/web/src/lib/i18n/ru.json @@ -152,7 +152,7 @@ "note_cannot_be_changed_later": "ПРИМЕЧАНИЕ: Это невозможно изменить позже!", "note_unlimited_quota": "Примечание: Введите 0 для неограниченной квоты или оставьте пустым", "notification_email_from_address": "Адрес отправителя", - "notification_email_from_address_description": "Адрес электронной почты отправителя, например: \"Immich Photo Server \"", + "notification_email_from_address_description": "Адрес электронной почты отправителя, например: \"Immich Photo Server \"", "notification_email_host_description": "Доменное имя почтового сервера (например, smtp.immich.app)", "notification_email_ignore_certificate_errors": "Игнорировать ошибки сертификата", "notification_email_ignore_certificate_errors_description": "Игнорировать ошибки проверки сертификата TLS (не рекомендуется)", diff --git a/web/src/lib/i18n/sr_Cyrl.json b/web/src/lib/i18n/sr_Cyrl.json index 6618aeab1dbc3..7bcd1e3dd8634 100644 --- a/web/src/lib/i18n/sr_Cyrl.json +++ b/web/src/lib/i18n/sr_Cyrl.json @@ -152,7 +152,7 @@ "note_cannot_be_changed_later": "НАПОМЕНА: Ovo se kasnije ne može promeniti!", "note_unlimited_quota": "Напомена: Unesite 0 za neograničenu kvotu", "notification_email_from_address": "Са адресе", - "notification_email_from_address_description": "Адреса е-поште пошиљаоца, на пример: \"Immich foto server \"", + "notification_email_from_address_description": "Адреса е-поште пошиљаоца, на пример: \"Immich foto server \"", "notification_email_host_description": "Хост сервера е-поште (нпр. smtp.immich.app)", "notification_email_ignore_certificate_errors": "Занемарите грешке сертификата", "notification_email_ignore_certificate_errors_description": "Игноришите грешке у валидацији ТЛС сертификата (не препоручује се)", diff --git a/web/src/lib/i18n/sr_Latn.json b/web/src/lib/i18n/sr_Latn.json index ea40525a81d6a..beb2009b4d4a8 100644 --- a/web/src/lib/i18n/sr_Latn.json +++ b/web/src/lib/i18n/sr_Latn.json @@ -152,7 +152,7 @@ "note_cannot_be_changed_later": "NAPOMENA: Ovo se kasnije ne može promeniti!", "note_unlimited_quota": "Napomena: Unesite 0 za neograničenu kvotu", "notification_email_from_address": "Sa adrese", - "notification_email_from_address_description": "Adresa e-pošte pošiljaoca, na primer: \"Immich foto server \"", + "notification_email_from_address_description": "Adresa e-pošte pošiljaoca, na primer: \"Immich foto server \"", "notification_email_host_description": "Host servera e-pošte (npr. smtp.immich.app)", "notification_email_ignore_certificate_errors": "Zanemarite greške sertifikata", "notification_email_ignore_certificate_errors_description": "Ignorišite greške u validaciji TLS sertifikata (ne preporučuje se)", diff --git a/web/src/lib/i18n/sv.json b/web/src/lib/i18n/sv.json index cebb74377e4f2..b00d521b20fe6 100644 --- a/web/src/lib/i18n/sv.json +++ b/web/src/lib/i18n/sv.json @@ -152,7 +152,7 @@ "note_cannot_be_changed_later": "OBS: Detta kan inte ändras i efterhand!", "note_unlimited_quota": "OBS: Skriv 0 för obegränsad kvota", "notification_email_from_address": "Från adress", - "notification_email_from_address_description": "Avsändarens epost, t.ex.: \"Immich Fotoserver \"", + "notification_email_from_address_description": "Avsändarens epost, t.ex.: \"Immich Fotoserver \"", "notification_email_host_description": "Värd för epostservern (t.ex. smtp.immich.app)", "notification_email_ignore_certificate_errors": "Ignorera certifikatfel", "notification_email_ignore_certificate_errors_description": "Ignorera valideringsfel för TLS-certifikat (rekommenderas ej)", diff --git a/web/src/lib/i18n/ta.json b/web/src/lib/i18n/ta.json index ec3f27124bdc2..bb7e888b76133 100644 --- a/web/src/lib/i18n/ta.json +++ b/web/src/lib/i18n/ta.json @@ -143,7 +143,7 @@ "note_cannot_be_changed_later": "குறிப்பு: இதை பின்னர் மாற்ற முடியாது!", "note_unlimited_quota": "குறிப்பு: வரம்பற்ற ஒதுக்கீட்டிற்கு 0 ஐ உள்ளிடவும்", "notification_email_from_address": "முகவரியிலிருந்து", - "notification_email_from_address_description": "அனுப்புநரின் மின்னஞ்சல் முகவரி, எடுத்துக்காட்டாக: \"இம்மிச் புகைப்பட சேவையகம் \"", + "notification_email_from_address_description": "அனுப்புநரின் மின்னஞ்சல் முகவரி, எடுத்துக்காட்டாக: \"இம்மிச் புகைப்பட சேவையகம் \"", "notification_email_host_description": "மின்னஞ்சல் சேவையகத்தின் ஹோஸ்ட் (எடுத்துக்காட்டாக: smtp.immich.app)", "notification_email_ignore_certificate_errors": "சான்றிதழ் பிழைகளை புறக்கணிக்கவும்", "notification_email_ignore_certificate_errors_description": "TLS சான்றிதழ் சரிபார்ப்பு பிழைகளை புறக்கணிக்கவும் (பரிந்துரைக்கப்படவில்லை)", diff --git a/web/src/lib/i18n/th.json b/web/src/lib/i18n/th.json index 19496b423843f..32336bfb4e9ce 100644 --- a/web/src/lib/i18n/th.json +++ b/web/src/lib/i18n/th.json @@ -147,7 +147,7 @@ "note_cannot_be_changed_later": "หมายเหตุ: ไม่สามารถเปลี่ยนภายหลังได้!", "note_unlimited_quota": "หมายเหตุ: ใส่เลข 0 สําหรับโควต้าไม่จํากัด", "notification_email_from_address": "จากที่อยู่", - "notification_email_from_address_description": "อีเมลผู้ส่ง อย่างเช่น \"Immich Photo Server \"", + "notification_email_from_address_description": "อีเมลผู้ส่ง อย่างเช่น \"Immich Photo Server \"", "notification_email_host_description": "ที่อยู่เซิร์ฟเวอร์อีเมล (เช่น smtp.immich.app)", "notification_email_ignore_certificate_errors": "ไม่สนใจข้อผิดพลาดเกี่ยวกับใบรับรอง", "notification_email_ignore_certificate_errors_description": "ไม่สนใจการยืนยันใบรับรอง TLS ผิดพลาด (ไม่แนะนำ)", diff --git a/web/src/lib/i18n/tr.json b/web/src/lib/i18n/tr.json index 4fefbf2f2132a..3a8a0db8af481 100644 --- a/web/src/lib/i18n/tr.json +++ b/web/src/lib/i18n/tr.json @@ -151,7 +151,7 @@ "note_cannot_be_changed_later": "NOT: Bu daha sonra değiştirilemez!", "note_unlimited_quota": "NOT: Sınırsız kota için 0 yazın", "notification_email_from_address": "Şu adresten", - "notification_email_from_address_description": "Göndericinin email adresi, örnek: \"Immich Fotoğraf Sunucusu \"", + "notification_email_from_address_description": "Göndericinin email adresi, örnek: \"Immich Fotoğraf Sunucusu \"", "notification_email_host_description": "E-posta sunucusunun ana bilgisayarı (örneğin, smtp.immich.app)", "notification_email_ignore_certificate_errors": "Sertifika hatalarını görmezden gel", "notification_email_ignore_certificate_errors_description": "TLS sertifika doğrulama ayarlarını görmezden gel (Önerilmez)", diff --git a/web/src/lib/i18n/uk.json b/web/src/lib/i18n/uk.json index 64411ef7586f4..ce72fde8b42ff 100644 --- a/web/src/lib/i18n/uk.json +++ b/web/src/lib/i18n/uk.json @@ -152,7 +152,7 @@ "note_cannot_be_changed_later": "ПРИМІТКА: Це не можна змінити пізніше!", "note_unlimited_quota": "Примітка: Введіть 0 для необмеженого обсягу квоти", "notification_email_from_address": "З адреси", - "notification_email_from_address_description": "Адреса електронної пошти відправника, наприклад: \"Immich Photo Server \"", + "notification_email_from_address_description": "Адреса електронної пошти відправника, наприклад: \"Immich Photo Server \"", "notification_email_host_description": "Хост поштового сервера (наприклад, smtp.immich.app)", "notification_email_ignore_certificate_errors": "Ігнорувати помилки сертифіката", "notification_email_ignore_certificate_errors_description": "Ігнорувати помилки перевірки сертифікатів TLS (не рекомендується)", diff --git a/web/src/lib/i18n/vi.json b/web/src/lib/i18n/vi.json index c4f23ec273520..e94eb7a46471f 100644 --- a/web/src/lib/i18n/vi.json +++ b/web/src/lib/i18n/vi.json @@ -152,7 +152,7 @@ "note_cannot_be_changed_later": "LƯU Ý: Cài đặt này không thể thay đổi được sau khi lưu!", "note_unlimited_quota": "Lưu ý: Nhập 0 để hạn mức không giới hạn", "notification_email_from_address": "Địa chỉ email người gửi", - "notification_email_from_address_description": "Địa chỉ email của người gửi, ví dụ: \"Immich Photo Server \"", + "notification_email_from_address_description": "Địa chỉ email của người gửi, ví dụ: \"Immich Photo Server \"", "notification_email_host_description": "Địa chỉ máy chủ email (ví dụ: smtp.immich.app)", "notification_email_ignore_certificate_errors": "Bỏ qua các lỗi chứng chỉ", "notification_email_ignore_certificate_errors_description": "Bỏ qua lỗi xác thực chứng chỉ TLS (không khuyến nghị)", diff --git a/web/src/lib/i18n/zh_Hant.json b/web/src/lib/i18n/zh_Hant.json index 30e32f60c9ed1..fb9a18a1f507f 100644 --- a/web/src/lib/i18n/zh_Hant.json +++ b/web/src/lib/i18n/zh_Hant.json @@ -152,7 +152,7 @@ "note_cannot_be_changed_later": "註:之後就無法更改嘍!", "note_unlimited_quota": "註:輸入 0 表示不限制配額", "notification_email_from_address": "寄件地址", - "notification_email_from_address_description": "寄件者電子郵件地址(例:Immich Photo Server )", + "notification_email_from_address_description": "寄件者電子郵件地址(例:Immich Photo Server )", "notification_email_host_description": "電子郵件伺服器主機(例:smtp.immich.app)", "notification_email_ignore_certificate_errors": "忽略憑證錯誤", "notification_email_ignore_certificate_errors_description": "忽略 TLS 憑證驗證錯誤(不建議)", diff --git a/web/src/lib/i18n/zh_SIMPLIFIED.json b/web/src/lib/i18n/zh_SIMPLIFIED.json index b56c2d29ebd94..e879365f410bb 100644 --- a/web/src/lib/i18n/zh_SIMPLIFIED.json +++ b/web/src/lib/i18n/zh_SIMPLIFIED.json @@ -152,7 +152,7 @@ "note_cannot_be_changed_later": "注意:此项一旦设定,以后无法更改!", "note_unlimited_quota": "提示:输入0表示无限制", "notification_email_from_address": "发件人地址", - "notification_email_from_address_description": "发件人邮箱地址,例如“Immich 服务器 ”", + "notification_email_from_address_description": "发件人邮箱地址,例如“Immich 服务器 ”", "notification_email_host_description": "邮件服务器主机(例如 smtp.immich.app)", "notification_email_ignore_certificate_errors": "忽略证书错误", "notification_email_ignore_certificate_errors_description": "忽略TLS证书验证错误(不建议)", From edb085691a67c84997f4f34ca2dede7f82af2bb2 Mon Sep 17 00:00:00 2001 From: "Weblate (bot)" Date: Mon, 16 Sep 2024 18:19:57 +0200 Subject: [PATCH 033/123] chore(web): update translations (#12590) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Translate-URL: https://hosted.weblate.org/projects/immich/immich/bg/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/ca/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/cs/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/da/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/de/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/es/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/fi/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/fr/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/he/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/hr/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/it/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/ko/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/lv/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/nb_NO/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/nl/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/ro/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/ru/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/sk/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/sr_Cyrl/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/sr_Latn/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/th/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/uk/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/vi/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/zh_SIMPLIFIED/ Translation: Immich/immich Co-authored-by: Bezruchenko Simon Co-authored-by: Boris Garmev Co-authored-by: David Abner Ciuhan Co-authored-by: Dean Cvjetanović Co-authored-by: Denis Pacquier Co-authored-by: Eero Jääskeläinen Co-authored-by: Javier Montón Co-authored-by: Junghyuk Kwon Co-authored-by: Michal Micech Co-authored-by: Miki Mrvos Co-authored-by: Mārtiņš Bruņenieks Co-authored-by: Owen Higgins Co-authored-by: Pat Oakly Co-authored-by: Poramate Homprakob Co-authored-by: Riccardo Co-authored-by: RoanV Co-authored-by: Roger Veciana Rovira Co-authored-by: Rémi Saurel Co-authored-by: Sam Smith Co-authored-by: Vladimir Petrov (Vlado) Co-authored-by: Xo Co-authored-by: aarhor Co-authored-by: chapvic Co-authored-by: dvbthien Co-authored-by: kiwinho Co-authored-by: pyccl Co-authored-by: pyorot Co-authored-by: waclaw66 --- web/src/lib/i18n/bg.json | 80 +- web/src/lib/i18n/ca.json | 73 +- web/src/lib/i18n/cs.json | 3 + web/src/lib/i18n/da.json | 14 + web/src/lib/i18n/de.json | 2 +- web/src/lib/i18n/es.json | 65 +- web/src/lib/i18n/fi.json | 6 + web/src/lib/i18n/fr.json | 52 +- web/src/lib/i18n/he.json | 14 +- web/src/lib/i18n/hr.json | 1136 ++++++++++++++++----------- web/src/lib/i18n/it.json | 3 + web/src/lib/i18n/ko.json | 8 +- web/src/lib/i18n/lv.json | 120 +-- web/src/lib/i18n/nb_NO.json | 3 + web/src/lib/i18n/nl.json | 2 + web/src/lib/i18n/ro.json | 304 ++++--- web/src/lib/i18n/ru.json | 5 +- web/src/lib/i18n/sk.json | 4 +- web/src/lib/i18n/sr_Cyrl.json | 3 + web/src/lib/i18n/sr_Latn.json | 3 + web/src/lib/i18n/th.json | 17 +- web/src/lib/i18n/uk.json | 4 + web/src/lib/i18n/vi.json | 3 + web/src/lib/i18n/zh_SIMPLIFIED.json | 66 +- 24 files changed, 1237 insertions(+), 753 deletions(-) diff --git a/web/src/lib/i18n/bg.json b/web/src/lib/i18n/bg.json index ef739e745219c..29ac04eda8b1e 100644 --- a/web/src/lib/i18n/bg.json +++ b/web/src/lib/i18n/bg.json @@ -137,7 +137,7 @@ "map_settings_description": "Управление на настройките на картата", "map_style_description": "URL адрес към файл \"style.json\" за задаване на стил на картата", "metadata_extraction_job": "Извличане на метаданни", - "metadata_extraction_job_description": "Извличане на метаданни от всеки ресурс, като GPS и резолюция", + "metadata_extraction_job_description": "Извличане на метаданни от всеки от ресурсите, като GPS локация, лица и резолюция на файловете", "metadata_faces_import_setting": "Включи импорт на лице", "metadata_faces_import_setting_description": "Импортирай лица от EXIF данни и помощни файлове", "metadata_settings": "Опции за метаданни", @@ -176,7 +176,7 @@ "oauth_issuer_url": "URL на издателя", "oauth_mobile_redirect_uri": "URI за мобилно пренасочване", "oauth_mobile_redirect_uri_override": "URI пренасочване за мобилни устройства", - "oauth_mobile_redirect_uri_override_description": "Разреши когато 'app.immich:/' е невалиден пренасочвар адрес/URI.", + "oauth_mobile_redirect_uri_override_description": "Разреши когато доставчика за OAuth удостоверяване не позволява за мобилни URI идентификатори, като '{callback}'", "oauth_profile_signing_algorithm": "Алгоритъм за създаване на профили", "oauth_profile_signing_algorithm_description": "Алгоритъм излпозлван за вписване на потребителски профил.", "oauth_scope": "Област/обхват на приложение", @@ -244,7 +244,7 @@ "thumbnail_generation_job": "Генериране на миниатюри", "thumbnail_generation_job_description": "Генерирайте големи, малки и замъглени миниатюри за всеки актив, както и миниатюри за всеки човек", "transcoding_acceleration_api": "API за ускоряване", - "transcoding_acceleration_api_description": "API, който ще взаимодейства с вашето устройство, за да ускори транскодирането. Тази настройка е „best effort“: тя ще се върне към софтуерно транскодиране при повреда. VP9 може или не може да работи в зависимост от вашия хардуер.", + "transcoding_acceleration_api_description": "API интерфейсът, който ще взаимодейства с вашето устройство, за да ускори транскодирането. Тази настройка е „възможно най-доброто“: тя ще се върне към софтуерно транскодиране при повреда. VP9 може и да не работи в зависимост от вашия хардуер.", "transcoding_acceleration_nvenc": "NVENC (необходим NVIDIA GPU)", "transcoding_acceleration_qsv": "Quick Sync (необходим 7th поколение Intel CPU или по-ново)", "transcoding_acceleration_rkmpp": "RKMPP (само на Rockchip SOCs)", @@ -252,9 +252,9 @@ "transcoding_accepted_audio_codecs": "Допустими аудио кодеци", "transcoding_accepted_audio_codecs_description": "Изберете кои аудио кодеци не са нужни за разкодиране. Използва се само за определени правила за разкодиране.", "transcoding_accepted_containers": "Приети контейнери", - "transcoding_accepted_containers_description": "Изберете кои формати на контейнери не трябва да се пренасочват към MP4. Използва се само за определени правила за разкодиране.", + "transcoding_accepted_containers_description": "Изберете кои формати на контейнери не е нужно да бъдат преобразувани в MP4 формат. Използва се само за определени правила за разкодиране.", "transcoding_accepted_video_codecs": "Приети видео кодеци", - "transcoding_accepted_video_codecs_description": "Изберете кои видео кодеци не трябва да се разкодиране. Използва се само за определени правила за разкодиране.", + "transcoding_accepted_video_codecs_description": "Изберете кои видео кодеци не трябват за разкодиране. Използва се само за определени правила за разкодиране.", "transcoding_advanced_options_description": "Опции, които повечето потребители не трябва да променят", "transcoding_audio_codec": "Аудио кодек", "transcoding_audio_codec_description": "Opus е опцията с най-високо качество, но има по-ниска съвместимост със стари устройства или софтуер.", @@ -446,7 +446,7 @@ "copy_to_clipboard": "Копиране в клипборда", "country": "Държава", "cover": "", - "covers": "", + "covers": "Обложка", "create": "Създай", "create_album": "Създай албум", "create_library": "Създай библиотека", @@ -938,19 +938,22 @@ "search_city": "", "search_country": "", "search_for_existing_person": "", - "search_people": "", - "search_places": "", + "search_people": "Търсете на хора", + "search_places": "Търсене на места", "search_state": "", - "search_timezone": "", - "search_type": "", - "search_your_photos": "", + "search_tags": "Търсене на етикети...", + "search_timezone": "Търсене на часова зона...", + "search_type": "Тип на търсене", + "search_your_photos": "Търсете вашите снимки", "searching_locales": "", "second": "Секунда", - "select_album_cover": "", - "select_all": "", - "select_avatar_color": "", - "select_face": "", + "see_all_people": "Вижте всички хора", + "select_album_cover": "Изберете обложка на албум", + "select_all": "Изберете всички", + "select_avatar_color": "Изберете цвят на аватара", + "select_face": "Изберете лице", "select_featured_photo": "", + "select_from_computer": "Изберете от компютъра", "select_keep_all": "", "select_library_owner": "Изберете собственик на библиотека", "select_new_face": "Изберете ново лице", @@ -998,28 +1001,40 @@ "show_metadata": "Покажи метаданни", "show_or_hide_info": "Покажи или скрий информацията", "show_password": "Покажи паролата", - "show_person_options": "", - "show_progress_bar": "", - "show_search_options": "", + "show_person_options": "Показване на опции за лица", + "show_progress_bar": "Показване на прогрес бара", + "show_search_options": "Показване на опциите за търсене", + "show_supporter_badge": "Значка поддръжник", + "show_supporter_badge_description": "Покажи значка поддръжник", "shuffle": "Разбъркване", - "sign_out": "", - "sign_up": "", + "sidebar": "Странична лента", + "sidebar_display_description": "Показване на връзка към изгледа в страничната лента", + "sign_out": "Отписване", + "sign_up": "Запиши се", "size": "Размер", - "skip_to_content": "", + "skip_to_content": "Премини към съдържанието", + "skip_to_folders": "Премини към папките", + "skip_to_tags": "Премини към етикетите", "slideshow": "Слайдшоу", - "slideshow_settings": "", - "sort_albums_by": "", + "slideshow_settings": "Настройки за слайдшоу", + "sort_albums_by": "Сортиране на албуми по...", + "sort_created": "Дата на създаване", + "sort_items": "Брой елементи", + "sort_modified": "Дата на промяна", + "sort_oldest": "Най-старата снимка", + "sort_recent": "Най-новата снимка", "sort_title": "Заглавие", "source": "Източник", "stack": "", - "stack_selected_photos": "", + "stack_duplicates": "Подреждане на дубликати", + "stack_selected_photos": "Подреждане на избрани снимки", "stacktrace": "", "start": "Старт", - "start_date": "", + "start_date": "Начална дата", "state": "", "status": "Статус", "stop_motion_photo": "", - "stop_photo_sharing": "Да спрете ли споделянето на вашите снимки?", + "stop_photo_sharing": "Да спра ли споделянето на вашите снимки?", "stop_photo_sharing_description": "{partner} вече няма достъп до вашите снимки.", "stop_sharing_photos_with_user": "Прекратете споделянето на снимки с този потребител", "storage": "Пространство на хранилището", @@ -1030,6 +1045,12 @@ "sunrise_on_the_beach": "Изгрев на плажа", "swap_merge_direction": "Размяна посоката на сливане", "sync": "Синхронизиране", + "tag": "Таг", + "tag_created": "Създаден етикет: {tag}", + "tag_feature_description": "Разглеждане на снимки и видеоклипове, групирани по теми с логически тагове", + "tag_not_found_question": "Не можете да намерите етикет? Създайте такъв тук", + "tag_updated": "Актуализиран етикет: {tag}", + "tags": "Етикет", "template": "Шаблон", "theme": "Тема", "theme_selection": "Избор на тема", @@ -1048,7 +1069,7 @@ "total_usage": "Общо използвано", "trash": "кошче", "trash_all": "Изхвърли всички", - "trash_count": "", + "trash_count": "Кошче {count, number}", "trash_no_results_message": "Изтритите снимки и видеоклипове ще се показват тук.", "trashed_items_will_be_permanently_deleted_after": "Изхвърлените в кошчето елементи ще бъдат изтрити за постоянно след {days, plural, one {# day} other {# days}}.", "type": "Тип", @@ -1062,6 +1083,7 @@ "unlink_oauth": "", "unlinked_oauth_account": "", "unnamed_album": "Албум без име", + "unnamed_album_delete_confirmation": "Сигурни ли сте, че искате да изтриете този албум?", "unnamed_share": "Споделяне без име", "unsaved_change": "Незапазена промяна", "unselect_all": "Деселектирайте всички", @@ -1085,7 +1107,7 @@ "user_purchase_settings": "Покупка", "user_purchase_settings_description": "Управлявай покупката си", "user_role_set": "Задай {user} като {role}", - "user_usage_detail": "", + "user_usage_detail": "Подробности за използването на потребителя", "username": "Потребителско име", "users": "Потребители", "utilities": "Инструменти", @@ -1103,9 +1125,11 @@ "view_album": "Разгледай албума", "view_all": "Преглед на всички", "view_all_users": "Преглед на всички потребители", + "view_in_timeline": "Покажи във времева линия", "view_links": "Преглед на връзките", "view_next_asset": "Преглед на следващия файл", "view_previous_asset": "Преглед на предишния файл", + "view_stack": "Покажи в стек", "viewer": "", "waiting": "в изчакване", "warning": "Внимание", diff --git a/web/src/lib/i18n/ca.json b/web/src/lib/i18n/ca.json index ba33c9b15606b..e9c695f79a74e 100644 --- a/web/src/lib/i18n/ca.json +++ b/web/src/lib/i18n/ca.json @@ -129,16 +129,21 @@ "map_enable_description": "Habilita característiques del mapa", "map_gps_settings": "Configuració de mapa i GPS", "map_gps_settings_description": "Gestiona la configuració de mapa i GPS (Geocodificació inversa)", + "map_implications": "La funció mapa depèn del servei extern de tesel·les (tiles.immich.cloud)", "map_light_style": "Tema clar", "map_manage_reverse_geocoding_settings": "Gestiona els paràmetres de geocodificació inversa", "map_reverse_geocoding": "Geocodificació inversa", "map_reverse_geocoding_enable_description": "Habilita la geocodificació inversa", "map_reverse_geocoding_settings": "Configuració de Geocodificació Inversa", - "map_settings": "Configuració del mapa i GPS", + "map_settings": "Mapa", "map_settings_description": "Gestiona la configuració del mapa", "map_style_description": "URL a un tema del mapa style.json", "metadata_extraction_job": "Extreure metadades", "metadata_extraction_job_description": "Extreu la informació de metadades de cada element, com per exemple el GPS i la resolució", + "metadata_faces_import_setting": "Activar la importació de cares", + "metadata_faces_import_setting_description": "Importar cares des de les metadades EXIF de les imatges i arxius auxiliars", + "metadata_settings": "Configuració de les metadades", + "metadata_settings_description": "Administrar la configuració de les metadades", "migration_job": "Migració", "migration_job_description": "Migra les miniatures d'elements i cares cap a la nova estructura de carpetes", "no_paths_added": "Cap camí afegit", @@ -173,7 +178,7 @@ "oauth_issuer_url": "URL de l'emissor", "oauth_mobile_redirect_uri": "URI de redirecció mòbil", "oauth_mobile_redirect_uri_override": "Sobreescriu l'URI de redirecció mòbil", - "oauth_mobile_redirect_uri_override_description": "Habilita quan 'app.immich:/' és una URI de redirecció invàlida.", + "oauth_mobile_redirect_uri_override_description": "Habilita quan el proveïdor d'OAuth no permet una URI mòbil, com ara '{callback}'", "oauth_profile_signing_algorithm": "Algoritme de signatura del perfil", "oauth_profile_signing_algorithm_description": "Algoritme utilitzat per signar el perfil d’usuari.", "oauth_scope": "Abast", @@ -278,7 +283,7 @@ "transcoding_preferred_hardware_device": "Dispositiu de maquinari preferit", "transcoding_preferred_hardware_device_description": "S'aplica només a VAAPI i QSV. Estableix el node dri utilitzat per a la transcodificació de maquinari.", "transcoding_preset_preset": "Preestablert (-preset)", - "transcoding_preset_preset_description": "Velocitat de compressió. Els valors predefinits més lents produeixen fitxers més petits i augmenten la qualitat quan s'orienta a una taxa de bits determinada. VP9 ignora les velocitats superiors a \"més ràpides\".", + "transcoding_preset_preset_description": "Velocitat de compressió. Els valors predefinits més lents produeixen fitxers més petits i augmenten la qualitat quan s'orienta a una taxa de bits determinada. VP9 ignora les velocitats superiors a 'més ràpides'.", "transcoding_reference_frames": "Fotogrames de referència", "transcoding_reference_frames_description": "El nombre de fotogrames a fer referència en comprimir un fotograma determinat. Els valors més alts milloren l'eficiència de la compressió, però alenteixen la codificació. 0 estableix aquest valor automàticament.", "transcoding_required_description": "Només vídeos que no tenen un format acceptat", @@ -320,7 +325,8 @@ "user_settings": "Configuració d'usuaris", "user_settings_description": "Gestiona la configuració dels usuaris", "user_successfully_removed": "L'usuari {email} s'ha eliminat correctament.", - "version_check_enabled_description": "Activa sol·licituds periòdiques a GitHub per comprovar si hi ha versions noves", + "version_check_enabled_description": "Activa la comprovació de la versió", + "version_check_implications": "La funció de comprovació de versions depèn de comunicacions periòdiques amb github.com", "version_check_settings": "Comprovació de versió", "version_check_settings_description": "Activa/desactiva la notificació de nova versió", "video_conversion_job": "Transcodificació de vídeos", @@ -336,7 +342,8 @@ "album_added": "Àlbum afegit", "album_added_notification_setting_description": "Rep una notificació per correu quan siguis afegit a un àlbum compartit", "album_cover_updated": "Portada de l'àlbum actualitzada", - "album_delete_confirmation": "N'esteu segur que voleu suprimir l'àlbum {album}?\nSi aquest àlbum és compartit, altres usuaris no hi podran accedir més.", + "album_delete_confirmation": "Esteu segur que voleu suprimir l'àlbum {album}?", + "album_delete_confirmation_description": "Si aquest àlbum es comparteix, els altres usuaris ja no podran accedir-hi.", "album_info_updated": "Informació de l'àlbum actualitzada", "album_leave": "Sortir de l'àlbum?", "album_leave_confirmation": "N'esteu segur que voleu sortir de {album}?", @@ -360,6 +367,7 @@ "allow_edits": "Permet editar", "allow_public_user_to_download": "Permet que l'usuari públic pugui descarregar", "allow_public_user_to_upload": "Permet que l'usuari públic pugui carregar", + "anti_clockwise": "En sentit antihorari", "api_key": "Clau API", "api_key_description": "Aquest valor només es mostrarà una vegada. Assegureu-vos de copiar-lo abans de tancar la finestra.", "api_key_empty": "El nom de la clau de l'API no pot estar buit", @@ -383,6 +391,7 @@ "asset_offline": "Element fora de línia", "asset_offline_description": "Aquest element està fora de línia. L'Immich no pot accedir a la seva ubicació. Si us plau, assegureu-vos que l'actiu està disponible i després torneu la llibreria.", "asset_skipped": "Saltat", + "asset_skipped_in_trash": "A la paperera", "asset_uploaded": "Carregat", "asset_uploading": "S'està carregant...", "assets": "Elements", @@ -440,9 +449,11 @@ "clear_all_recent_searches": "Esborra totes les cerques recents", "clear_message": "Neteja el missatge", "clear_value": "Neteja el valor", + "clockwise": "En sentit horari", "close": "Tanca", "collapse": "Tanca", "collapse_all": "Redueix-ho tot", + "color": "Color", "color_theme": "Tema de color", "comment_deleted": "Comentari esborrat", "comment_options": "Opcions de comentari", @@ -476,6 +487,8 @@ "create_new_person": "Crea una nova persona", "create_new_person_hint": "Assigna els elements seleccionats a una persona nova", "create_new_user": "Crea un usuari nou", + "create_tag": "Crear etiqueta", + "create_tag_description": "Crear una nova etiqueta. Per les etiquetes aniuades, escriu la ruta comperta de l'etiqueta, incloses les barres diagonals.", "create_user": "Crea un usuari", "created": "Creat", "current_device": "Dispositiu actual", @@ -499,6 +512,8 @@ "delete_library": "Suprimeix la llibreria", "delete_link": "Esborra l'enllaç", "delete_shared_link": "Odstranit sdílený odkaz", + "delete_tag": "Eliminar etiqueta", + "delete_tag_confirmation_prompt": "Estàs segur que vols eliminar l'etiqueta {tagName}?", "delete_user": "Suprimeix l'usuari", "deleted_shared_link": "Suprimeix l'enllaç compartit", "description": "Descripció", @@ -516,6 +531,8 @@ "do_not_show_again": "No tornis a mostrar aquest missatge", "done": "Fet", "download": "Descarregar", + "download_include_embedded_motion_videos": "Vídeos incrustats", + "download_include_embedded_motion_videos_description": "Incloure vídeos incrustats en fotografies en moviment com un arxiu separat", "download_settings": "Descarregar", "download_settings_description": "Gestioneu la configuració relacionada amb la descàrrega de recursos", "downloading": "Baixant", @@ -545,10 +562,15 @@ "edit_location": "Edita ubicació", "edit_name": "Edita el nom", "edit_people": "Edita la gent", + "edit_tag": "Editar etiqueta", "edit_title": "Edita títol", "edit_user": "Edita l'usuari", "edited": "Editat", "editor": "Editor", + "editor_close_without_save_prompt": "No es desaran els canvis", + "editor_close_without_save_title": "Tancar l'editor?", + "editor_crop_tool_h2_aspect_ratios": "Relació d'aspecte", + "editor_crop_tool_h2_rotation": "Rotació", "email": "Correu electrònic", "empty": "", "empty_album": "", @@ -638,6 +660,7 @@ "unable_to_get_comments_number": "No es pot obtenir el nombre de comentaris", "unable_to_get_shared_link": "No s'ha pogut obtenir l'enllaç compartit", "unable_to_hide_person": "No es pot amagar la persona", + "unable_to_link_motion_video": "No es pot enllaçar el vídeo en moviment", "unable_to_link_oauth_account": "No es pot enllaçar el compte OAuth", "unable_to_load_album": "No es pot carregar l'àlbum", "unable_to_load_asset_activity": "No es pot carregar l'activitat dels recursos", @@ -678,6 +701,7 @@ "unable_to_submit_job": "No es pot enviar la tasca", "unable_to_trash_asset": "No es pot eliminar el recurs a la paperera", "unable_to_unlink_account": "No es pot desenllaçar el compte", + "unable_to_unlink_motion_video": "No es pot desvincular el vídeo en moviment", "unable_to_update_album_cover": "No es pot actualitzar la portada de l'àlbum", "unable_to_update_album_info": "No es pot actualitzar la informació de l'àlbum", "unable_to_update_library": "No es pot actualitzar la biblioteca", @@ -698,6 +722,7 @@ "expired": "Caducat", "expires_date": "Caduca el {date}", "explore": "Explorar", + "explorer": "Explorador", "export": "Exporta", "export_as_json": "Exportar com a JSON", "extension": "Extensió", @@ -711,6 +736,8 @@ "feature": "", "feature_photo_updated": "Foto destacada actualitzada", "featurecollection": "", + "features": "Característiques", + "features_setting_description": "Administrar les funcions de l'aplicació", "file_name": "Nom de l'arxiu", "file_name_or_extension": "Nom de l'arxiu o extensió", "filename": "Nom del fitxer", @@ -719,6 +746,8 @@ "filter_people": "Filtra persones", "find_them_fast": "Trobeu-los ràpidament pel nom amb la cerca", "fix_incorrect_match": "Corregiu la coincidència incorrecta", + "folders": "Carpetes", + "folders_feature_description": "Explorar la vista de carpetes per les fotos i vídeos del sistema d'arxius", "force_re-scan_library_files": "Força a tornar a escanejar tots els fitxers de la biblioteca", "forward": "Endavant", "general": "General", @@ -803,6 +832,7 @@ "license_trial_info_3": "{accountAge, plural, one {# dia} other {# dies}}", "light": "Llum", "like_deleted": "M'agrada suprimit", + "link_motion_video": "Enllaçar vídeo en moviment", "link_options": "Opcions d'enllaç", "link_to_oauth": "Enllaç a OAuth", "linked_oauth_account": "Compte OAuth enllaçat", @@ -896,12 +926,14 @@ "ok": "D'acord", "oldest_first": "El més vell primer", "onboarding": "Onboarding", + "onboarding_privacy_description": "Les següents funcions (opcionals) depenen de serveis externs i poden desactivarse en qualsevol moment de dels ajustos.", "onboarding_theme_description": "Trieu un tema de color per a la vostra instància. Podeu canviar-ho més endavant a la vostra configuració.", "onboarding_welcome_description": "Configurem la vostra instància amb alguns paràmetres habituals.", "onboarding_welcome_user": "Benvingut, {user}", "online": "En línia", "only_favorites": "Només preferits", "only_refreshes_modified_files": "Només actualitza els fitxers modificats", + "open_in_map_view": "Obrir a la vista del mapa", "open_in_openstreetmap": "Obre a OpenStreetMap", "open_the_search_filters": "Obriu els filtres de cerca", "options": "Opcions", @@ -936,6 +968,7 @@ "pending": "Pendent", "people": "Persones", "people_edits_count": "{count, plural, one {# persona editada} other {# persones editades}}", + "people_feature_description": "Explorar fotos i vídeos agrupades per persona", "people_sidebar_description": "Mostrar un enllaç a Persones a la barra lateral", "perform_library_tasks": "", "permanent_deletion_warning": "Avís d'eliminació permanent", @@ -967,6 +1000,7 @@ "previous_memory": "Memòria anterior", "previous_or_next_photo": "Foto anterior o següent", "primary": "Primària", + "privacy": "Privacitat", "profile_image_of_user": "Imatge de perfil de {user}", "profile_picture_set": "Imatge de perfil configurada.", "public_album": "Àlbum públic", @@ -1004,6 +1038,10 @@ "purchase_server_title": "Servidor", "purchase_settings_server_activated": "La clau de producte del servidor la gestiona l'administrador", "range": "", + "rating": "Valoració", + "rating_clear": "Esborrar valoració", + "rating_count": "{count, plural, one {# estrella} other {# estrelles}}", + "rating_description": "Mostrar la valoració EXIF al panell d'informació", "raw": "", "reaction_options": "Opcions de reacció", "read_changelog": "Llegeix el registre de canvis", @@ -1036,6 +1074,7 @@ "removed_from_archive": "Eliminat de l'arxiu", "removed_from_favorites": "Eliminat dels preferits", "removed_from_favorites_count": "{count, plural, other {# eliminats}} dels preferits", + "removed_tagged_assets": "Etiqueta eliminada de {count, plural, one {# actiu} other {# actius}}", "rename": "Canviar nom", "repair": "Reparació", "repair_no_results_message": "Els fitxers sense seguiment i que falten es mostraran aquí", @@ -1082,9 +1121,11 @@ "search_for_existing_person": "Busca una persona existent", "search_no_people": "Cap persona", "search_no_people_named": "Cap persona anomenada \"{name}\"", + "search_options": "Opcions de cerca", "search_people": "Buscar persones", "search_places": "Buscar llocs", "search_state": "Buscar per regió...", + "search_tags": "Cercant etiquetes...", "search_timezone": "Buscar per fus horari...", "search_type": "Buscar per tipus", "search_your_photos": "Cerca les teves fotos", @@ -1126,6 +1167,7 @@ "shared_by_user": "Compartit per {user}", "shared_by_you": "Compartit per tu", "shared_from_partner": "Fotos de {partner}", + "shared_link_options": "Opcions d'enllaços compartits", "shared_links": "Enllaços compartits", "shared_photos_and_videos_count": "{assetCount, plural, other {# fotos i vídeos compartits.}}", "shared_with_partner": "Compartit amb {partner}", @@ -1134,6 +1176,7 @@ "sharing_sidebar_description": "Mostra un enllaç a Compartit a la barra lateral", "shift_to_permanent_delete": "premeu ⇧ per suprimir el recurs permanentment", "show_album_options": "Mostra les opcions d'àlbum", + "show_albums": "Mostrar àlbums", "show_all_people": "Veure totes les persones", "show_and_hide_people": "Mostra i amaga persones", "show_file_location": "Mostra l'ubicació del fitxer", @@ -1151,10 +1194,14 @@ "show_supporter_badge": "Insígnia de contribuent", "show_supporter_badge_description": "Mostra una insígnia de contributor", "shuffle": "Mescla", + "sidebar": "Barra lateral", + "sidebar_display_description": "Mostra un enllaç a la vista a la barra lateral", "sign_out": "Tanca sessió", "sign_up": "Registrar-se", "size": "Mida", "skip_to_content": "Salta al contingut", + "skip_to_folders": "Anar a carpetes", + "skip_to_tags": "Anar a etiquetes", "slideshow": "Diapositives", "slideshow_settings": "Configuració de diapositives", "sort_albums_by": "Ordena àlbums per...", @@ -1166,6 +1213,8 @@ "sort_title": "Títol", "source": "Font", "stack": "Apila", + "stack_duplicates": "Aplicar duplicats", + "stack_select_one_photo": "Selecciona una imatge principal per la pila", "stack_selected_photos": "Apila les fotos seleccionades", "stacked_assets_count": "Apilats {count, plural, one {# element} other {# elements}}", "stacktrace": "Traça de pila", @@ -1185,6 +1234,14 @@ "sunrise_on_the_beach": "Albada a la platja", "swap_merge_direction": "Canvia la direcció d'unió", "sync": "Sincronitza", + "tag": "Etiqueta", + "tag_assets": "Etiquetar actius", + "tag_created": "Etiqueta creada: {tag}", + "tag_feature_description": "Exploreu fotos i vídeos agrupats per temes d'etiquetes lògiques", + "tag_not_found_question": "No trobeu una etiqueta? Creeu-ne una aquí", + "tag_updated": "Etiqueta actualizada: {tag}", + "tagged_assets": "{count, plural, one {#Etiquetat} other {#Etiquetats}} {count, plural, one {# actiu} other {# actius}}", + "tags": "Etiquetes", "template": "Plantilla", "theme": "Tema", "theme_selection": "Selecció de tema", @@ -1196,9 +1253,10 @@ "to_change_password": "Canviar la contrasenya", "to_favorite": "Prefereix", "to_login": "Iniciar sessió", + "to_parent": "Anar als pares", "to_trash": "Paperera", "toggle_settings": "Canvia configuració", - "toggle_theme": "Canvia tema", + "toggle_theme": "Alternar tema", "toggle_visibility": "Canvia visibilitat", "total_usage": "Ús total", "trash": "Paperera", @@ -1217,9 +1275,11 @@ "unknown_album": "Àlbum desconegut", "unknown_year": "Any desconegut", "unlimited": "Il·limitat", + "unlink_motion_video": "Desvincular vídeo en moviment", "unlink_oauth": "Desvincula OAuth", "unlinked_oauth_account": "Compte Oauth desvinculat", "unnamed_album": "Àlbum sense nom", + "unnamed_album_delete_confirmation": "Segur que voleu esborrar aquest àlbum?", "unnamed_share": "Compartit sense nom", "unsaved_change": "Canvi no desat", "unselect_all": "Deselecciona-ho tot", @@ -1267,6 +1327,7 @@ "view_album": "Veure l'àlbum", "view_all": "Veure tot", "view_all_users": "Mostra tot els usuaris", + "view_in_timeline": "Mostrar a la línia de temps", "view_links": "Mostra enllaços", "view_next_asset": "Mostra el següent element", "view_previous_asset": "Mostra l'element anterior", diff --git a/web/src/lib/i18n/cs.json b/web/src/lib/i18n/cs.json index e49d3700ee94f..c2d7bce0e5664 100644 --- a/web/src/lib/i18n/cs.json +++ b/web/src/lib/i18n/cs.json @@ -661,6 +661,7 @@ "unable_to_get_comments_number": "Nelze načíst počet komentářů", "unable_to_get_shared_link": "Nepodařilo se získat sdílený odkaz", "unable_to_hide_person": "Nelze skrýt osobu", + "unable_to_link_motion_video": "Nelze připojit pohyblivé video", "unable_to_link_oauth_account": "Nelze propojit OAuth účet", "unable_to_load_album": "Nelze načíst album", "unable_to_load_asset_activity": "Nelze načíst aktivitu položky", @@ -701,6 +702,7 @@ "unable_to_submit_job": "Nelze odeslat úlohu", "unable_to_trash_asset": "Nelze vyhodit položku do koše", "unable_to_unlink_account": "Nelze zrušit propojení účtu", + "unable_to_unlink_motion_video": "Nelze odpojit pohyblivé video", "unable_to_update_album_cover": "Nelze aktualizovat obal alba", "unable_to_update_album_info": "Nelze aktualizovat informace o albu", "unable_to_update_library": "Nelze aktualizovat knihovnu", @@ -1292,6 +1294,7 @@ "unknown_album": "Neznámé album", "unknown_year": "Neznámý rok", "unlimited": "Neomezeně", + "unlink_motion_video": "Odpojit pohyblivé video", "unlink_oauth": "Zrušit OAuth propojení", "unlinked_oauth_account": "OAuth účet odpojen", "unnamed_album": "Nepojmenované album", diff --git a/web/src/lib/i18n/da.json b/web/src/lib/i18n/da.json index ab1d57d48e347..1e2c9a2b4ab54 100644 --- a/web/src/lib/i18n/da.json +++ b/web/src/lib/i18n/da.json @@ -140,6 +140,10 @@ "map_style_description": "URL til en style.json for et korttema", "metadata_extraction_job": "Udtræk metadata", "metadata_extraction_job_description": "Udtræk metadataoplysninger fra hvert Billede/Video, såsom GPS og opløsning", + "metadata_faces_import_setting": "Aktivér for at importere ansigter", + "metadata_faces_import_setting_description": "Importerer ansigter fra billed EXIF-data og forbandt filer", + "metadata_settings": "Metadatainstillinger", + "metadata_settings_description": "Håndtér metadataindstillinger", "migration_job": "Migrering", "migration_job_description": "Migrér miniaturebilleder for aktiver og ansigter til den seneste mappestruktur", "no_paths_added": "Ingen stier tilføjet", @@ -347,15 +351,25 @@ "album_options": "Albumindstillinger", "album_remove_user": "Fjern bruger?", "album_remove_user_confirmation": "Er du sikker på at du vil fjerne {user}?", + "album_share_no_users": "Det ser ud til at du har delt denne album med alle brugere, eller du har ikke nogen brugere til at dele med.", "album_updated": "Album opdateret", "album_updated_setting_description": "Modtag en emailnotifikation når et delt album får nye mediefiler", + "album_user_left": "Forlod {album}", + "album_user_removed": "Fjernede {user}", "albums": "Albummer", "albums_count": "{count, plural, one {{count, number} Album} other {{count, number} Albummer}}", "all": "Alt", + "all_albums": "Alle albummer", "all_people": "Alle personer", + "all_videos": "Alle videoer", "allow_dark_mode": "Tillad mørk tilstand", "allow_edits": "Tillad redigeringer", + "allow_public_user_to_download": "Tillad offentlige brugere til at hente", + "allow_public_user_to_upload": "Tillad offentlige brugere til at uploade", + "anti_clockwise": "Mod uret", "api_key": "API-nøgle", + "api_key_description": "Denne værdi vises kun én gang. Venligst kopiér den før du lukker vinduet.", + "api_key_empty": "Din API-nøgle-navn burde ikke være tom", "api_keys": "API-nøgler", "app_settings": "Appindstillinger", "appears_in": "Optræder i", diff --git a/web/src/lib/i18n/de.json b/web/src/lib/i18n/de.json index d519352862c2b..352006ef6eb88 100644 --- a/web/src/lib/i18n/de.json +++ b/web/src/lib/i18n/de.json @@ -1171,7 +1171,7 @@ "server_stats": "Server-Statistiken", "server_version": "Server-Version", "set": "Speichern", - "set_as_album_cover": "Als Albumcover gesetzt", + "set_as_album_cover": "Als Albumcover festlegen", "set_as_profile_picture": "Als Profilbild festlegen", "set_date_of_birth": "Geburtsdatum festlegen", "set_profile_picture": "Profilbild einstellen", diff --git a/web/src/lib/i18n/es.json b/web/src/lib/i18n/es.json index 31c613dcbdb6e..013631919286a 100644 --- a/web/src/lib/i18n/es.json +++ b/web/src/lib/i18n/es.json @@ -312,7 +312,7 @@ "trash_settings_description": "Administrar la configuración de la papelera", "untracked_files": "Archivos sin seguimiento", "untracked_files_description": "La aplicación no rastrea estos archivos. Puede ser el resultado de movimientos fallidos, cargas interrumpidas o sin procesar debido a un error", - "user_delete_delay": "La cuenta {user} y los archivos se programarán para su eliminación permanente en {delay, plural, one {# day} other {# days}}.", + "user_delete_delay": "La cuenta {user} y los archivos se programarán para su eliminación permanente en {delay, plural, one {# día} other {# días}}.", "user_delete_delay_settings": "Eliminar retardo", "user_delete_delay_settings_description": "Número de días después de la eliminación para eliminar permanentemente la cuenta y los activos de un usuario. El trabajo de eliminación de usuarios se ejecuta a medianoche para comprobar si hay usuarios que estén listos para su eliminación. Los cambios a esta configuración se evaluarán en la próxima ejecución.", "user_delete_immediately": "La cuenta {user} y los archivos se pondrán en cola para su eliminación permanente inmediatamente.", @@ -336,8 +336,8 @@ "admin_password": "Contraseña del Administrador", "administration": "Administración", "advanced": "Avanzada", - "age_months": "Tiempo {months, plural, one {# month} other {# months}}", - "age_year_months": "1 año, {months, plural, one {# month} other {# months}}", + "age_months": "Tiempo {months, plural, one {# mes} other {# meses}}", + "age_year_months": "1 año, {months, plural, one {# mes} other {# meses}}", "age_years": "Edad {years, plural, one {# año} other {# años}}", "album_added": "Álbum añadido", "album_added_notification_setting_description": "Reciba una notificación por correo electrónico cuando lo agreguen a un álbum compartido", @@ -400,12 +400,12 @@ "assets_added_to_name_count": "Añadido {count, plural, one {# asset} other {# assets}} a {hasName, select, true {{name}} other {new album}}", "assets_count": "{count, plural, one {# activo} other {# activos}}", "assets_moved_to_trash": "Se movió {count, plural, one {# activo} other {# activos}} a la papelera", - "assets_moved_to_trash_count": "Movido {count, plural, one {# asset} other {# assets}} a la papelera", - "assets_permanently_deleted_count": "Eliminado permanentemente {count, plural, one {# asset} other {# assets}}", - "assets_removed_count": "Eliminado {count, plural, one {# asset} other {# assets}}", + "assets_moved_to_trash_count": "Movido {count, plural, one {# elemento} other {# elementos}} a la papelera", + "assets_permanently_deleted_count": "Eliminado permanentemente {count, plural, one {# elemento} other {# elementos}}", + "assets_removed_count": "Eliminado {count, plural, one {# elemento} other {# elementos}}", "assets_restore_confirmation": "¿Está seguro de que desea restaurar todos sus archivos eliminados? ¡No puedes deshacer esta acción!", - "assets_restored_count": "Restaurado {count, plural, one {# asset} other {# assets}}", - "assets_trashed_count": "Borrado {count, plural, one {# asset} other {# assets}}", + "assets_restored_count": "Restaurado {count, plural, one {# elemento} other {# elementos}}", + "assets_trashed_count": "Borrado {count, plural, one {# elemento} other {# elementos}}", "assets_were_part_of_album_count": "{count, plural, one {Asset was} other {Assets were}} ya forma parte del álbum", "authorized_devices": "Dispositivos Autorizados", "back": "Atrás", @@ -416,7 +416,7 @@ "blurred_background": "Fondo borroso", "build": "Compilación", "build_image": "Construir Imagen", - "bulk_delete_duplicates_confirmation": "¿Estás seguro de que deseas eliminar de forma masiva {count, plural, one {# duplicate asset} other {# duplicate assets}}? Esto mantendrá el activo más grande de cada grupo y eliminará permanentemente todos los demás duplicados. ¡Esta acción no se puede deshacer!", + "bulk_delete_duplicates_confirmation": "¿Estás seguro de que deseas eliminar de forma masiva {count, plural, one {# elemento duplicado} other {# elementos duplicados}}? Esto mantendrá el activo más grande de cada grupo y eliminará permanentemente todos los demás duplicados. ¡Esta acción no se puede deshacer!", "bulk_keep_duplicates_confirmation": "¿Estas seguro de que desea mantener {count, plural, one {# duplicate asset} other {# duplicate assets}} archivos duplicados? Esto resolverá todos los grupos duplicados sin borrar nada.", "bulk_trash_duplicates_confirmation": "¿Estas seguro de que desea eliminar masivamente {count, plural, one {# duplicate asset} other {# duplicate assets}} archivos duplicados? Esto mantendrá el archivo más grande de cada grupo y eliminará todos los demás duplicados.", "buy": "Comprar Immich", @@ -589,7 +589,7 @@ "cant_apply_changes": "No se pueden aplicar los cambios", "cant_change_activity": "No se puede realizar la actividad {enabled, select, true {disable} other {enable}}", "cant_change_asset_favorite": "No se puede cambiar favorito para este archivo", - "cant_change_metadata_assets_count": "No se pueden cambiar los metadatos de {count, plural, one {# asset} other {# assets}}", + "cant_change_metadata_assets_count": "No se pueden cambiar los metadatos de {count, plural, one {# elemento} other {# elementos}}", "cant_get_faces": "No se encuentran caras", "cant_get_number_of_comments": "No se puede obtener la cantidad de comentarios", "cant_search_people": "No se puede buscar a personas", @@ -616,7 +616,7 @@ "failed_to_unstack_assets": "Error al desagrupar los archivos", "import_path_already_exists": "Esta ruta de importación ya existe.", "incorrect_email_or_password": "Contraseña o email incorrecto", - "paths_validation_failed": "Falló la validación en {paths, plural, one {# carpetas} other {# carpetas}}", + "paths_validation_failed": "Falló la validación en {paths, plural, one {# carpeta} other {# carpetas}}", "profile_picture_transparent_pixels": "Las imágenes de perfil no pueden tener píxeles transparentes. Por favor amplíe y/o mueva la imagen.", "quota_higher_than_disk_size": "Se ha establecido una cuota superior al tamaño del disco", "repair_unable_to_check_items": "No se puede verificar {count, select, one {elemento} other {elementos}}", @@ -634,7 +634,7 @@ "unable_to_change_favorite": "Imposible cambiar el archivo favorito", "unable_to_change_location": "No se puede cambiar de ubicación", "unable_to_change_password": "No se puede cambiar la contraseña", - "unable_to_change_visibility": "No se puede cambiar la visibilidad de {count, plural, one {# person} other {# people}}", + "unable_to_change_visibility": "No se puede cambiar la visibilidad de {count, plural, one {# persona} other {# personas}}", "unable_to_check_item": "", "unable_to_check_items": "", "unable_to_complete_oauth_login": "No se puede completar el inicio de sesión de OAuth", @@ -661,6 +661,7 @@ "unable_to_get_comments_number": "No se puede obtener el número de comentarios", "unable_to_get_shared_link": "Error al obtener el enlace compartido", "unable_to_hide_person": "No se puede ocultar a la persona", + "unable_to_link_motion_video": "No se puede enlazar el vídeo en movimiento", "unable_to_link_oauth_account": "No se puede vincular la cuenta OAuth", "unable_to_load_album": "No se puede cargar el álbum", "unable_to_load_asset_activity": "No se puede cargar la actividad de los archivos", @@ -701,6 +702,7 @@ "unable_to_submit_job": "No se puede enviar el trabajo", "unable_to_trash_asset": "No se puede eliminar el archivo", "unable_to_unlink_account": "No se puede desvincular la cuenta", + "unable_to_unlink_motion_video": "No se puede desvincular el vídeo en movimiento", "unable_to_update_album_cover": "No se puede actualizar la portada del álbum", "unable_to_update_album_info": "No se puede actualizar la información del álbum", "unable_to_update_library": "No se puede actualizar la biblioteca", @@ -846,6 +848,7 @@ "license_trial_info_4": "Por favor, considera la compra de una licencia para apoyar el desarrollo continuo del servicio", "light": "Claro", "like_deleted": "Me gusta eliminado", + "link_motion_video": "Enlazar vídeo en movimiento", "link_options": "Opciones de enlace", "link_to_oauth": "Enlace a OAuth", "linked_oauth_account": "Cuenta OAuth vinculada", @@ -888,7 +891,7 @@ "merge_people_limit": "Solo puedes fusionar hasta 5 caras a la vez", "merge_people_prompt": "¿Quieres fusionar a estas personas? Esta acción es irreversible.", "merge_people_successfully": "Personas fusionadas correctamente", - "merged_people_count": "Fusionar {count, plural, one {# person} other {# people}}", + "merged_people_count": "Fusionada {count, plural, one {# persona} other {# personas}}", "minimize": "Minimizar", "minute": "Minuto", "missing": "Perdido", @@ -969,9 +972,9 @@ "password_required": "Contraseña requerida", "password_reset_success": "Restablecimiento de contraseña exitoso", "past_durations": { - "days": "Pasados {days, plural, one {day} other {# days}}", - "hours": "Pasadas {hours, plural, one {hour} other {# hours}}", - "years": "Pasado(s) {years, plural, one {year} other {# years}}" + "days": "Pasados {days, plural, one {día} other {# días}}", + "hours": "Pasadas {hours, plural, one {hora} other {# horas}}", + "years": "Pasado(s) {years, plural, one {año} other {# años}}" }, "path": "Ruta", "pattern": "Patrón", @@ -980,18 +983,18 @@ "paused": "Detenido", "pending": "Pendiente", "people": "Personas", - "people_edits_count": "Editado {count, plural, one {# person} other {# people}}", + "people_edits_count": "Editada {count, plural, one {# persona} other {# personas}}", "people_feature_description": "Explorar fotos y vídeos agrupados por personas", "people_sidebar_description": "Mostrar un enlace a Personas en la barra lateral", "perform_library_tasks": "", "permanent_deletion_warning": "Advertencia de eliminación permanente", "permanent_deletion_warning_setting_description": "Mostrar una advertencia al eliminar archivos permanentemente", "permanently_delete": "Borrar permanentemente", - "permanently_delete_assets_count": "Eliminar permanentemente {count, plural, one {asset} other {assets}}", - "permanently_delete_assets_prompt": "¿Está seguro de que desea eliminar permanentemente {count, plural, one {¿este activo?} other {¿estos # activos?}} Esto también eliminará {count, plural, one {de tu} other {de tus}} álbum(es).", + "permanently_delete_assets_count": "Eliminar permanentemente {count, plural, one {elemento} other {elementos}}", + "permanently_delete_assets_prompt": "¿Está seguro de que desea eliminar permanentemente {count, plural, one {este activo?} other {estos # activos?}} Esto también eliminará {count, plural, one {de tu} other {de tus}} álbum(es).", "permanently_deleted_asset": "Archivo eliminado permanentemente", "permanently_deleted_assets": "Eliminado permanentemente {count, plural, one {# activo} other {# activos}}", - "permanently_deleted_assets_count": "Eliminado permanentemente {count, plural, one {# asset} other {# assets}}", + "permanently_deleted_assets_count": "Eliminado permanentemente {count, plural, one {# elemento} other {# elementos}}", "person": "Persona", "person_hidden": "{name}{hidden, select, true { (oculto)} other {}}", "photo_shared_all_users": "Parece que compartiste tus fotos con todos los usuarios o no tienes ningún usuario con quien compartirlas.", @@ -1060,8 +1063,8 @@ "reaction_options": "Opciones de reacción", "read_changelog": "Leer registro de cambios", "reassign": "Reasignar", - "reassigned_assets_to_existing_person": "Reasignado {count, plural, one {# asset} other {# assets}} to {name, select, null {an existing person} other {{name}}}", - "reassigned_assets_to_new_person": "Reasignado {count, plural, one {# asset} other {# assets}} a un nuevo usuario", + "reassigned_assets_to_existing_person": "Reasignado {count, plural, one {# elemento} other {# elementos}} a {name, select, null {una persona existente} other {{name}}}", + "reassigned_assets_to_new_person": "Reasignado {count, plural, one {# elemento} other {# elementos}} a un nuevo usuario", "reassing_hint": "Asignar archivos seleccionados a una persona existente", "recent": "Reciente", "recent_searches": "Búsquedas recientes", @@ -1075,8 +1078,8 @@ "refreshing_metadata": "Recargando metadatos", "regenerating_thumbnails": "Recargando miniaturas", "remove": "Eliminar", - "remove_assets_album_confirmation": "¿Estás seguro que quieres eliminar {count, plural, one {# asset} other {# assets}} del álbum?", - "remove_assets_shared_link_confirmation": "¿Estás seguro que quieres eliminar {count, plural, one {# asset} other {# assets}} del enlace compartido?", + "remove_assets_album_confirmation": "¿Estás seguro que quieres eliminar {count, plural, one {# elemento} other {# elementos}} del álbum?", + "remove_assets_shared_link_confirmation": "¿Estás seguro que quieres eliminar {count, plural, one {# elemento} other {# elementos}} del enlace compartido?", "remove_assets_title": "¿Eliminar activos?", "remove_custom_date_range": "Eliminar intervalo de fechas personalizado", "remove_from_album": "Eliminar del álbum", @@ -1087,7 +1090,7 @@ "removed_api_key": "Clave API eliminada: {name}", "removed_from_archive": "Eliminado del archivo", "removed_from_favorites": "Eliminado de favoritos", - "removed_from_favorites_count": "{count, plural, other {Removed #}} de favoritos", + "removed_from_favorites_count": "{count, plural, other {Eliminados #}} de favoritos", "removed_tagged_assets": "Etiqueta eliminada de {count, plural, one {# activo} other {# activos}}", "rename": "Renombrar", "repair": "Reparar", @@ -1135,6 +1138,7 @@ "search_for_existing_person": "Buscar persona existente", "search_no_people": "Ninguna persona", "search_no_people_named": "Ninguna persona llamada \"{name}\"", + "search_options": "Opciones de búsqueda", "search_people": "Buscar personas", "search_places": "Buscar lugar", "search_state": "Buscar región/estado...", @@ -1229,7 +1233,7 @@ "stack_duplicates": "Apilar duplicados", "stack_select_one_photo": "Selecciona una imagen principal para la pila", "stack_selected_photos": "Apilar fotos seleccionadas", - "stacked_assets_count": "Apilados {count, plural, one {# asset} other {# assets}}", + "stacked_assets_count": "Apilado(s) {count, plural, one {# activo} other {# activos}}", "stacktrace": "Stacktrace", "start": "Inicio", "start_date": "Fecha de inicio", @@ -1289,6 +1293,7 @@ "unknown_album": "Álbum desconocido", "unknown_year": "Año desconocido", "unlimited": "Ilimitado", + "unlink_motion_video": "Desvincular vídeo en movimiento", "unlink_oauth": "Desvincular OAuth", "unlinked_oauth_account": "Cuenta OAuth desconectada", "unnamed_album": "Album sin nombre", @@ -1298,14 +1303,14 @@ "unselect_all": "Limpiar selección", "unselect_all_duplicates": "Deseleccionar todos los duplicados", "unstack": "Desapilar", - "unstacked_assets_count": "Sin apilar {count, plural, one {# asset} other {# assets}}", + "unstacked_assets_count": "Desapilado(s) {count, plural, one {# elemento} other {# elementos}}", "untracked_files": "Archivos no monitorizados", "untracked_files_decription": "Estos archivos no están siendo monitorizados por la aplicación. Es posible que sean resultado de errores al moverlos, cargas interrumpidas o por un fallo de la aplicación", "up_next": "A continuación", "updated_password": "Contraseña actualizada", "upload": "Subir", "upload_concurrency": "Cargas simultáneas", - "upload_errors": "Carga completada con {count, plural, one {# error} other {# errors}}, actualice la página para ver los nuevos recursos de carga.", + "upload_errors": "Carga completada con {count, plural, one {# error} other {# errores}}, actualice la página para ver los nuevos recursos de carga.", "upload_progress": "Restante {remaining, number} - Procesado {processed, number}/{total, number}", "upload_skipped_duplicates": "Saltado {count, plural, one {# duplicate asset} other {# duplicate assets}}", "upload_status_duplicates": "Duplicados", @@ -1347,14 +1352,14 @@ "view_previous_asset": "Mostrar elemento anterior", "view_stack": "Ver Pila", "viewer": "Visualizador", - "visibility_changed": "Visibilidad cambiada para {count, plural, one {# person} other {# people}}", + "visibility_changed": "Visibilidad cambiada para {count, plural, one {# persona} other {# personas}}", "waiting": "Esperando", "warning": "Advertencia", "week": "Semana", "welcome": "Bienvenido", "welcome_to_immich": "Bienvenido a immich", "year": "Año", - "years_ago": "Hace {years, plural, one {# year} other {# years}}", + "years_ago": "Hace {years, plural, one {# año} other {# años}}", "yes": "Sí", "you_dont_have_any_shared_links": "No tienes ningún enlace compartido", "zoom_image": "Acercar Imagen" diff --git a/web/src/lib/i18n/fi.json b/web/src/lib/i18n/fi.json index 6d951b93f9e89..15a3dc0a265cb 100644 --- a/web/src/lib/i18n/fi.json +++ b/web/src/lib/i18n/fi.json @@ -140,6 +140,10 @@ "map_style_description": "style.json -karttateeman URL", "metadata_extraction_job": "Kerää metadata", "metadata_extraction_job_description": "Poimi metatiedot aineistoista, kuten GPS ja resoluutio", + "metadata_faces_import_setting": "Ota käyttöön kasvojen tuonti", + "metadata_faces_import_setting_description": "Tuo kasvot kuvan EXIF -tiedoista ja kylkiäistiedostoista", + "metadata_settings": "Metatietoasetukset", + "metadata_settings_description": "Hallitse metatietoja", "migration_job": "Migrointi", "migration_job_description": "Migroi aineiston pikkukuvat ja kasvot uusimpaan kansiorakenteeseen", "no_paths_added": "Polkuja ei asetettu", @@ -963,6 +967,7 @@ "send_message": "Lähetä viesti", "send_welcome_email": "Lähetä tervetuloviesti", "server": "Palvelin", + "server_online": "Palvelin on linjalla", "server_stats": "Palvelimen tilastot", "server_version": "Palvelimen versio", "set": "Aseta", @@ -1113,6 +1118,7 @@ "view_album": "Näytä albumi", "view_all": "Näytä kaikki", "view_all_users": "Näytä kaikki käyttäjät", + "view_in_timeline": "Näytä aikajanalla", "view_links": "Näytä linkit", "view_next_asset": "Näytä seuraava", "view_previous_asset": "Näytä edellinen", diff --git a/web/src/lib/i18n/fr.json b/web/src/lib/i18n/fr.json index 9edcb1fdd2807..9628573b0d30b 100644 --- a/web/src/lib/i18n/fr.json +++ b/web/src/lib/i18n/fr.json @@ -148,7 +148,7 @@ "migration_job_description": "Migration des miniatures pour les médias et les visages vers la dernière structure de dossiers", "no_paths_added": "Aucun chemin n'a été ajouté", "no_pattern_added": "Aucun schéma d'exclusion n'a été ajouté", - "note_apply_storage_label_previous_assets": "Remarque : pour appliquer l'étiquette de stockage à des médias précédemment téléversés, exécutez la commande", + "note_apply_storage_label_previous_assets": "Remarque : pour appliquer l'étiquette de stockage à des médias précédemment envoyés, exécutez la commande", "note_cannot_be_changed_later": "REMARQUE : Il n'est pas possible de modifier ce paramètre ultérieurement !", "note_unlimited_quota": "Note : saisir 0 pour un quota illimité", "notification_email_from_address": "Depuis l'adresse", @@ -228,14 +228,14 @@ "storage_template_hash_verification_enabled": "Vérification du hachage activée", "storage_template_hash_verification_enabled_description": "Active la vérification du hachage, ne désactivez pas cette option à moins d'être sûr de ce que vous faites", "storage_template_migration": "Migration du modèle de stockage", - "storage_template_migration_description": "Appliquer le modèle courant {template} aux médias précédemment téléchargés", - "storage_template_migration_info": "Les changements de modèle ne s'appliqueront qu'aux nouveaux médias. Pour appliquer rétroactivement le modèle aux médias précédemment téléchargés, exécutez la tâche {job}.", + "storage_template_migration_description": "Appliquer le modèle courant {template} aux médias précédemment envoyés", + "storage_template_migration_info": "Les changements de modèle ne s'appliqueront qu'aux nouveaux médias. Pour appliquer rétroactivement le modèle aux médias précédemment envoyés, exécutez la tâche {job}.", "storage_template_migration_job": "Tâche de migration du modèle de stockage", "storage_template_more_details": "Pour plus de détails sur cette fonctionnalité, reportez-vous au Modèle de stockage et à ses implications", "storage_template_onboarding_description": "Lorsqu'elle est activée, cette fonctionnalité réorganise les fichiers basés sur un modèle défini par l'utilisateur. En raison de problèmes de stabilité, la fonction a été désactivée par défaut. Pour plus d'informations, veuillez consulter la documentation.", "storage_template_path_length": "Limite approximative de la longueur du chemin : {length, number}/{limit, number}", "storage_template_settings": "Modèle de stockage", - "storage_template_settings_description": "Gérer la structure des dossiers et le nom des fichiers du média téléversé", + "storage_template_settings_description": "Gérer la structure des dossiers et le nom des fichiers du média envoyé", "storage_template_user_label": "{label} est l'étiquette de stockage de l'utilisateur", "system_settings": "Paramètres du système", "theme_custom_css_settings": "CSS personnalisé", @@ -311,7 +311,7 @@ "trash_settings": "Corbeille", "trash_settings_description": "Gérer les paramètres de la corbeille", "untracked_files": "Fichiers non suivis", - "untracked_files_description": "Ces fichiers ne sont pas suivis par l'application. Ils peuvent être le résultat d'erreurs de déplacement, téléchargements interrompus, ou abandons en raison d'un bug", + "untracked_files_description": "Ces fichiers ne sont pas suivis par l'application. Ils peuvent être le résultat d'erreurs de déplacement, d'envois interrompus, ou d'abandons en raison d'un bug", "user_delete_delay": "La suppression définitive du compte et des médias de {user} sera programmée dans {delay, plural, one {# jour} other {# jours}}.", "user_delete_delay_settings": "Délai de suppression", "user_delete_delay_settings_description": "Nombre de jours après la validation pour supprimer définitivement le compte et les médias d'un utilisateur. La suppression des utilisateurs se lance à minuit. Les modifications apportées à ce paramètre seront pris en compte lors de la prochaine exécution.", @@ -366,7 +366,7 @@ "allow_dark_mode": "Autoriser le mode sombre", "allow_edits": "Autoriser les modifications", "allow_public_user_to_download": "Permettre aux utilisateurs non connectés de télécharger", - "allow_public_user_to_upload": "Permettre aux utilisateurs non connectés de téléverser", + "allow_public_user_to_upload": "Permettre l'envoi aux utilisateurs non connectés", "anti_clockwise": "Sens anti-horaire", "api_key": "Clé API", "api_key_description": "Cette valeur ne sera affichée qu'une seule fois. Assurez-vous de la copier avant de fermer la fenêtre.", @@ -391,8 +391,9 @@ "asset_offline": "Média hors ligne", "asset_offline_description": "Ce média est hors ligne. Immich ne peut pas accéder à son emplacement physique. Veuillez vous assurez que le média est disponible, puis relancez l'analyse de la bibliothèque.", "asset_skipped": "Sauté", - "asset_uploaded": "Téléversé", - "asset_uploading": "Chargement...", + "asset_skipped_in_trash": "À la corbeille", + "asset_uploaded": "Envoyé", + "asset_uploading": "Envoi...", "assets": "Médias", "assets_added_count": "{count, plural, one {# média ajouté} other {# médias ajoutés}}", "assets_added_to_album_count": "{count, plural, one {# média ajouté} other {# médias ajoutés}} à l'album", @@ -537,7 +538,7 @@ "download_settings_description": "Gérer les paramètres de téléchargement des médias", "downloading": "Téléchargement", "downloading_asset_filename": "Téléchargement du média {filename}", - "drop_files_to_upload": "Déposer des fichiers n'importe où pour téléverser", + "drop_files_to_upload": "Déposez les fichiers n'importe où pour envoyer", "duplicates": "Doublons", "duplicates_description": "Examiner chaque groupe et indiquer s'il y a des doublons", "duration": "Durée", @@ -613,7 +614,7 @@ "failed_to_remove_product_key": "Échec de suppression de la clé du produit", "failed_to_stack_assets": "Impossible d'empiler les médias", "failed_to_unstack_assets": "Impossible de dépiler les médias", - "import_path_already_exists": "Ce chemin d'import existe déjà.", + "import_path_already_exists": "Ce chemin d'importation existe déjà.", "incorrect_email_or_password": "Courriel ou mot de passe incorrect", "paths_validation_failed": "Validation échouée pour {paths, plural, one {# un chemin} other {# plusieurs chemins}}", "profile_picture_transparent_pixels": "Les images de profil ne peuvent pas avoir de pixels transparents. Veuillez agrandir et/ou déplacer l'image.", @@ -623,7 +624,7 @@ "unable_to_add_assets_to_shared_link": "Impossible d'ajouter des médias au lien partagé", "unable_to_add_comment": "Impossible d'ajouter un commentaire", "unable_to_add_exclusion_pattern": "Impossible d'ajouter un schéma d'exclusion", - "unable_to_add_import_path": "Impossible d'ajouter un chemin d'import", + "unable_to_add_import_path": "Impossible d'ajouter le chemin d'importation", "unable_to_add_partners": "Impossible d'ajouter des partenaires", "unable_to_add_remove_archive": "Impossible {archived, select, true {de supprimer des médias de} other {d'ajouter des médias à}} l'archive", "unable_to_add_remove_favorites": "Impossible {favorite, select, true {d'ajouter des médias aux} other {de supprimer des médias des}} favoris", @@ -648,18 +649,19 @@ "unable_to_delete_asset": "Suppression du média impossible", "unable_to_delete_assets": "Erreur lors de la suppression des médias", "unable_to_delete_exclusion_pattern": "Suppression du modèle d'exclusion impossible", - "unable_to_delete_import_path": "Suppression du chemin d'import impossible", + "unable_to_delete_import_path": "Suppression du chemin d'importation impossible", "unable_to_delete_shared_link": "Suppression du lien de partage impossible", "unable_to_delete_user": "Suppression de l'utilisateur impossible", "unable_to_download_files": "Impossible de télécharger les fichiers", "unable_to_edit_exclusion_pattern": "Modification du modèle d'exclusion impossible", - "unable_to_edit_import_path": "Modification du chemin d'import impossible", + "unable_to_edit_import_path": "Modification du chemin d'importation impossible", "unable_to_empty_trash": "Impossible de vider la corbeille", "unable_to_enter_fullscreen": "Mode plein écran indisponible", "unable_to_exit_fullscreen": "Sortie du mode plein écran impossible", "unable_to_get_comments_number": "Impossible d'obtenir le nombre de commentaires", "unable_to_get_shared_link": "Échec de la récupération du lien partagé", "unable_to_hide_person": "Impossible de cacher la personne", + "unable_to_link_motion_video": "Impossible de lier la photo animée", "unable_to_link_oauth_account": "Impossible de lier le compte OAuth", "unable_to_load_album": "Impossible de charger l'album", "unable_to_load_asset_activity": "Impossible de charger l'activité du média", @@ -700,6 +702,7 @@ "unable_to_submit_job": "Impossible d'exécuter la tâche", "unable_to_trash_asset": "Impossible de mettre le média à la corbeille", "unable_to_unlink_account": "Impossible de détacher le compte", + "unable_to_unlink_motion_video": "Impossible de détacher la photo animée", "unable_to_update_album_cover": "Impossible de mettre à jour la couverture de l'album", "unable_to_update_album_info": "Impossible de mettre à jour les informations de l'album", "unable_to_update_library": "Impossible de mettre à jour la bibliothèque", @@ -707,7 +710,7 @@ "unable_to_update_settings": "Impossible de mettre à jour les paramètres", "unable_to_update_timeline_display_status": "Impossible de mettre à jour le statut d'affichage de la timeline", "unable_to_update_user": "Impossible de mettre à jour l'utilisateur", - "unable_to_upload_file": "Impossible de téléverser le fichier" + "unable_to_upload_file": "Impossible d'envoyer le fichier" }, "every_day_at_onepm": "", "every_night_at_midnight": "", @@ -845,6 +848,7 @@ "license_trial_info_4": "Pensez à acheter une licence pour soutenir le développement du service", "light": "Clair", "like_deleted": "Réaction « j'aime » supprimée", + "link_motion_video": "Lier la photo animée", "link_options": "Options de lien", "link_to_oauth": "Lien au service OAuth", "linked_oauth_account": "Compte OAuth rattaché", @@ -913,10 +917,10 @@ "no_albums_with_name_yet": "Il semble que vous n'ayez pas encore d'albums avec ce nom.", "no_albums_yet": "Il semble que vous n'ayez pas encore d'album.", "no_archived_assets_message": "Archiver des photos et vidéos pour les masquer dans votre bibliothèque", - "no_assets_message": "CLIQUER ICI POUR IMPORTER VOTRE PREMIÈRE PHOTO", + "no_assets_message": "CLIQUER ICI POUR ENVOYER VOTRE PREMIÈRE PHOTO", "no_duplicates_found": "Aucun doublon n'a été trouvé.", "no_exif_info_available": "Aucune information exif disponible", - "no_explore_results_message": "Importer plus de photos pour explorer votre collection.", + "no_explore_results_message": "Envoyez plus de photos pour explorer votre collection.", "no_favorites_message": "Ajouter des photos et vidéos à vos favoris pour les retrouver plus rapidement", "no_libraries_message": "Créer une bibliothèque externe pour voir vos photos et vidéos dans un autre espace de stockage", "no_name": "Pas de nom", @@ -925,7 +929,7 @@ "no_results_description": "Essayez un synonyme ou un mot-clé plus général", "no_shared_albums_message": "Créer un album pour partager vos photos et vidéos avec les personnes de votre réseau", "not_in_any_album": "Dans aucun album", - "note_apply_storage_label_to_previously_uploaded assets": "Note : Pour appliquer l'étiquette de stockage aux médias déjà importés, lancer la", + "note_apply_storage_label_to_previously_uploaded assets": "Note : Pour appliquer l'étiquette de stockage aux médias déjà envoyés, lancer la", "note_unlimited_quota": "Note : Saisir 0 pour définir un quota illimité", "notes": "Notes", "notification_toggle_setting_description": "Activer les notifications par courriel", @@ -1134,6 +1138,7 @@ "search_for_existing_person": "Rechercher une personne existante", "search_no_people": "Aucune personne", "search_no_people_named": "Aucune personne nommée « {name} »", + "search_options": "Rechercher une option", "search_people": "Rechercher une personne", "search_places": "Rechercher un lieu", "search_state": "Rechercher par état/région...", @@ -1288,6 +1293,7 @@ "unknown_album": "", "unknown_year": "Année inconnue", "unlimited": "Illimité", + "unlink_motion_video": "Détacher la photo animée", "unlink_oauth": "Déconnecter OAuth", "unlinked_oauth_account": "Compte OAuth non connecté", "unnamed_album": "Album sans nom", @@ -1299,18 +1305,18 @@ "unstack": "Désempiler", "unstacked_assets_count": "{count, plural, one {# média dépilé} other {# médias dépilés}}", "untracked_files": "Fichiers non suivis", - "untracked_files_decription": "Ces fichiers ne sont pas suivis par l'application. Ils peuvent être le résultat de déplacements échoués, de téléchargements interrompus ou laissés pour compte à cause d'un bug", + "untracked_files_decription": "Ces fichiers ne sont pas suivis par l'application. Ils peuvent être le résultat de déplacements échoués, d'envois interrompus ou laissés pour compte à cause d'un bug", "up_next": "Suite", "updated_password": "Mot de passe mis à jour", - "upload": "Téléverser", - "upload_concurrency": "Envoi simultané", - "upload_errors": "Le téléversement s'est achevé avec {count, plural, one {# erreur} other {# erreurs}}. Rafraîchir la page pour voir les nouveaux médias téléversés.", + "upload": "Envoyer", + "upload_concurrency": "Envois simultanés", + "upload_errors": "L'envoi s'est achevé avec {count, plural, one {# erreur} other {# erreurs}}. Rafraîchir la page pour voir les nouveaux médias envoyés.", "upload_progress": "{remaining, number} restant(s) - {processed, number} traité(s)/{total, number}", "upload_skipped_duplicates": "{count, plural, one {# doublon ignoré} other {# doublons ignorés}}", "upload_status_duplicates": "Doublons", "upload_status_errors": "Erreurs", - "upload_status_uploaded": "Téléversé", - "upload_success": "Téléversement réussi. Rafraîchir la page pour voir les nouveaux médias téléversés.", + "upload_status_uploaded": "Envoyé", + "upload_success": "Envoi réussi. Rafraîchir la page pour voir les nouveaux médias envoyés.", "url": "URL", "usage": "Utilisation", "use_custom_date_range": "Utilisez une plage de date personnalisée à la place", diff --git a/web/src/lib/i18n/he.json b/web/src/lib/i18n/he.json index e2b836256801d..05eab7a804519 100644 --- a/web/src/lib/i18n/he.json +++ b/web/src/lib/i18n/he.json @@ -139,7 +139,11 @@ "map_settings_description": "נהל הגדרות מפה", "map_style_description": "כתובת אתר לערכת נושא של מפה style.json", "metadata_extraction_job": "חלץ מטא-נתונים", - "metadata_extraction_job_description": "חלץ מידע מטא-נתונים מכל נכס, כגון GPS ורזולוציה", + "metadata_extraction_job_description": "חלץ מידע מטא-נתונים מכל נכס, כגון GPS, פנים ורזולוציה", + "metadata_faces_import_setting": "אפשר יבוא פנים", + "metadata_faces_import_setting_description": "יבא פנים מנתוני EXIF של תמונה ומקבצים נלווים", + "metadata_settings": "הגדרות מטא-נתונים", + "metadata_settings_description": "נהל הגדרות מטא-נתונים", "migration_job": "העברה", "migration_job_description": "העבר תמונות ממוזערות של נכסים ופנים למבנה התיקיות העדכני ביותר", "no_paths_added": "לא נוספו נתיבים", @@ -387,6 +391,7 @@ "asset_offline": "נכס לא מקוון", "asset_offline_description": "הנכס הזה אינו מקוון. Immich לא יכול לגשת למיקום הקובץ שלו. נא לוודא שהנכס זמין ואז סרוק מחדש את הספרייה.", "asset_skipped": "דילג", + "asset_skipped_in_trash": "באשפה", "asset_uploaded": "הועלה", "asset_uploading": "מעלה...", "assets": "נכסים", @@ -656,6 +661,7 @@ "unable_to_get_comments_number": "לא ניתן להשיג את מספר התגובות", "unable_to_get_shared_link": "קבלת קישור משותף נכשלה", "unable_to_hide_person": "לא ניתן להסתיר אדם", + "unable_to_link_motion_video": "לא ניתן לקשר סרטון תנועה", "unable_to_link_oauth_account": "לא ניתן לקשר חשבון OAuth", "unable_to_load_album": "לא ניתן לטעון אלבום", "unable_to_load_asset_activity": "לא ניתן לטעון את פעילות הנכס", @@ -696,6 +702,7 @@ "unable_to_submit_job": "לא ניתן לשלוח משימה", "unable_to_trash_asset": "לא ניתן להעביר נכס לאשפה", "unable_to_unlink_account": "לא ניתן לבטל קישור חשבון", + "unable_to_unlink_motion_video": "לא ניתן לבטל קישור סרטון תנועה", "unable_to_update_album_cover": "לא ניתן לעדכן עטיפת אלבום", "unable_to_update_album_info": "לא ניתן לעדכן פרטי אלבום", "unable_to_update_library": "לא ניתן לעדכן ספרייה", @@ -841,6 +848,7 @@ "license_trial_info_4": "אנא שקול לרכוש רישיון כדי לתמוך בפיתוח המתמשך של השירות", "light": "בהיר", "like_deleted": "לייק נמחק", + "link_motion_video": "קשר סרטון תנועה", "link_options": "אפשרויות קישור", "link_to_oauth": "קישור ל-OAuth", "linked_oauth_account": "חשבון OAuth מקושר", @@ -1130,6 +1138,7 @@ "search_for_existing_person": "חפש אדם קיים", "search_no_people": "אין אנשים", "search_no_people_named": "אין אנשים בשם \"{name}\"", + "search_options": "אפשרויות חיפוש", "search_people": "חפש אנשים", "search_places": "חפש מקומות", "search_state": "חפש מדינה...", @@ -1208,6 +1217,8 @@ "sign_up": "הרשמה", "size": "גודל", "skip_to_content": "דלג לתוכן", + "skip_to_folders": "דלג לתיקיות", + "skip_to_tags": "דלג לתגים", "slideshow": "מצגת שקופיות", "slideshow_settings": "הגדרות מצגת שקופיות", "sort_albums_by": "מיין אלבומים לפי...", @@ -1282,6 +1293,7 @@ "unknown_album": "אלבום לא ידוע", "unknown_year": "שנה לא ידועה", "unlimited": "בלתי מוגבל", + "unlink_motion_video": "בטל קישור סרטון תנועה", "unlink_oauth": "בטל קישור OAuth", "unlinked_oauth_account": "בוטל קישור חשבון OAuth", "unnamed_album": "אלבום ללא שם", diff --git a/web/src/lib/i18n/hr.json b/web/src/lib/i18n/hr.json index 16d08bbfca211..954eeff202d6a 100644 --- a/web/src/lib/i18n/hr.json +++ b/web/src/lib/i18n/hr.json @@ -27,10 +27,11 @@ "added_to_favorites": "Dodano u omiljeno", "added_to_favorites_count": "Dodano {count, number} u omiljeno", "admin": { - "add_exclusion_pattern_description": "", + "add_exclusion_pattern_description": "Dodajte uzorke izuzimanja. Globiranje pomoću *, ** i ? je podržano. Za ignoriranje svih datoteka u bilo kojem direktoriju pod nazivom \"Raw\", koristite \"**/Raw/**\". Da biste zanemarili sve datoteke koje završavaju na \".tif\", koristite \"**/*.tif\". Da biste zanemarili apsolutni put, koristite \"/path/to/ignore/**\".", "authentication_settings": "Postavke autentikacije", "authentication_settings_description": "Uredi lozinku, OAuth, i druge postavke autentikacije", "authentication_settings_disable_all": "Jeste li sigurni da želite onemogućenit sve načine prijave? Prijava će biti potpuno onemogućena.", + "authentication_settings_reenable": "Za ponovno uključivanje upotrijebite naredbu poslužitelja.", "background_task_job": "Pozadinski zadaci", "check_all": "Provjeri sve", "cleared_jobs": "Izbrisani poslovi za: {job}", @@ -72,8 +73,8 @@ "job_settings": "Postavke posla", "job_settings_description": "Upravljajte istovremenošću poslova", "job_status": "Status posla", - "jobs_delayed": "", - "jobs_failed": "", + "jobs_delayed": "{jobCount, plural, other {# delayed}}", + "jobs_failed": "{jobCount, plural, other {# failed}}", "library_created": "Stvorena biblioteka: {library}", "library_cron_expression": "Cron izraz", "library_cron_expression_description": "Postavite interval skeniranja koristeći cron format. Za više informacija pogledajte npr. Crontab Guru", @@ -96,8 +97,8 @@ "machine_learning_clip_model_description": "Naziv CLIP modela navedenog ovdje. Imajte na umu da morate ponovno pokrenuti posao 'Pametno Pretraživanje' za sve slike nakon promjene modela.", "machine_learning_duplicate_detection": "Detekcija Duplikata", "machine_learning_duplicate_detection_enabled": "Omogući detekciju duplikata", - "machine_learning_duplicate_detection_enabled_description": "", - "machine_learning_duplicate_detection_setting_description": "", + "machine_learning_duplicate_detection_enabled_description": "Ako je onemogućeno, potpuno identična sredstva i dalje će biti deduplicirana.", + "machine_learning_duplicate_detection_setting_description": "Upotrijebite CLIP ugradnje da biste pronašli vjerojatne duplikate", "machine_learning_enabled": "Uključi strojsko učenje", "machine_learning_enabled_description": "Ukoliko je ovo isključeno, sve funkcije strojnoga učenja biti će isključene bez obzira na postavke ispod.", "machine_learning_facial_recognition": "Detekcija lica", @@ -138,6 +139,10 @@ "map_style_description": "URL na style.json temu karte", "metadata_extraction_job": "Izdvoj metapodatke", "metadata_extraction_job_description": "Izdvojite podatke o metapodacima iz svakog sredstva, kao što su GPS i rezolucija", + "metadata_faces_import_setting": "Omogući uvoz lica", + "metadata_faces_import_setting_description": "Uvezite lica iz EXIF podataka slike i sidecar datoteka", + "metadata_settings": "Postavke Metapodataka", + "metadata_settings_description": "Upravljanje postavkama metapodataka", "migration_job": "Migracija", "migration_job_description": "Premjestite minijature za sredstva i lica u najnoviju strukturu mapa", "no_paths_added": "Nema dodanih putanja", @@ -171,18 +176,20 @@ "oauth_enable_description": "Prijavite se putem OAutha", "oauth_issuer_url": "URL Izdavatelja", "oauth_mobile_redirect_uri": "Mobilnog Preusmjeravanja URI", - "oauth_mobile_redirect_uri_override": "", - "oauth_mobile_redirect_uri_override_description": "", - "oauth_scope": "", + "oauth_mobile_redirect_uri_override": "Nadjačavanje URI-preusmjeravanja za mobilne uređaje", + "oauth_mobile_redirect_uri_override_description": "Omogući kada pružatelj OAuth ne dopušta mobilni URI, poput '{callback}'", + "oauth_profile_signing_algorithm": "Algoritam za potpisivanje profila", + "oauth_profile_signing_algorithm_description": "Algoritam koji se koristi za potpisivanje korisničkog profila.", + "oauth_scope": "Opseg", "oauth_settings": "OAuth", "oauth_settings_description": "Upravljanje postavkama za prijavu kroz OAuth", "oauth_settings_more_details": "Za više pojedinosti o ovoj značajci pogledajte uputstva.", - "oauth_signing_algorithm": "", - "oauth_storage_label_claim": "", - "oauth_storage_label_claim_description": "", - "oauth_storage_quota_claim": "", - "oauth_storage_quota_claim_description": "", - "oauth_storage_quota_default": "", + "oauth_signing_algorithm": "Algoritam potpisivanja", + "oauth_storage_label_claim": "Potraživanje oznake za pohranu", + "oauth_storage_label_claim_description": "Automatski postavite korisničku oznaku za pohranu na vrijednost ovog zahtjeva.", + "oauth_storage_quota_claim": "Zahtjev za kvotom pohrane", + "oauth_storage_quota_claim_description": "Automatski postavite korisničku kvotu pohrane na vrijednost ovog zahtjeva.", + "oauth_storage_quota_default": "Zadana kvota pohrane (GiB)", "oauth_storage_quota_default_description": "Kvota u GiB koja će se koristiti kada nema zahtjeva (unesite 0 za neograničenu kvotu).", "offline_paths": "Izvanmrežne putanje", "offline_paths_description": "Ovi rezultati mogu biti posljedica ručnog brisanja datoteka koje nisu dio vanjske biblioteke.", @@ -196,8 +203,8 @@ "registration_description": "Budući da ste prvi korisnik na sustavu, bit ćete dodijeljeni administratorsku ulogu i odgovorni ste za administrativne poslove, a dodatne korisnike kreirat ćete sami.", "removing_offline_files": "Uklanjanje izvanmrežnih datoteka", "repair_all": "Popravi sve", - "repair_matched_items": "", - "repaired_items": "", + "repair_matched_items": "Podudaranje {count, plural, one {# item} other {# items}}", + "repaired_items": "Popravljeno {count, plural, one {# item} other {# items}}", "require_password_change_on_login": "Zahtijevajte od korisnika promjenu lozinke pri prvoj prijavi", "reset_settings_to_default": "Vrati postavke na zadane", "reset_settings_to_recent_saved": "Resetirajte postavke na nedavno spremljene postavke", @@ -210,25 +217,33 @@ "server_settings_description": "Upravljanje postavkama servera", "server_welcome_message": "Poruka dobrodošlice", "server_welcome_message_description": "Poruka koja je prikazana na prijavi.", - "sidecar_job": "", - "sidecar_job_description": "", - "slideshow_duration_description": "", - "smart_search_job_description": "", - "storage_template_enable_description": "", - "storage_template_hash_verification_enabled": "", - "storage_template_hash_verification_enabled_description": "", - "storage_template_migration": "", - "storage_template_migration_job": "", - "storage_template_settings": "", - "storage_template_settings_description": "", - "system_settings": "", + "sidecar_job": "Sidecar metapodaci", + "sidecar_job_description": "Otkrijte ili sinkronizirajte sidecar metapodatke iz datotečnog sustava", + "slideshow_duration_description": "Broj sekundi za prikaz svake slike", + "smart_search_job_description": "Pokrenite strojno učenje na sredstvima za podršku pametnog pretraživanja", + "storage_template_date_time_description": "Vremenska oznaka stvaranja sredstva koristi se za informacije o datumu i vremenu", + "storage_template_date_time_sample": "Vrijeme uzorka {date}", + "storage_template_enable_description": "Omogući mehanizam predloška za pohranu", + "storage_template_hash_verification_enabled": "Omogućena hash provjera", + "storage_template_hash_verification_enabled_description": "Omogućuje hash provjeru, nemojte je onemogućiti osim ako niste sigurni u implikacije", + "storage_template_migration": "Migracija predloška za pohranu", + "storage_template_migration_description": "Primijenite trenutni {template} na prethodno prenesena sredstva", + "storage_template_migration_info": "Promjene predloška primjenjivat će se samo na nova sredstva. Za retroaktivnu primjenu predloška na prethodno prenesena sredstva, pokrenite {job}.", + "storage_template_migration_job": "Posao Migracije Predloška Pohrane", + "storage_template_more_details": "Za više pojedinosti o ovoj značajci pogledajte Predložak pohrane i njegove implikacije", + "storage_template_onboarding_description": "Kada je omogućena, ova će značajka automatski organizirati datoteke na temelju korisnički definiranog predloška. Zbog problema sa stabilnošću značajka je isključena prema zadanim postavkama. Za više informacija pogledajte dokumentaciju.", + "storage_template_path_length": "Približno ograničenje duljine putanje: {length, number}/{limit, number}", + "storage_template_settings": "Predložak pohrane", + "storage_template_settings_description": "Upravljajte strukturom mape i nazivom datoteke učitanog sredstva", + "storage_template_user_label": "{label} je korisnička oznaka za pohranu", + "system_settings": "Postavke Sustava", "theme_custom_css_settings": "Prilagođeni CSS", "theme_custom_css_settings_description": "Kaskadni listovi stilova (CSS) omogućuju prilagođavanje dizajna Immicha.", "theme_settings": "Postavke tema", "theme_settings_description": "Upravljajte prilagodbom Immich web sučelja", - "these_files_matched_by_checksum": "", + "these_files_matched_by_checksum": "Ove datoteke se podudaraju prema njihovim kontrolnim zbrojevima", "thumbnail_generation_job": "Generirajte sličice", - "thumbnail_generation_job_description": "", + "thumbnail_generation_job_description": "Generirajte velike, male i zamućene sličice za svaki materijal, kao i sličice za svaku osobu", "transcoding_acceleration_api": "API ubrzanja", "transcoding_acceleration_api_description": "API koji će komunicirati s vašim uređajem radi ubrzanja transkodiranja. Ova postavka je 'najveći trud': vratit će se na softversko transkodiranje u slučaju kvara. VP9 može ili ne mora raditi ovisno o vašem hardveru.", "transcoding_acceleration_nvenc": "NVENC (zahtjeva NVIDIA GPU)", @@ -240,201 +255,290 @@ "transcoding_accepted_containers": "Prihvaćeni kontenjeri", "transcoding_accepted_containers_description": "Odaberite koji formati spremnika ne moraju biti remulksirani u MP4. Koristi se samo za određena pravila transkodiranja.", "transcoding_accepted_video_codecs": "Prihvaćeni video kodeci", - "transcoding_accepted_video_codecs_description": "", + "transcoding_accepted_video_codecs_description": "Odaberite koje video kodeke nije potrebno transkodirati. Koristi se samo za određena pravila transkodiranja.", "transcoding_advanced_options_description": "Postavke većina korisnika ne treba mjenjati", "transcoding_audio_codec": "Audio kodek", "transcoding_audio_codec_description": "Opus je opcija s najvećom kvalitetom, no ima manju podršku s starim uređajima i softverima.", "transcoding_bitrate_description": "Videozapisi veći od maksimalne brzine prijenosa ili nisu u prihvatljivom formatu", - "transcoding_constant_quality_mode": "", - "transcoding_constant_quality_mode_description": "", - "transcoding_constant_rate_factor": "", - "transcoding_constant_rate_factor_description": "", - "transcoding_disabled_description": "", - "transcoding_hardware_acceleration": "", - "transcoding_hardware_acceleration_description": "", - "transcoding_hardware_decoding": "", - "transcoding_hardware_decoding_setting_description": "", + "transcoding_codecs_learn_more": "Da biste saznali više o terminologiji koja se ovdje koristi, pogledajte FFmpeg dokumentaciju za H.264 kodek, HEVC kodek i VP9 kodek.", + "transcoding_constant_quality_mode": "Način stalne kvalitete", + "transcoding_constant_quality_mode_description": "ICQ je bolji od CQP-a, ali neki uređaji za hardversko ubrzanje ne podržavaju ovaj način rada. Postavljanje ove opcije daje prednost navedenom načinu rada kada se koristi kodiranje temeljeno na kvaliteti. NVENC je zanemaren jer ne podržava ICQ.", + "transcoding_constant_rate_factor": "Faktor konstantne stope (-crf)", + "transcoding_constant_rate_factor_description": "Razina kvalitete videa. Uobičajene vrijednosti su 23 za H.264, 28 za HEVC, 31 za VP9 i 35 za AV1. Niže je bolje, ali stvara veće datoteke.", + "transcoding_disabled_description": "Nemojte transkodirati nijedan videozapis, može prekinuti reprodukciju na nekim klijentima", + "transcoding_hardware_acceleration": "Hardversko Ubrzanje", + "transcoding_hardware_acceleration_description": "Eksperimentalno; puno brže, ali će imati nižu kvalitetu pri istoj bitrate postavci", + "transcoding_hardware_decoding": "Hardversko dekodiranje", + "transcoding_hardware_decoding_setting_description": "Odnosi se samo na NVENC, QSV i RKMPP. Omogućuje ubrzanje s kraja na kraj umjesto samo ubrzavanja kodiranja. Možda neće raditi na svim videozapisima.", "transcoding_hevc_codec": "HEVC kodek", - "transcoding_max_b_frames": "", - "transcoding_max_b_frames_description": "", + "transcoding_max_b_frames": "Maksimalni B-frameovi", + "transcoding_max_b_frames_description": "Više vrijednosti poboljšavaju učinkovitost kompresije, ali usporavaju kodiranje. Možda nije kompatibilan s hardverskim ubrzanjem na starijim uređajima. 0 onemogućuje B-frameove, dok -1 automatski postavlja ovu vrijednost.", "transcoding_max_bitrate": "Maksimalne brzina prijenosa (bitrate)", - "transcoding_max_bitrate_description": "", - "transcoding_max_keyframe_interval": "", - "transcoding_max_keyframe_interval_description": "", - "transcoding_optimal_description": "", - "transcoding_preferred_hardware_device": "", - "transcoding_preferred_hardware_device_description": "", - "transcoding_preset_preset": "", - "transcoding_preset_preset_description": "", - "transcoding_reference_frames": "", - "transcoding_reference_frames_description": "", - "transcoding_required_description": "", - "transcoding_settings": "", - "transcoding_settings_description": "", - "transcoding_target_resolution": "", - "transcoding_target_resolution_description": "", - "transcoding_temporal_aq": "", - "transcoding_temporal_aq_description": "", - "transcoding_threads": "", - "transcoding_threads_description": "", - "transcoding_tone_mapping": "", - "transcoding_tone_mapping_description": "", - "transcoding_tone_mapping_npl": "", - "transcoding_tone_mapping_npl_description": "", - "transcoding_transcode_policy": "", - "transcoding_transcode_policy_description": "", - "transcoding_two_pass_encoding": "", - "transcoding_two_pass_encoding_setting_description": "", - "transcoding_video_codec": "", - "transcoding_video_codec_description": "", - "trash_enabled_description": "", - "trash_number_of_days": "", - "trash_number_of_days_description": "", - "trash_settings": "", - "trash_settings_description": "", - "untracked_files": "", - "untracked_files_description": "", - "user_delete_delay_settings": "", - "user_delete_delay_settings_description": "", - "user_management": "", - "user_password_has_been_reset": "", - "user_password_reset_description": "", - "user_settings": "", - "user_settings_description": "", - "user_successfully_removed": "", - "version_check_enabled_description": "", - "version_check_settings": "", - "version_check_settings_description": "", - "video_conversion_job": "", - "video_conversion_job_description": "" + "transcoding_max_bitrate_description": "Postavljanje maksimalne brzine prijenosa može učiniti veličine datoteka predvidljivijima uz manji trošak za kvalitetu. Pri 720p, tipične vrijednosti su 2600k za VP9 ili HEVC ili 4500k za H.264. Onemogućeno ako je postavljeno na 0.", + "transcoding_max_keyframe_interval": "Maksimalni interval ključnih sličica", + "transcoding_max_keyframe_interval_description": "Postavlja maksimalnu udaljenost slika između ključnih kadrova. Niže vrijednosti pogoršavaju učinkovitost kompresije, ali poboljšavaju vrijeme traženja i mogu poboljšati kvalitetu u scenama s brzim kretanjem. 0 automatski postavlja ovu vrijednost.", + "transcoding_optimal_description": "Videozapisi koji su veći od ciljne rezolucije ili nisu u prihvatljivom formatu", + "transcoding_preferred_hardware_device": "Preferirani hardverski uređaj", + "transcoding_preferred_hardware_device_description": "Odnosi se samo na VAAPI i QSV. Postavlja dri node koji se koristi za hardversko transkodiranje.", + "transcoding_preset_preset": "Preset (-preset)", + "transcoding_preset_preset_description": "Brzina kompresije. Sporije postavke proizvode manje datoteke i povećavaju kvalitetu pri ciljanju određene postavke bitratea. VP9 zanemaruje brzine iznad 'brže'.", + "transcoding_reference_frames": "Referentne slike", + "transcoding_reference_frames_description": "Broj slika za referencu prilikom komprimiranja određene slike. Više vrijednosti poboljšavaju učinkovitost kompresije, ali usporavaju kodiranje. 0 automatski postavlja ovu vrijednost.", + "transcoding_required_description": "Samo videozapisi koji nisu u prihvaćenom formatu", + "transcoding_settings": "Postavke Video Transkodiranja", + "transcoding_settings_description": "Upravljajte informacijama o razlučivosti i kodiranju video datoteka", + "transcoding_target_resolution": "Ciljana rezolucija", + "transcoding_target_resolution_description": "Veće razlučivosti mogu sačuvati više detalja, ali trebaju dulje za kodiranje, imaju veće veličine datoteka i mogu smanjiti odziv aplikacije.", + "transcoding_temporal_aq": "Vremenski AQ", + "transcoding_temporal_aq_description": "Odnosi se samo na NVENC. Povećava kvalitetu scena s puno detalja i malo pokreta. Možda nije kompatibilan sa starijim uređajima.", + "transcoding_threads": "Sljedovi (Threads)", + "transcoding_threads_description": "Više vrijednosti dovode do bržeg kodiranja, ali ostavljaju manje prostora poslužitelju za obradu drugih zadataka dok je aktivan. Ova vrijednost ne smije biti veća od broja CPU jezgri. Maksimalno povećava iskorištenje ako je postavljeno na 0.", + "transcoding_tone_mapping": "Tonsko preslikavanje", + "transcoding_tone_mapping_description": "Pokušava sačuvati izgled HDR videozapisa kada se pretvori u SDR. Svaki algoritam čini različite kompromise za boju, detalje i svjetlinu. Hable čuva detalje, Mobius čuva boju, a Reinhard svjetlinu.", + "transcoding_tone_mapping_npl": "Tone-mapping NPL", + "transcoding_tone_mapping_npl_description": "Boje će se prilagoditi tako da izgledaju normalno za zaslon ove svjetline. Suprotno intuiciji, niže vrijednosti povećavaju svjetlinu videa i obrnuto budući da kompenziraju svjetlinu zaslona. 0 automatski postavlja ovu vrijednost.", + "transcoding_transcode_policy": "Pravila transkodiranja", + "transcoding_transcode_policy_description": "Pravila o tome kada se video treba transkodirati. HDR videozapisi uvijek će biti transkodirani (osim ako je transkodiranje onemogućeno).", + "transcoding_two_pass_encoding": "Kodiranje u dva prolaza", + "transcoding_two_pass_encoding_setting_description": "Transkodiranje u dva prolaza za proizvodnju bolje kodiranih videozapisa. Kada je omogućena maksimalna brzina prijenosa (potrebna za rad s H.264 i HEVC), ovaj način rada koristi raspon brzine prijenosa na temelju maksimalne brzine prijenosa i zanemaruje CRF. Za VP9, CRF se može koristiti ako je maksimalna brzina prijenosa onemogućena.", + "transcoding_video_codec": "Video Kodek", + "transcoding_video_codec_description": "VP9 ima visoku učinkovitost i web-kompatibilnost, ali treba dulje za transkodiranje. HEVC ima sličnu izvedbu, ali ima slabiju web kompatibilnost. H.264 široko je kompatibilan i brzo se transkodira, ali proizvodi mnogo veće datoteke. AV1 je najučinkovitiji kodek, ali nema podršku na starijim uređajima.", + "trash_enabled_description": "Omogućite značajke Smeća", + "trash_number_of_days": "Broj dana", + "trash_number_of_days_description": "Broj dana za držanje sredstava u smeću prije njihovog trajnog uklanjanja", + "trash_settings": "Postavke Smeća", + "trash_settings_description": "Upravljanje postavkama smeća", + "untracked_files": "Nepraćene datoteke", + "untracked_files_description": "Aplikacija ne prati ove datoteke. Mogu biti rezultat neuspjelih premještanja, prekinutih prijenosa ili izostale zbog pogreške", + "user_delete_delay": "Račun i sredstva korisnika {user} bit će zakazani za trajno brisanje za {delay, plural, one {# day} other {# days}}.", + "user_delete_delay_settings": "Brisanje odgode", + "user_delete_delay_settings_description": "Broj dana nakon uklanjanja za trajno brisanje korisničkog računa i imovine. Posao brisanja korisnika pokreće se u ponoć kako bi se provjerili korisnici koji su spremni za brisanje. Promjene ove postavke bit će procijenjene pri sljedećem izvršavanju.", + "user_delete_immediately": "Račun i sredstva korisnika {user} bit će stavljeni u red čekanja za trajno brisanje odmah.", + "user_delete_immediately_checkbox": "Stavite korisnika i imovinu u red za trenutačno brisanje", + "user_management": "Upravljanje Korisnicima", + "user_password_has_been_reset": "Korisnička lozinka je poništena:", + "user_password_reset_description": "Molimo dostavite privremenu lozinku korisniku i obavijestite ga da će morati promijeniti lozinku pri sljedećoj prijavi.", + "user_restore_description": "Račun korisnika {user} bit će vraćen.", + "user_restore_scheduled_removal": "Vrati korisnika - zakazano uklanjanje {date, date, long}", + "user_settings": "Korisničke Postavke", + "user_settings_description": "Upravljanje korisničkim postavkama", + "user_successfully_removed": "Korisnik {email} je uspješno uklonjen.", + "version_check_enabled_description": "Omogući provjeru verzije", + "version_check_implications": "Značajka provjere verzije oslanja se na periodičnu komunikaciju s github.com", + "version_check_settings": "Provjera Verzije", + "version_check_settings_description": "Omogućite/onemogućite obavijest o novoj verziji", + "video_conversion_job": "Transkodiranje videozapisa", + "video_conversion_job_description": "Transkodiranje videozapisa za veću kompatibilnost s preglednicima i uređajima" }, - "admin_email": "", - "admin_password": "", - "administration": "", - "advanced": "", - "album_added": "", - "album_added_notification_setting_description": "", - "album_cover_updated": "", - "album_info_updated": "", - "album_name": "", - "album_options": "", - "album_updated": "", - "album_updated_setting_description": "", - "albums": "", - "albums_count": "", - "all": "", - "all_people": "", - "allow_dark_mode": "", - "allow_edits": "", - "api_key": "", - "api_keys": "", - "app_settings": "", - "appears_in": "", - "archive": "", - "archive_or_unarchive_photo": "", + "admin_email": "E-pošta administratora", + "admin_password": "Admin Lozinka", + "administration": "Administracija", + "advanced": "Napredno", + "age_months": "Dob {months, plural, one {# month} other {# months}}", + "age_year_months": "Dob 1 godina, {months, plural, one {# month} other {# months}}", + "age_years": "{years, plural, other {Age #}}", + "album_added": "Album dodan", + "album_added_notification_setting_description": "Primite obavijest e-poštom kada ste dodani u dijeljeni album", + "album_cover_updated": "Naslovnica albuma ažurirana", + "album_delete_confirmation": "Jeste li sigurni da želite izbrisati album {album}?", + "album_delete_confirmation_description": "Ako se ovaj album dijeli, drugi korisnici mu više neće moći pristupiti.", + "album_info_updated": "Podaci o albumu ažurirani", + "album_leave": "Napustiti album?", + "album_leave_confirmation": "Jeste li sigurni da želite napustiti {album}?", + "album_name": "Naziv Albuma", + "album_options": "Opcije albuma", + "album_remove_user": "Ukloni korisnika?", + "album_remove_user_confirmation": "Jeste li sigurni da želite ukloniti {user}?", + "album_share_no_users": "Čini se da ste podijelili ovaj album sa svim korisnicima ili nemate nijednog korisnika s kojim biste ga dijelili.", + "album_updated": "Album ažuriran", + "album_updated_setting_description": "Primite obavijest e-poštom kada dijeljeni album ima nova sredstva", + "album_user_left": "Napušten {album}", + "album_user_removed": "Uklonjen {user}", + "album_with_link_access": "Dopusti svima s poveznicom pristup fotografijama i osobama u ovom albumu.", + "albums": "Albumi", + "albums_count": "{count, plural, one {{count, number} Album} other {{count, number} Albumi}}", + "all": "Sve", + "all_albums": "Svi albumi", + "all_people": "Svi ljudi", + "all_videos": "Svi videi", + "allow_dark_mode": "Dozvoli tamni način", + "allow_edits": "Dozvoli izmjene", + "allow_public_user_to_download": "Dopusti javnom korisniku preuzimanje", + "allow_public_user_to_upload": "Dopusti javnom korisniku učitavanje", + "anti_clockwise": "Suprotno smjeru kazaljke na satu", + "api_key": "API Ključ", + "api_key_description": "Ova će vrijednost biti prikazana samo jednom. Obavezno ju kopirajte prije zatvaranja prozora.", + "api_key_empty": "Naziv vašeg API ključa ne smije biti prazan", + "api_keys": "API Ključevi", + "app_settings": "Postavke Aplikacije", + "appears_in": "Pojavljuje se u", + "archive": "Arhiva", + "archive_or_unarchive_photo": "Arhivirajte ili dearhivirajte fotografiju", + "archive_size": "Veličina arhive", + "archive_size_description": "Konfigurirajte veličinu arhive za preuzimanja (u GiB)", "archived": "", - "asset_offline": "", - "assets": "", - "authorized_devices": "", - "back": "", - "backward": "", - "blurred_background": "", - "camera": "", - "camera_brand": "", - "camera_model": "", - "cancel": "", - "cancel_search": "", - "cannot_merge_people": "", - "cannot_update_the_description": "", + "archived_count": "{count, plural, other {Archived #}}", + "are_these_the_same_person": "Je li ovo ista osoba?", + "are_you_sure_to_do_this": "Jeste li sigurni da to želite učiniti?", + "asset_added_to_album": "Dodano u album", + "asset_adding_to_album": "Dodavanje u album...", + "asset_description_updated": "Opis imovine je ažuriran", + "asset_filename_is_offline": "Sredstvo {filename} je izvan mreže", + "asset_has_unassigned_faces": "Materijal ima nedodijeljena lica", + "asset_hashing": "Hashiranje...", + "asset_offline": "Sredstvo izvan mreže", + "asset_offline_description": "Ovaj materijal je izvan mreže. Immich ne može pristupiti lokaciji datoteke. Provjerite je li sredstvo dostupno, a zatim ponovno skenirajte biblioteku.", + "asset_skipped": "Preskočeno", + "asset_skipped_in_trash": "U smeću", + "asset_uploaded": "Učitano", + "asset_uploading": "Učitavanje...", + "assets": "Sredstva", + "assets_added_count": "Dodano {count, plural, one {# asset} other {# assets}}", + "assets_added_to_album_count": "Dodano {count, plural, one {# asset} other {# assets}} u album", + "assets_added_to_name_count": "Dodano {count, plural, one {# asset} other {# assets}} u {hasName, select, true {{name}} other {new album}}", + "assets_count": "{count, plural, one {# asset} other {# assets}}", + "assets_moved_to_trash_count": "{count, plural, one {# asset} other {# asset}} premješteno u smeće", + "assets_permanently_deleted_count": "Trajno izbrisano {count, plural, one {# asset} other {# assets}}", + "assets_removed_count": "Uklonjeno {count, plural, one {# asset} other {# assets}}", + "assets_restore_confirmation": "Jeste li sigurni da želite vratiti sve svoje resurse bačene u otpad? Ne možete poništiti ovu radnju!", + "assets_restored_count": "Vraćeno {count, plural, one {# asset} other {# assets}}", + "assets_trashed_count": "Bačeno u smeće {count, plural, one {# asset} other {# assets}}", + "assets_were_part_of_album_count": "{count, plural, one {Asset was} other {Assets were}} već dio albuma", + "authorized_devices": "Ovlašteni Uređaji", + "back": "Nazad", + "back_close_deselect": "Natrag, zatvorite ili poništite odabir", + "backward": "Unazad", + "birthdate_saved": "Datum rođenja uspješno spremljen", + "birthdate_set_description": "Datum rođenja se koristi za izračunavanje godina ove osobe u trenutku fotografije.", + "blurred_background": "Zamućena pozadina", + "build": "Sagradi (Build)", + "build_image": "Sagradi (Build) Image", + "bulk_delete_duplicates_confirmation": "Jeste li sigurni da želite skupno izbrisati {count, plural, one {# duplicate asset} other {# duplicate asset}}? Ovo će zadržati najveće sredstvo svake grupe i trajno izbrisati sve druge duplikate. Ne možete poništiti ovu radnju!", + "bulk_keep_duplicates_confirmation": "Jeste li sigurni da želite zadržati {count, plural, one {# duplicate asset} other {# duplicate asset}}? Ovo će riješiti sve duplicirane grupe bez brisanja ičega.", + "bulk_trash_duplicates_confirmation": "Jeste li sigurni da želite na veliko baciti u smeće {count, plural, one {# duplicate asset} other {# duplicate asset}}? Ovo će zadržati najveće sredstvo svake grupe i baciti sve ostale duplikate u smeće.", + "buy": "Kupi Immich", + "camera": "Kamera", + "camera_brand": "Marka kamere", + "camera_model": "Model kamere", + "cancel": "Otkaži", + "cancel_search": "Otkaži pretragu", + "cannot_merge_people": "Nije moguće spojiti osobe", + "cannot_undo_this_action": "Ne možete poništiti ovu radnju!", + "cannot_update_the_description": "Nije moguće ažurirati opis", "cant_apply_changes": "", "cant_get_faces": "", "cant_search_people": "", "cant_search_places": "", - "change_date": "", - "change_expiration_time": "", - "change_location": "", - "change_name": "", - "change_name_successfully": "", - "change_password": "", - "change_your_password": "", - "changed_visibility_successfully": "", - "check_all": "", - "check_logs": "", - "choose_matching_people_to_merge": "", - "city": "", - "clear": "", - "clear_all": "", - "clear_message": "", - "clear_value": "", - "close": "", - "collapse_all": "", - "color_theme": "", - "comment_options": "", - "comments_are_disabled": "", - "confirm": "", - "confirm_admin_password": "", - "confirm_delete_shared_link": "", - "confirm_password": "", - "contain": "", - "context": "", - "continue": "", - "copied_image_to_clipboard": "", - "copied_to_clipboard": "", - "copy_error": "", - "copy_file_path": "", - "copy_image": "", - "copy_link": "", - "copy_link_to_clipboard": "", - "copy_password": "", - "copy_to_clipboard": "", - "country": "", - "cover": "", - "covers": "", - "create": "", - "create_album": "", - "create_library": "", - "create_link": "", - "create_link_to_share": "", - "create_new_person": "", - "create_new_user": "", - "create_user": "", - "created": "", - "current_device": "", - "custom_locale": "", - "custom_locale_description": "", - "dark": "", - "date_after": "", - "date_and_time": "", - "date_before": "", - "date_range": "", - "day": "", - "default_locale": "", - "default_locale_description": "", - "delete": "", - "delete_album": "", - "delete_api_key_prompt": "", - "delete_key": "", - "delete_library": "", - "delete_link": "", - "delete_shared_link": "", - "delete_user": "", - "deleted_shared_link": "", - "description": "", - "details": "", - "direction": "", - "disabled": "", - "disallow_edits": "", - "discover": "", - "dismiss_all_errors": "", - "dismiss_error": "", - "display_options": "", - "display_order": "", - "display_original_photos": "", - "display_original_photos_setting_description": "", - "done": "", - "download": "", - "downloading": "", - "duration": "", + "change_date": "Promjena datuma", + "change_expiration_time": "Promjena vremena isteka", + "change_location": "Promjena lokacije", + "change_name": "Promjena imena", + "change_name_successfully": "Promijena imena uspješna", + "change_password": "Promjena Lozinke", + "change_password_description": "Ovo je ili prvi put da se prijavljujete u sustav ili je poslan zahtjev za promjenom lozinke. Unesite novu lozinku ispod.", + "change_your_password": "Promijenite lozinku", + "changed_visibility_successfully": "Vidljivost je uspješno promijenjena", + "check_all": "Provjeri Sve", + "check_logs": "Provjera Zapisa", + "choose_matching_people_to_merge": "Odaberite odgovarajuće osobe za spajanje", + "city": "Grad", + "clear": "Očisti", + "clear_all": "Očisti sve", + "clear_all_recent_searches": "Izbriši sva nedavna pretraživanja", + "clear_message": "Jasna poruka", + "clear_value": "Očisti vrijednost", + "clockwise": "U smjeru kazaljke na satu", + "close": "Zatvori", + "collapse": "Sažimanje", + "collapse_all": "Sažmi sve", + "color": "Boja", + "color_theme": "Tema boja", + "comment_deleted": "Komentar izbrisan", + "comment_options": "Opcije komentara", + "comments_and_likes": "Komentari i lajkovi", + "comments_are_disabled": "Komentari onemogućeni", + "confirm": "Potvrdi", + "confirm_admin_password": "Potvrdite lozinku administratora", + "confirm_delete_shared_link": "Jeste li sigurni da želite izbrisati ovu zajedničku vezu?", + "confirm_password": "Potvrdite lozinku", + "contain": "Sadrži", + "context": "Kontekst", + "continue": "Nastavi", + "copied_image_to_clipboard": "Slika je kopirana u međuspremnik.", + "copied_to_clipboard": "Kopirano u međuspremnik!", + "copy_error": "Greška kopiranja", + "copy_file_path": "Kopiraj put datoteke", + "copy_image": "Kopiraj Sliku", + "copy_link": "Kopiraj poveznicu", + "copy_link_to_clipboard": "Kopiraj poveznicu u međuspremnik", + "copy_password": "Kopiraj lozinku", + "copy_to_clipboard": "Kopiraj u međuspremnik", + "country": "Država", + "cover": "Naslovnica", + "covers": "Naslovnice", + "create": "Kreiraj", + "create_album": "Kreiraj album", + "create_library": "Kreiraj Biblioteku", + "create_link": "Kreiraj poveznicu", + "create_link_to_share": "Izradite vezu za dijeljenje", + "create_link_to_share_description": "Dopusti svakome s vezom da vidi odabrane fotografije", + "create_new_person": "Stvorite novu osobu", + "create_new_person_hint": "Dodijelite odabrana sredstva novoj osobi", + "create_new_user": "Kreiraj novog korisnika", + "create_tag": "Stvori oznaku", + "create_tag_description": "Napravite novu oznaku. Za ugniježđene oznake unesite punu putanju oznake uključujući kose crte.", + "create_user": "Stvori korisnika", + "created": "Stvoreno", + "current_device": "Trenutačni uređaj", + "custom_locale": "Prilagođena Lokalizacija", + "custom_locale_description": "Formatiranje datuma i brojeva na temelju jezika i regije", + "dark": "Tamno", + "date_after": "Datum nakon", + "date_and_time": "Datum i Vrijeme", + "date_before": "Datum prije", + "date_of_birth_saved": "Datum rođenja uspješno spremljen", + "date_range": "Razdoblje", + "day": "Dan", + "deduplicate_all": "Dedupliciraj Sve", + "default_locale": "Zadana lokalizacija", + "default_locale_description": "Oblikujte datume i brojeve na temelju jezika preglednika", + "delete": "Izbriši", + "delete_album": "Izbriši album", + "delete_api_key_prompt": "Jeste li sigurni da želite izbrisati ovaj API ključ?", + "delete_duplicates_confirmation": "Jeste li sigurni da želite trajno izbrisati ove duplikate?", + "delete_key": "Ključ za brisanje", + "delete_library": "Izbriši knjižnicu", + "delete_link": "Izbriši poveznicu", + "delete_shared_link": "Izbriši dijeljenu poveznicu", + "delete_tag": "Izbriši oznaku", + "delete_tag_confirmation_prompt": "Jeste li sigurni da želite izbrisati oznaku {tagName}?", + "delete_user": "Izbriši korisnika", + "deleted_shared_link": "Izbrisana dijeljena poveznica", + "description": "Opis", + "details": "Detalji", + "direction": "Smjer", + "disabled": "Onemogućeno", + "disallow_edits": "Zabrani izmjene", + "discover": "Otkrij", + "dismiss_all_errors": "Odbaci sve pogreške", + "dismiss_error": "Odbaci pogrešku", + "display_options": "Mogućnosti prikaza", + "display_order": "Redoslijed prikaza", + "display_original_photos": "Prikaz originalnih fotografija", + "display_original_photos_setting_description": "Radije prikažite izvornu fotografiju kada gledate materijal umjesto sličica kada je izvorni materijal kompatibilan s webom. To može rezultirati sporijim brzinama prikaza fotografija.", + "do_not_show_again": "Ne prikazuj više ovu poruku", + "done": "Gotovo", + "download": "Preuzmi", + "download_include_embedded_motion_videos": "Ugrađeni videozapisi", + "download_include_embedded_motion_videos_description": "Uključite videozapise ugrađene u fotografije s pokretom kao zasebnu datoteku", + "download_settings": "Preuzmi", + "download_settings_description": "Upravljajte postavkama koje se odnose na preuzimanje sredstava", + "downloading": "Preuzimanje", + "downloading_asset_filename": "Preuzimanje materijala {filename}", + "drop_files_to_upload": "Ispustite datoteke bilo gdje za prijenos", + "duplicates": "Duplikati", + "duplicates_description": "Razriješite svaku grupu tako da naznačite koji su duplikati, ako ih ima", + "duration": "Trajanje", "durations": { "days": "", "hours": "", @@ -442,254 +546,378 @@ "months": "", "years": "" }, - "edit_album": "", - "edit_avatar": "", - "edit_date": "", - "edit_date_and_time": "", - "edit_exclusion_pattern": "", - "edit_faces": "", - "edit_import_path": "", - "edit_import_paths": "", - "edit_key": "", - "edit_link": "", - "edit_location": "", - "edit_name": "", - "edit_people": "", - "edit_title": "", - "edit_user": "", - "edited": "", - "editor": "", - "email": "", + "edit": "Izmjena", + "edit_album": "Uredi album", + "edit_avatar": "Uredi avatar", + "edit_date": "Uredi datum", + "edit_date_and_time": "Uredite datum i vrijeme", + "edit_exclusion_pattern": "Uredi uzorak izuzimanja", + "edit_faces": "Uređivanje lica", + "edit_import_path": "Uredi put uvoza", + "edit_import_paths": "Uredi Uvozne Putanje", + "edit_key": "Ključ za uređivanje", + "edit_link": "Uredi poveznicu", + "edit_location": "Uredi lokaciju", + "edit_name": "Uredi ime", + "edit_people": "Uredi ljude", + "edit_tag": "Uredi oznaku", + "edit_title": "Uredi Naslov", + "edit_user": "Uredi korisnika", + "edited": "Uređeno", + "editor": "Urednik", + "editor_close_without_save_prompt": "Promjene neće biti spremljene", + "editor_close_without_save_title": "Zatvoriti uređivač?", + "editor_crop_tool_h2_aspect_ratios": "Omjeri stranica", + "editor_crop_tool_h2_rotation": "Rotacija", + "email": "E-pošta", "empty_album": "", - "empty_trash": "", - "enable": "", - "enabled": "", - "end_date": "", - "error": "", - "error_loading_image": "", + "empty_trash": "Isprazni smeće", + "empty_trash_confirmation": "Jeste li sigurni da želite isprazniti smeće? Time će se iz Immicha trajno ukloniti sva sredstva u otpadu.\nNe možete poništiti ovu radnju!", + "enable": "Omogući", + "enabled": "Omogućeno", + "end_date": "Datum završetka", + "error": "Greška", + "error_loading_image": "Pogreška pri učitavanju slike", + "error_title": "Greška - Nešto je pošlo krivo", "errors": { - "cleared_jobs": "", - "exclusion_pattern_already_exists": "", - "failed_job_command": "", - "import_path_already_exists": "", - "paths_validation_failed": "", - "quota_higher_than_disk_size": "", - "repair_unable_to_check_items": "", - "unable_to_add_album_users": "", - "unable_to_add_comment": "", - "unable_to_add_exclusion_pattern": "", - "unable_to_add_import_path": "", - "unable_to_add_partners": "", - "unable_to_change_album_user_role": "", - "unable_to_change_date": "", - "unable_to_change_location": "", - "unable_to_change_password": "", - "unable_to_copy_to_clipboard": "", - "unable_to_create_api_key": "", - "unable_to_create_library": "", - "unable_to_create_user": "", - "unable_to_delete_album": "", - "unable_to_delete_asset": "", - "unable_to_delete_exclusion_pattern": "", - "unable_to_delete_import_path": "", - "unable_to_delete_shared_link": "", - "unable_to_delete_user": "", - "unable_to_edit_exclusion_pattern": "", - "unable_to_edit_import_path": "", - "unable_to_empty_trash": "", - "unable_to_enter_fullscreen": "", - "unable_to_exit_fullscreen": "", - "unable_to_hide_person": "", - "unable_to_link_oauth_account": "", - "unable_to_load_album": "", - "unable_to_load_asset_activity": "", - "unable_to_load_items": "", - "unable_to_load_liked_status": "", - "unable_to_play_video": "", - "unable_to_refresh_user": "", - "unable_to_remove_album_users": "", - "unable_to_remove_api_key": "", - "unable_to_remove_library": "", - "unable_to_remove_offline_files": "", - "unable_to_remove_partner": "", - "unable_to_remove_reaction": "", - "unable_to_repair_items": "", - "unable_to_reset_password": "", - "unable_to_resolve_duplicate": "", - "unable_to_restore_assets": "", - "unable_to_restore_trash": "", - "unable_to_restore_user": "", - "unable_to_save_album": "", - "unable_to_save_api_key": "", - "unable_to_save_name": "", - "unable_to_save_profile": "", - "unable_to_save_settings": "", - "unable_to_scan_libraries": "", - "unable_to_scan_library": "", - "unable_to_set_profile_picture": "", - "unable_to_submit_job": "", - "unable_to_trash_asset": "", - "unable_to_unlink_account": "", - "unable_to_update_library": "", - "unable_to_update_location": "", - "unable_to_update_settings": "", - "unable_to_update_timeline_display_status": "", - "unable_to_update_user": "" + "cannot_navigate_next_asset": "Nije moguće prijeći na sljedeći materijal", + "cannot_navigate_previous_asset": "Nije moguće prijeći na prethodni materijal", + "cant_apply_changes": "Nije moguće primijeniti promjene", + "cant_change_activity": "Ne mogu {enabled, select, true {disable} druge {enable}} aktivnosti", + "cant_change_asset_favorite": "Nije moguće promijeniti favorita za sredstvo", + "cant_change_metadata_assets_count": "Nije moguće promijeniti metapodatke {count, plural, one {# asset} other {# assets}}", + "cant_get_faces": "Ne mogu dobiti lica", + "cant_get_number_of_comments": "Ne mogu dobiti broj komentara", + "cant_search_people": "Ne mogu pretraživati ljude", + "cant_search_places": "Ne mogu pretraživati mjesta", + "cleared_jobs": "Izbrisani poslovi za: {job}", + "error_adding_assets_to_album": "Pogreška pri dodavanju materijala u album", + "error_adding_users_to_album": "Pogreška pri dodavanju korisnika u album", + "error_deleting_shared_user": "Pogreška pri brisanju dijeljenog korisnika", + "error_downloading": "Pogreška pri preuzimanju {filename}", + "error_hiding_buy_button": "Pogreška pri skrivanju gumba za kupnju", + "error_removing_assets_from_album": "Pogreška prilikom uklanjanja materijala iz albuma, provjerite konzolu za više pojedinosti", + "error_selecting_all_assets": "Pogreška pri odabiru svih sredstava", + "exclusion_pattern_already_exists": "Ovaj uzorak izuzimanja već postoji.", + "failed_job_command": "Naredba {command} nije uspjela za posao: {job}", + "failed_to_create_album": "Izrada albuma nije uspjela", + "failed_to_create_shared_link": "Stvaranje dijeljene veze nije uspjelo", + "failed_to_edit_shared_link": "Nije uspjelo uređivanje dijeljene poveznice", + "failed_to_get_people": "Dohvaćanje ljudi nije uspjelo", + "failed_to_load_asset": "Učitavanje sredstva nije uspjelo", + "failed_to_load_assets": "Učitavanje sredstava nije uspjelo", + "failed_to_load_people": "Učitavanje ljudi nije uspjelo", + "failed_to_remove_product_key": "Uklanjanje ključa proizvoda nije uspjelo", + "failed_to_stack_assets": "Slaganje sredstava nije uspjelo", + "failed_to_unstack_assets": "Nije uspjelo uklanjanje snopa sredstava", + "import_path_already_exists": "Ovaj uvozni put već postoji.", + "incorrect_email_or_password": "Netočna adresa e-pošte ili lozinka", + "paths_validation_failed": "{paths, plural, one {# putanja nije prošla} other {# putanje nisu prošle}} provjeru valjanosti", + "profile_picture_transparent_pixels": "Profilne slike ne smiju imati prozirne piksele. Povećajte i/ili pomaknite sliku.", + "quota_higher_than_disk_size": "Postavili ste kvotu veću od veličine diska", + "repair_unable_to_check_items": "Nije moguće provjeriti {count, select, one {item} other {items}}", + "unable_to_add_album_users": "Nije moguće dodati korisnike u album", + "unable_to_add_assets_to_shared_link": "Nije moguće dodati sredstva na dijeljenu poveznicu", + "unable_to_add_comment": "Nije moguće dodati komentar", + "unable_to_add_exclusion_pattern": "Nije moguće dodati uzorak izuzimanja", + "unable_to_add_import_path": "Nije moguće dodati putanju uvoza", + "unable_to_add_partners": "Nije moguće dodati partnere", + "unable_to_add_remove_archive": "Nije moguće {arhivirano, odabrati, istinito {ukloniti sredstvo iz} druge {dodati sredstvo u}} arhivu", + "unable_to_add_remove_favorites": "Nije moguće {favorite, select, true {add asset to} other {remove asset from}} favorite", + "unable_to_archive_unarchive": "Nije moguće {arhivirati, odabrati, istinito {arhivirati} ostalo {dearhivirati}}", + "unable_to_change_album_user_role": "Nije moguće promijeniti ulogu korisnika albuma", + "unable_to_change_date": "Nije moguće promijeniti datum", + "unable_to_change_favorite": "Nije moguće promijeniti favorita za sredstvo", + "unable_to_change_location": "Nije moguće promijeniti lokaciju", + "unable_to_change_password": "Nije moguće promijeniti lozinku", + "unable_to_change_visibility": "Nije moguće promijeniti vidljivost za {count, plural, one {# osobu} other {# osobe}}", + "unable_to_complete_oauth_login": "Nije moguće dovršiti OAuth prijavu", + "unable_to_connect": "Povezivanje nije moguće", + "unable_to_connect_to_server": "Nije moguće spojiti se na poslužitelj", + "unable_to_copy_to_clipboard": "Nije moguće kopirati u međuspremnik, provjerite pristupate li stranici putem https-a", + "unable_to_create_admin_account": "Nije moguće stvoriti administratorski račun", + "unable_to_create_api_key": "Nije moguće izraditi novi API ključ", + "unable_to_create_library": "Nije moguće stvoriti biblioteku", + "unable_to_create_user": "Nije moguće stvoriti korisnika", + "unable_to_delete_album": "Nije moguće izbrisati album", + "unable_to_delete_asset": "Nije moguće izbrisati sredstvo", + "unable_to_delete_assets": "Pogreška pri brisanju sredstava", + "unable_to_delete_exclusion_pattern": "Nije moguće izbrisati uzorak izuzimanja", + "unable_to_delete_import_path": "Nije moguće izbrisati put uvoza", + "unable_to_delete_shared_link": "Nije moguće izbrisati dijeljenu poveznicu", + "unable_to_delete_user": "Nije moguće izbrisati korisnika", + "unable_to_download_files": "Nije moguće preuzeti datoteke", + "unable_to_edit_exclusion_pattern": "Nije moguće urediti uzorak izuzimanja", + "unable_to_edit_import_path": "Nije moguće urediti put uvoza", + "unable_to_empty_trash": "Nije moguće isprazniti otpad", + "unable_to_enter_fullscreen": "Nije moguće otvoriti cijeli zaslon", + "unable_to_exit_fullscreen": "Nije moguće izaći iz cijelog zaslona", + "unable_to_get_comments_number": "Nije moguće dobiti broj komentara", + "unable_to_get_shared_link": "Dohvaćanje dijeljene veze nije uspjelo", + "unable_to_hide_person": "Nije moguće sakriti osobu", + "unable_to_link_motion_video": "Nije moguće povezati videozapis pokreta", + "unable_to_link_oauth_account": "Nije moguće povezati OAuth račun", + "unable_to_load_album": "Nije moguće učitati album", + "unable_to_load_asset_activity": "Nije moguće učitati aktivnost sredstva", + "unable_to_load_items": "Nije moguće učitati stavke", + "unable_to_load_liked_status": "Nije moguće učitati status sviđanja", + "unable_to_log_out_all_devices": "Nije moguće odjaviti sve uređaje", + "unable_to_log_out_device": "Nije moguće odjaviti uređaj", + "unable_to_login_with_oauth": "Nije moguće prijaviti se pomoću OAutha", + "unable_to_play_video": "Nije moguće reproducirati video", + "unable_to_reassign_assets_existing_person": "Nije moguće ponovno dodijeliti imovinu na {name, select, null {postojeću osobu} other {{name}}}", + "unable_to_reassign_assets_new_person": "Nije moguće ponovno dodijeliti imovinu novoj osobi", + "unable_to_refresh_user": "Nije moguće osvježiti korisnika", + "unable_to_remove_album_users": "Nije moguće ukloniti korisnike iz albuma", + "unable_to_remove_api_key": "Nije moguće ukloniti API ključ", + "unable_to_remove_assets_from_shared_link": "Nije moguće ukloniti sredstva iz dijeljene poveznice", + "unable_to_remove_library": "Nije moguće ukloniti biblioteku", + "unable_to_remove_offline_files": "Nije moguće ukloniti izvanmrežne datoteke", + "unable_to_remove_partner": "Nije moguće ukloniti partnera", + "unable_to_remove_reaction": "Nije moguće ukloniti reakciju", + "unable_to_repair_items": "Nije moguće popraviti stavke", + "unable_to_reset_password": "Nije moguće ponovno postaviti lozinku", + "unable_to_resolve_duplicate": "Nije moguće razriješiti duplikat", + "unable_to_restore_assets": "Nije moguće vratiti imovinu", + "unable_to_restore_trash": "Nije moguće vratiti otpad", + "unable_to_restore_user": "Nije moguće vratiti korisnika", + "unable_to_save_album": "Nije moguće spremiti album", + "unable_to_save_api_key": "Nije moguće spremiti API ključ", + "unable_to_save_date_of_birth": "Nije moguće spremiti datum rođenja", + "unable_to_save_name": "Nije moguće spremiti ime", + "unable_to_save_profile": "Nije moguće spremiti profil", + "unable_to_save_settings": "Nije moguće spremiti postavke", + "unable_to_scan_libraries": "Nije moguće skenirati knjižnice", + "unable_to_scan_library": "Nije moguće skenirati knjižnicu", + "unable_to_set_feature_photo": "Nije moguće postaviti istaknutu fotografiju", + "unable_to_set_profile_picture": "Nije moguće postaviti profilnu sliku", + "unable_to_submit_job": "Nije moguće poslati posao", + "unable_to_trash_asset": "Nije moguće baciti sredstvo u smeće", + "unable_to_unlink_account": "Nije moguće prekinuti vezu računa", + "unable_to_unlink_motion_video": "Nije moguće prekinuti vezu videozapisa pokreta", + "unable_to_update_album_cover": "Nije moguće ažurirati omot albuma", + "unable_to_update_album_info": "Nije moguće ažurirati informacije o albumu", + "unable_to_update_library": "Nije moguće ažurirati biblioteku", + "unable_to_update_location": "Nije moguće ažurirati lokaciju", + "unable_to_update_settings": "Nije moguće ažurirati postavke", + "unable_to_update_timeline_display_status": "Nije moguće ažurirati status prikaza vremenske trake", + "unable_to_update_user": "Nije moguće ažurirati korisnika", + "unable_to_upload_file": "Nije moguće učitati datoteku" }, - "exit_slideshow": "", - "expand_all": "", - "expire_after": "", - "expired": "", - "explore": "", - "export": "", - "export_as_json": "", - "extension": "", - "external": "", - "external_libraries": "", + "exif": "Exif", + "exit_slideshow": "Izađi iz projekcije slideova", + "expand_all": "Proširi sve", + "expire_after": "Istječe nakon", + "expired": "Isteklo", + "expires_date": "Ističe {date}", + "explore": "Istraži", + "explorer": "Pretraživač (Explorer)", + "export": "Izvoz", + "export_as_json": "Izvezi kao JSON", + "extension": "Proširenje (Extension)", + "external": "Vanjski", + "external_libraries": "Vanjske Biblioteke", + "face_unassigned": "Nedodijeljeno", "failed_to_get_people": "", - "favorite": "", - "favorite_or_unfavorite_photo": "", - "favorites": "", - "feature_photo_updated": "", - "file_name": "", - "file_name_or_extension": "", - "filename": "", - "filetype": "", - "filter_people": "", - "find_them_fast": "", - "fix_incorrect_match": "", - "force_re-scan_library_files": "", - "forward": "", - "general": "", - "get_help": "", - "getting_started": "", - "go_back": "", - "go_to_search": "", + "favorite": "Omiljeno", + "favorite_or_unfavorite_photo": "Omiljena ili neomiljena fotografija", + "favorites": "Omiljene", + "feature_photo_updated": "Istaknuta fotografija ažurirana", + "features": "Značajke (Features)", + "features_setting_description": "Upravljajte značajkama aplikacije", + "file_name": "Naziv datoteke", + "file_name_or_extension": "Naziv ili ekstenzija datoteke", + "filename": "Naziv datoteke", + "filetype": "Vrsta datoteke", + "filter_people": "Filtrirajte ljude", + "find_them_fast": "Pronađite ih brzo po imenu pomoću pretraživanja", + "fix_incorrect_match": "Ispravite netočno podudaranje", + "folders": "Mape", + "folders_feature_description": "Pregledavanje prikaza mape za fotografije i videozapise u sustavu datoteka", + "force_re-scan_library_files": "Prisilno ponovno skeniraj sve datoteke biblioteke", + "forward": "Naprijed", + "general": "Općenito", + "get_help": "Potražite pomoć", + "getting_started": "Početak Rada", + "go_back": "Idi natrag", + "go_to_search": "Idi na pretragu", "go_to_share_page": "", - "group_albums_by": "", - "has_quota": "", - "hide_gallery": "", - "hide_password": "", - "hide_person": "", - "host": "", - "hour": "", - "image": "", - "immich_logo": "", - "import_from_json": "", - "import_path": "", - "in_archive": "", - "include_archived": "", - "include_shared_albums": "", - "include_shared_partner_assets": "", - "individual_share": "", - "info": "", + "group_albums_by": "Grupiraj albume po...", + "group_no": "Nema grupiranja", + "group_owner": "Grupiraj po vlasniku", + "group_year": "Grupiraj po godini", + "has_quota": "Ima kvotu", + "hi_user": "Bok {name} ({email})", + "hide_all_people": "Sakrij sve ljude", + "hide_gallery": "Sakrij galeriju", + "hide_named_person": "Sakrij osobu {name}", + "hide_password": "Sakrij lozinku", + "hide_person": "Sakrij osobu", + "hide_unnamed_people": "Sakrij neimenovane osobe", + "host": "Domaćin", + "hour": "Sat", + "image": "Slika", + "image_alt_text_date": "{isVideo, select, true {Video} other {Image}} snimljeno {date}", + "image_alt_text_date_1_person": "{isVideo, select, true {Video} other {Image}} snimljeno s {person1} {date}", + "image_alt_text_date_2_people": "{isVideo, select, true {Video} other {Image}} snimljeno s {person1} i {person2} {date}", + "image_alt_text_date_3_people": "{isVideo, select, true {Video} other {Image}} snimljeno s {person1}, {person2} i {person3} {date}", + "image_alt_text_date_4_or_more_people": "{isVideo, select, true {Video} other {Image}} snimljeno s {person1}, {person2} i {additionalCount, number} drugih {date}", + "image_alt_text_date_place": "{isVideo, select, true {Video} other {Image}} snimljeno u {city}, {country} {date}", + "image_alt_text_date_place_1_person": "{isVideo, select, true {Video} other {Image}} snimljeno u {city}, {country} s {person1} {date}", + "image_alt_text_date_place_2_people": "{isVideo, select, true {Video} other {Image}} snimljeno u {city}, {country} s {person1} i {person2} {date}", + "image_alt_text_date_place_3_people": "{isVideo, select, true {Video} other {Image}} snimljeno u {city}, {country} s {person1}, {person2} i {person3} {date}", + "image_alt_text_date_place_4_or_more_people": "{isVideo, select, true {Video} other {Image}} snimljeno u {city}, {country} s {person1}, {person2} i {additionalCount, number} drugih {date}", + "immich_logo": "Immich Logo", + "immich_web_interface": "Immich Web Sučelje", + "import_from_json": "Uvoz iz JSON-a", + "import_path": "Putanja uvoza", + "in_albums": "U {count, plural, one {# album} other {# albuma}}", + "in_archive": "U arhivi", + "include_archived": "Uključi arhivirano", + "include_shared_albums": "Uključi dijeljene albume", + "include_shared_partner_assets": "Uključite zajedničku imovinu partnera", + "individual_share": "Pojedinačni udio", + "info": "Informacije", "interval": { - "day_at_onepm": "", - "hours": "", - "night_at_midnight": "", - "night_at_twoam": "" + "day_at_onepm": "Svaki dan u 13 sati", + "hours": "{hours, plural, one {Svaki sat} few {Svakih {hours, number} sata} other {Svakih {hours, number} sati}}", + "night_at_midnight": "Svaku večer u ponoć", + "night_at_twoam": "Svake noći u 2 ujutro" }, - "invite_people": "", - "invite_to_album": "", - "jobs": "", - "keep": "", - "keyboard_shortcuts": "", - "language": "", - "language_setting_description": "", - "last_seen": "", - "leave": "", - "let_others_respond": "", - "level": "", - "library": "", - "library_options": "", - "light": "", - "link_options": "", - "link_to_oauth": "", - "linked_oauth_account": "", - "list": "", - "loading": "", - "loading_search_results_failed": "", - "log_out": "", - "log_out_all_devices": "", - "login_has_been_disabled": "", - "look": "", - "loop_videos": "", - "loop_videos_description": "", - "make": "", - "manage_shared_links": "", - "manage_sharing_with_partners": "", - "manage_the_app_settings": "", - "manage_your_account": "", - "manage_your_api_keys": "", - "manage_your_devices": "", - "manage_your_oauth_connection": "", - "map": "", - "map_marker_with_image": "", - "map_settings": "", - "matches": "", - "media_type": "", - "memories": "", - "memories_setting_description": "", - "menu": "", - "merge": "", - "merge_people": "", - "merge_people_successfully": "", - "minimize": "", - "minute": "", - "missing": "", - "model": "", - "month": "", - "more": "", - "moved_to_trash": "", - "my_albums": "", - "name": "", - "name_or_nickname": "", - "never": "", - "new_api_key": "", - "new_password": "", - "new_person": "", - "new_user_created": "", - "newest_first": "", - "next": "", - "next_memory": "", - "no": "", - "no_albums_message": "", - "no_archived_assets_message": "", - "no_assets_message": "", - "no_duplicates_found": "", - "no_exif_info_available": "", - "no_explore_results_message": "", - "no_favorites_message": "", - "no_libraries_message": "", - "no_name": "", - "no_places": "", - "no_results": "", - "no_shared_albums_message": "", - "not_in_any_album": "", - "note_apply_storage_label_to_previously_uploaded assets": "", - "note_unlimited_quota": "", - "notes": "", - "notification_toggle_setting_description": "", - "notifications": "", - "notifications_setting_description": "", - "oauth": "", - "offline": "", - "offline_paths": "", - "offline_paths_description": "", - "ok": "", - "oldest_first": "", - "online": "", - "only_favorites": "", - "only_refreshes_modified_files": "", - "open_the_search_filters": "", - "options": "", - "organize_your_library": "", - "other": "", - "other_devices": "", - "other_variables": "", - "owned": "", - "owner": "", - "partner_can_access": "", + "invite_people": "Pozovite ljude", + "invite_to_album": "Pozovi u album", + "items_count": "{count, plural, one {# datoteka} other {# datoteke}}", + "jobs": "Poslovi", + "keep": "Zadrži", + "keep_all": "Zadrži Sve", + "keyboard_shortcuts": "Prečaci tipkovnice", + "language": "Jezik", + "language_setting_description": "Odaberite željeni jezik", + "last_seen": "Zadnji put viđen", + "latest_version": "Najnovija verzija", + "latitude": "Zemljopisna širina", + "leave": "Izađi", + "let_others_respond": "Dozvoli da drugi odgovore", + "level": "Razina", + "library": "Biblioteka", + "library_options": "Mogućnosti biblioteke", + "light": "Svjetlo", + "like_deleted": "Like izbrisan", + "link_motion_video": "Povežite videozapis pokreta", + "link_options": "Opcije veze", + "link_to_oauth": "Veza na OAuth", + "linked_oauth_account": "Povezani OAuth račun", + "list": "Popis", + "loading": "Učitavanje", + "loading_search_results_failed": "Učitavanje rezultata pretraživanja nije uspjelo", + "log_out": "Odjavi se", + "log_out_all_devices": "Odjava sa svih uređaja", + "logged_out_all_devices": "Odjavljeni su svi uređaji", + "logged_out_device": "Odjavljen uređaj", + "login": "Prijava", + "login_has_been_disabled": "Prijava je onemogućena.", + "logout_all_device_confirmation": "Jeste li sigurni da želite odjaviti sve uređaje?", + "logout_this_device_confirmation": "Jeste li sigurni da se želite odjaviti s ovog uređaja?", + "longitude": "Zemljopisna dužina", + "look": "Izgled", + "loop_videos": "Ponavljajte videozapise", + "loop_videos_description": "Omogućite automatsko ponavljanje videozapisa u pregledniku detalja.", + "make": "Proizvođač", + "manage_shared_links": "Upravljanje dijeljenim vezama", + "manage_sharing_with_partners": "Upravljajte dijeljenjem s partnerima", + "manage_the_app_settings": "Upravljajte postavkama aplikacije", + "manage_your_account": "Upravljajte svojim računom", + "manage_your_api_keys": "Upravljajte svojim API ključevima", + "manage_your_devices": "Upravljajte uređajima na kojima ste prijavljeni", + "manage_your_oauth_connection": "Upravljajte svojom OAuth vezom", + "map": "Karta", + "map_marker_for_images": "Oznaka karte za slike snimljene u {city}, {country}", + "map_marker_with_image": "Oznaka karte sa slikom", + "map_settings": "Postavke karte", + "matches": "Podudaranja", + "media_type": "Vrsta medija", + "memories": "Sjećanja", + "memories_setting_description": "Upravljajte onim što vidite u svojim sjećanjima", + "memory": "Memorija", + "memory_lane_title": "Traka sjećanja {title}", + "menu": "Izbornik", + "merge": "Spoji", + "merge_people": "Spajanje ljudi", + "merge_people_limit": "Možete spojiti najviše 5 lica odjednom", + "merge_people_prompt": "Želite li spojiti ove ljude? Ova radnja je nepovratna.", + "merge_people_successfully": "Uspješno spajanje ljudi", + "merged_people_count": "{count, plural, one {# Spojena osoba} other {# Spojene osobe}}", + "minimize": "Minimiziraj", + "minute": "Minuta", + "missing": "Nedostaje", + "model": "Model", + "month": "Mjesec", + "more": "Više", + "moved_to_trash": "Premješteno u smeće", + "my_albums": "Moji albumi", + "name": "Ime", + "name_or_nickname": "Ime ili nadimak", + "never": "Nikada", + "new_album": "Novi Album", + "new_api_key": "Novi API ključ", + "new_password": "Nova lozinka", + "new_person": "Nova osoba", + "new_user_created": "Stvoren novi korisnik", + "new_version_available": "DOSTUPNA NOVA VERZIJA", + "newest_first": "Prvo najnovije", + "next": "Sljedeće", + "next_memory": "Sljedeće sjećanje", + "no": "Ne", + "no_albums_message": "Izradite album za organiziranje svojih fotografija i videozapisa", + "no_albums_with_name_yet": "Čini se da još nemate nijedan album s ovim imenom.", + "no_albums_yet": "Čini se da još nemate nijedan album.", + "no_archived_assets_message": "Arhivirajte fotografije i videozapise kako biste ih sakrili iz prikaza fotografija", + "no_assets_message": "KLIKNITE DA PRENESETE SVOJU PRVU FOTOGRAFIJU", + "no_duplicates_found": "Nisu pronađeni duplikati.", + "no_exif_info_available": "Nema dostupnih exif podataka", + "no_explore_results_message": "Prenesite više fotografija da istražite svoju zbirku.", + "no_favorites_message": "Dodajte favorite kako biste brzo pronašli svoje najbolje slike i videozapise", + "no_libraries_message": "Stvorite vanjsku biblioteku za pregled svojih fotografija i videozapisa", + "no_name": "Bez imena", + "no_places": "Nema mjesta", + "no_results": "Nema rezultata", + "no_results_description": "Pokušajte sa sinonimom ili općenitijom ključnom riječi", + "no_shared_albums_message": "Stvorite album za dijeljenje fotografija i videozapisa s osobama u svojoj mreži", + "not_in_any_album": "Ni u jednom albumu", + "note_apply_storage_label_to_previously_uploaded assets": "Napomena: Da biste primijenili Oznaku za skladištenje na prethodno prenesena sredstva, pokrenite", + "note_unlimited_quota": "napomena: Unesite 0 za neograni%C4%8Denu kvotu", + "notes": "Bilješke", + "notification_toggle_setting_description": "Omogući obavijesti putem e-pošte", + "notifications": "Obavijesti", + "notifications_setting_description": "Upravljanje obavijestima", + "oauth": "OAuth", + "offline": "Izvan mreže", + "offline_paths": "Izvanmrežne putanje", + "offline_paths_description": "Ovi rezultati mogu biti posljedica ručnog brisanja datoteka koje nisu dio vanjske biblioteke.", + "ok": "Ok", + "oldest_first": "Prvo najstarije", + "onboarding": "Uključivanje (Onboarding)", + "onboarding_privacy_description": "Sljedeće (neobavezne) značajke oslanjaju se na vanjske usluge i mogu se onemogućiti u bilo kojem trenutku u postavkama administracije.", + "onboarding_theme_description": "Odaberite temu boja za svoj primjer. To možete kasnije promijeniti u postavkama.", + "onboarding_welcome_description": "Postavimo vašu instancu s nekim uobičajenim postavkama.", + "onboarding_welcome_user": "Dobro došli, {user}", + "online": "Dostupan (Online)", + "only_favorites": "Samo omiljeno", + "only_refreshes_modified_files": "Osvježava samo izmijenjene datoteke", + "open_in_map_view": "Otvori u prikazu karte", + "open_in_openstreetmap": "Otvori u OpenStreetMap", + "open_the_search_filters": "Otvorite filtre pretraživanja", + "options": "Opcije", + "or": "ili", + "organize_your_library": "Organizirajte svoju knjižnicu", + "original": "original", + "other": "Ostalo", + "other_devices": "Ostali uređaji", + "other_variables": "Ostale varijable", + "owned": "Vlasništvo", + "owner": "Vlasnik", + "partner": "Partner", + "partner_can_access": "{partner} može pristupiti", "partner_can_access_assets": "", "partner_can_access_location": "", "partner_sharing": "", diff --git a/web/src/lib/i18n/it.json b/web/src/lib/i18n/it.json index cbe3651927edb..daee687003cf1 100644 --- a/web/src/lib/i18n/it.json +++ b/web/src/lib/i18n/it.json @@ -661,6 +661,7 @@ "unable_to_get_comments_number": "Impossibile ottenere il numero di commenti", "unable_to_get_shared_link": "Impossibile ottenere il link condiviso", "unable_to_hide_person": "Impossibile nascondere persona", + "unable_to_link_motion_video": "Impossibile collegare video in movimento", "unable_to_link_oauth_account": "Impossibile collegare l'account OAuth", "unable_to_load_album": "Impossibile caricare l'album", "unable_to_load_asset_activity": "Impossibile caricare l'attività dell'asset", @@ -701,6 +702,7 @@ "unable_to_submit_job": "Impossibile eseguire l'attività", "unable_to_trash_asset": "Impossibile cestinare l'asset", "unable_to_unlink_account": "Impossibile scollegare l'account", + "unable_to_unlink_motion_video": "Impossibile scollegare video in movimento", "unable_to_update_album_cover": "Errore durante l'aggiornamento della copertina dell'album", "unable_to_update_album_info": "Impossibile aggiornare le informazioni sull'album", "unable_to_update_library": "Impossibile aggiornare la libreria", @@ -1290,6 +1292,7 @@ "unknown_album": "Album sconosciuto", "unknown_year": "Anno sconosciuto", "unlimited": "Illimitato", + "unlink_motion_video": "Scollega video in movimento", "unlink_oauth": "Scollega OAuth", "unlinked_oauth_account": "Scollega account OAuth", "unnamed_album": "Album senza nome", diff --git a/web/src/lib/i18n/ko.json b/web/src/lib/i18n/ko.json index df46923b5ef73..8adf988f5dd6d 100644 --- a/web/src/lib/i18n/ko.json +++ b/web/src/lib/i18n/ko.json @@ -139,7 +139,11 @@ "map_settings_description": "지도 설정 관리", "map_style_description": "지도 테마 style.json URL", "metadata_extraction_job": "메타데이터 추출", - "metadata_extraction_job_description": "각 항목에서 GPS, 해상도 등의 메타데이터 정보 추출", + "metadata_extraction_job_description": "각 항목에서 GPS, 인물 및 해상도 등의 메타데이터 정보 추출", + "metadata_faces_import_setting": "얼굴 가져오기 활성화", + "metadata_faces_import_setting_description": "사이드카 파일의 이미지 EXIF 데이터에서 얼굴 가져오기", + "metadata_settings": "메타데이터 설정", + "metadata_settings_description": "메타데이터 설정 관리", "migration_job": "마이그레이션", "migration_job_description": "각 항목의 섬네일 및 인물의 얼굴을 최신 폴더 구조로 마이그레이션", "no_paths_added": "추가된 경로 없음", @@ -1114,6 +1118,7 @@ "search_for_existing_person": "존재하는 인물 검색", "search_no_people": "인물이 없습니다.", "search_no_people_named": "\"{name}\" 인물을 찾을 수 없음", + "search_options": "검색 옵션", "search_people": "인물 검색", "search_places": "장소 검색", "search_state": "지역 검색...", @@ -1243,6 +1248,7 @@ "to_change_password": "비밀번호 변경", "to_favorite": "즐겨찾기", "to_login": "로그인", + "to_parent": "상위 항목으로", "to_root": "루트", "to_trash": "삭제", "toggle_settings": "설정 변경", diff --git a/web/src/lib/i18n/lv.json b/web/src/lib/i18n/lv.json index bf17ccb8135a4..2701cda4e8428 100644 --- a/web/src/lib/i18n/lv.json +++ b/web/src/lib/i18n/lv.json @@ -25,7 +25,7 @@ "add_to_shared_album": "Pievienot koplietotam albumam", "added_to_archive": "Pievienots arhīvam", "added_to_favorites": "Pievienots izlasei", - "added_to_favorites_count": "Pievienots {count} izlasei", + "added_to_favorites_count": "Pievienots {count, number} izlasei", "admin": { "add_exclusion_pattern_description": "Pievienojiet izlaišanas shēmas. Aizstājējzīmju izmantoša *, **, un ? tiek atbalstīta. Lai ignorētu visus failus jebkurā direktorijā ar nosaukumu “RAW”, izmantojiet “**/RAW/**”. Lai ignorētu visus failus, kas beidzas ar “. tif”, izmantojiet “**/*. tif”. Lai ignorētu absolūto ceļu, izmantojiet “/path/to/ignore/**”.", "authentication_settings": "Autentifikācijas iestatījumi", @@ -44,6 +44,9 @@ "disable_login": "Atspējot pieteikšanos", "disabled": "", "duplicate_detection_job_description": "Palaidiet mašīnmācīšanos uz līdzekļiem, lai noteiktu līdzīgus attēlus. Paļaujas uz Viedo Meklēšanu", + "external_library_created_at": "Ārēja bibliotēka (izveidota {date})", + "external_library_management": "Ārējo bibliotēku pārvaldība", + "face_detection": "Seju noteikšana", "image_format_description": "", "image_prefer_embedded_preview": "", "image_prefer_embedded_preview_setting_description": "", @@ -82,7 +85,7 @@ "machine_learning_enabled_description": "", "machine_learning_facial_recognition": "", "machine_learning_facial_recognition_description": "", - "machine_learning_facial_recognition_model": "", + "machine_learning_facial_recognition_model": "Seju atpazīšanas modelis", "machine_learning_facial_recognition_model_description": "", "machine_learning_facial_recognition_setting_description": "", "machine_learning_max_detection_distance": "", @@ -102,11 +105,13 @@ "manage_log_settings": "", "map_dark_style": "", "map_enable_description": "", + "map_gps_settings": "Kartes un GPS iestatījumi", + "map_gps_settings_description": "Pārvaldīt karšu un GPS (apgrieztās ģeokodēšanas) iestatījumus", "map_light_style": "", "map_reverse_geocoding": "", "map_reverse_geocoding_enable_description": "", "map_reverse_geocoding_settings": "", - "map_settings": "", + "map_settings": "Karte", "map_settings_description": "", "map_style_description": "", "metadata_extraction_job_description": "", @@ -151,10 +156,12 @@ "password_enable_description": "", "password_settings": "", "password_settings_description": "", + "quota_size_gib": "Kvotas izmērs (GiB)", + "require_password_change_on_login": "Pieprasīt lietotājam mainīt paroli pēc pirmās pieteikšanās", "server_external_domain_settings": "", "server_external_domain_settings_description": "", - "server_settings": "", - "server_settings_description": "", + "server_settings": "Servera iestatījumi", + "server_settings_description": "Pārvaldīt servera iestatījumus", "server_welcome_message": "", "server_welcome_message_description": "", "sidecar_job_description": "", @@ -234,38 +241,46 @@ "trash_settings_description": "", "user_delete_delay_settings": "", "user_delete_delay_settings_description": "", + "user_management": "Lietotāju pārvaldība", "user_settings": "", "user_settings_description": "", - "version_check_enabled_description": "", + "version_check_enabled_description": "Ieslēgt versijas pārbaudi", + "version_check_implications": "Versiju pārbaudes funkcija ir atkarīga no periodiskas saziņas ar github.com", "version_check_settings": "", "version_check_settings_description": "", "video_conversion_job_description": "" }, - "admin_email": "", - "admin_password": "", - "administration": "", + "admin_email": "Administratora e-pasts", + "admin_password": "Administratora parole", + "administration": "Administrēšana", "advanced": "Papildu", - "album_added": "", + "album_added": "Albums pievienots", "album_added_notification_setting_description": "", - "album_cover_updated": "", - "album_info_updated": "", - "album_name": "", + "album_cover_updated": "Albuma attēls atjaunināts", + "album_info_updated": "Albuma informācija atjaunināta", + "album_leave": "Pamest albumu?", + "album_name": "Albuma nosaukums", "album_options": "", - "album_updated": "", + "album_remove_user": "Noņemt lietotāju?", + "album_updated": "Albums atjaunināts", "album_updated_setting_description": "", - "albums": "", + "albums": "Albumi", "all": "Viss", - "all_people": "", + "all_albums": "Visi albumi", + "all_people": "Visi cilvēki", + "all_videos": "Visi video", "allow_dark_mode": "", "allow_edits": "", - "api_key": "", - "api_keys": "", + "api_key": "API atslēga", + "api_keys": "API atslēgas", "app_settings": "", "appears_in": "", "archive": "Arhīvs", "archive_or_unarchive_photo": "", + "archive_size": "Arhīva izmērs", "archived": "", "asset_offline": "", + "asset_uploading": "Augšupielādē...", "assets": "aktīvi", "authorized_devices": "", "back": "Atpakaļ", @@ -303,7 +318,7 @@ "comments_are_disabled": "", "confirm": "Apstiprināt", "confirm_admin_password": "", - "confirm_password": "Apstiprināt Paroli", + "confirm_password": "Apstiprināt paroli", "contain": "", "context": "", "continue": "", @@ -324,8 +339,8 @@ "create_link": "Izveidot saiti", "create_link_to_share": "Izveidot kopīgošanas saiti", "create_new_person": "", - "create_new_user": "", - "create_user": "", + "create_new_user": "Izveidot jaunu lietotāju", + "create_user": "Izveidot lietotāju", "created": "", "current_device": "", "custom_locale": "", @@ -344,7 +359,7 @@ "delete_library": "", "delete_link": "", "delete_shared_link": "Dzēst Kopīgošanas saiti", - "delete_user": "", + "delete_user": "Dzēst lietotāju", "deleted_shared_link": "", "description": "Apraksts", "details": "INFORMĀCIJA", @@ -360,6 +375,7 @@ "done": "Gatavs", "download": "Lejupielādēt", "downloading": "", + "duplicates": "Dublikāti", "duration": "", "durations": { "days": "", @@ -382,7 +398,7 @@ "edit_name": "Rediģēt vārdu", "edit_people": "", "edit_title": "", - "edit_user": "", + "edit_user": "Labot lietotāju", "edited": "", "editor": "", "email": "E-pasts", @@ -395,6 +411,7 @@ "error": "", "error_loading_image": "", "errors": { + "failed_to_create_album": "Neizdevās izveidot albumu", "unable_to_add_album_users": "", "unable_to_add_comment": "", "unable_to_add_partners": "", @@ -405,10 +422,10 @@ "unable_to_check_items": "", "unable_to_create_admin_account": "", "unable_to_create_library": "", - "unable_to_create_user": "", + "unable_to_create_user": "Neizdevās izveidot lietotāju", "unable_to_delete_album": "", "unable_to_delete_asset": "", - "unable_to_delete_user": "", + "unable_to_delete_user": "Neizdevās dzēst lietotāju", "unable_to_empty_trash": "", "unable_to_enter_fullscreen": "", "unable_to_exit_fullscreen": "", @@ -450,11 +467,11 @@ "every_night_at_midnight": "", "every_night_at_twoam": "", "every_six_hours": "", - "exit_slideshow": "", + "exit_slideshow": "Iziet no slīdrādes", "expand_all": "", "expire_after": "Derīguma termiņš beidzas pēc", "expired": "Derīguma termiņš beidzās", - "explore": "", + "explore": "Izpētīt", "extension": "", "external_libraries": "", "failed_to_get_people": "", @@ -471,6 +488,7 @@ "filetype": "", "filter_people": "", "fix_incorrect_match": "", + "folders": "Mapes", "force_re-scan_library_files": "", "forward": "", "general": "", @@ -480,7 +498,7 @@ "go_to_search": "", "go_to_share_page": "", "group_albums_by": "", - "has_quota": "", + "has_quota": "Ir kvota", "hide_gallery": "", "hide_password": "", "hide_person": "", @@ -491,7 +509,7 @@ "immich_logo": "", "import_path": "", "in_archive": "", - "include_archived": "Iekļaut Arhivētos", + "include_archived": "Iekļaut arhivētos", "include_shared_albums": "", "include_shared_partner_assets": "", "individual_share": "", @@ -537,8 +555,9 @@ "manage_your_api_keys": "", "manage_your_devices": "", "manage_your_oauth_connection": "", - "map": "", - "map_marker_with_image": "", + "map": "Karte", + "map_marker_for_images": "Kartes marķieris attēliem, kas uzņemti {city}, {country}", + "map_marker_with_image": "Kartes marķieris ar attēlu", "map_settings": "Kartes Iestatījumi", "media_type": "", "memories": "", @@ -559,9 +578,9 @@ "name_or_nickname": "", "never": "nekad", "new_api_key": "", - "new_password": "Jauna Parole", + "new_password": "Jaunā parole", "new_person": "", - "new_user_created": "", + "new_user_created": "Izveidots jauns lietotājs", "newest_first": "", "next": "Nākošais", "next_memory": "", @@ -569,6 +588,7 @@ "no_albums_message": "", "no_archived_assets_message": "", "no_assets_message": "", + "no_duplicates_found": "Dublikāti netika atrasti.", "no_exif_info_available": "", "no_explore_results_message": "", "no_favorites_message": "", @@ -587,8 +607,9 @@ "ok": "", "oldest_first": "", "online": "", - "only_favorites": "", + "only_favorites": "Tikai izlase", "only_refreshes_modified_files": "", + "open_in_openstreetmap": "Atvērt OpenStreetMap", "open_the_search_filters": "", "options": "Iestatījumi", "organize_your_library": "", @@ -658,14 +679,17 @@ "repair_no_results_message": "", "replace_with_upload": "", "require_password": "", + "require_user_to_change_password_on_first_login": "Pieprasīt lietotājam mainīt paroli pēc pirmās pieteikšanās", "reset": "", "reset_password": "", "reset_people_visibility": "", "reset_settings_to_default": "", + "resolve_duplicates": "Atrisināt dublēšanās gadījumus", + "resolved_all_duplicates": "Visi dublikāti ir atrisināti", "restore": "Atjaunot", "restore_user": "", "retry_upload": "", - "review_duplicates": "", + "review_duplicates": "Pārskatīt dublikātus", "role": "", "save": "Saglabāt", "saved_profile": "", @@ -691,8 +715,9 @@ "search_your_photos": "Meklēt Jūsu fotoattēlus", "searching_locales": "", "second": "", - "select_album_cover": "", + "select_album_cover": "Izvēlieties albuma vāciņu", "select_all": "", + "select_all_duplicates": "Atlasīt visus dublikātus", "select_avatar_color": "", "select_face": "", "select_featured_photo": "", @@ -702,7 +727,8 @@ "selected": "", "send_message": "", "server": "", - "server_stats": "", + "server_online": "Serveris tiešsaistē", + "server_stats": "Servera statistika", "set": "", "set_as_album_cover": "", "set_as_profile_picture": "", @@ -715,7 +741,7 @@ "shared": "Kopīgots", "shared_by": "", "shared_by_you": "", - "shared_links": "Kopīgotas Saites", + "shared_links": "Kopīgotās saites", "sharing": "Kopīgošana", "sharing_sidebar_description": "", "show_album_options": "", @@ -735,8 +761,8 @@ "sign_up": "", "size": "", "skip_to_content": "", - "slideshow": "", - "slideshow_settings": "", + "slideshow": "Slīdrāde", + "slideshow_settings": "Slīdrādes iestatījumi", "sort_albums_by": "", "stack": "Steks", "stack_selected_photos": "", @@ -746,7 +772,7 @@ "status": "", "stop_motion_photo": "", "stop_photo_sharing": "Beigt kopīgot jūsu fotogrāfijas?", - "storage": "", + "storage": "Uzglabāšanas vieta", "storage_label": "", "submit": "", "suggestions": "Ieteikumi", @@ -762,7 +788,7 @@ "toggle_settings": "", "toggle_theme": "", "toggle_visibility": "", - "total_usage": "", + "total_usage": "Kopējais lietojums", "trash": "Atkritne", "trash_all": "", "trash_no_results_message": "", @@ -774,6 +800,7 @@ "unknown": "", "unknown_album": "", "unknown_year": "", + "unlimited": "Neierobežots", "unlink_oauth": "", "unlinked_oauth_account": "", "unselect_all": "", @@ -782,16 +809,17 @@ "updated_password": "", "upload": "Augšupielādēt", "upload_concurrency": "", + "upload_status_duplicates": "Dublikāti", "upload_status_errors": "Kļūdas", "upload_status_uploaded": "Augšupielādēts", "url": "", - "usage": "", + "usage": "Lietojums", "user": "Lietotājs", "user_id": "Lietotāja ID", - "user_usage_detail": "", + "user_usage_detail": "Informācija par lietotāju lietojumu", "username": "", "users": "Lietotāji", - "utilities": "", + "utilities": "Rīki", "validate": "", "variables": "", "version": "Versija", @@ -804,7 +832,7 @@ "view_next_asset": "", "view_previous_asset": "", "viewer": "", - "waiting": "", + "waiting": "Gaida", "week": "", "welcome_to_immich": "", "year": "", diff --git a/web/src/lib/i18n/nb_NO.json b/web/src/lib/i18n/nb_NO.json index 1c0e2f5eef116..be2ae2638ea0c 100644 --- a/web/src/lib/i18n/nb_NO.json +++ b/web/src/lib/i18n/nb_NO.json @@ -138,6 +138,8 @@ "map_style_description": "URL til et style.json-karttema", "metadata_extraction_job": "Hent metadata", "metadata_extraction_job_description": "Hent metadatainformasjon fra hver fil, for eksempel GPS-posisjon og oppløsning", + "metadata_settings": "Metadatainnstillinger", + "metadata_settings_description": "Administrer metadatainnstillinger", "migration_job": "Migrering", "migration_job_description": "Migrer miniatyrbilder for filer og ansikter til den nyeste mappestrukturen", "no_paths_added": "Ingen filbaner lagt til", @@ -384,6 +386,7 @@ "asset_offline": "Fil utilgjengelig", "asset_offline_description": "Dette elementet er offline. Immich kan ikke aksessere dets lokasjon. Vennlist påse at elementet er tilgijengelig og skann så biblioteket på nytt.", "asset_skipped": "Hoppet over", + "asset_skipped_in_trash": "I søppelbøtten", "asset_uploaded": "Lastet opp", "asset_uploading": "Laster opp...", "assets": "Filer", diff --git a/web/src/lib/i18n/nl.json b/web/src/lib/i18n/nl.json index 786a1627febbb..dc9f003978033 100644 --- a/web/src/lib/i18n/nl.json +++ b/web/src/lib/i18n/nl.json @@ -391,6 +391,7 @@ "asset_offline": "Asset offline", "asset_offline_description": "Deze asset is offline. Immich kan de bestandslocatie niet openen. Controleer of de asset beschikbaar is en scan de bibliotheek opnieuw.", "asset_skipped": "Overgeslagen", + "asset_skipped_in_trash": "In prullenbak", "asset_uploaded": "Geüpload", "asset_uploading": "Uploaden...", "assets": "Assets", @@ -1135,6 +1136,7 @@ "search_for_existing_person": "Zoek naar bestaande persoon", "search_no_people": "Geen mensen", "search_no_people_named": "Geen mensen genaamd \"{name}\"", + "search_options": "Zoekopties", "search_people": "Zoek mensen", "search_places": "Zoek plaatsen", "search_state": "Zoek staat...", diff --git a/web/src/lib/i18n/ro.json b/web/src/lib/i18n/ro.json index 29acdd03ce225..02022569cd12a 100644 --- a/web/src/lib/i18n/ro.json +++ b/web/src/lib/i18n/ro.json @@ -235,95 +235,127 @@ "storage_template_onboarding_description": "Atunci când este activată, această caracteristică va organiza automat fișierele pe baza unui șablon definit de utilizator. Din cauza unor probleme de stabilitate, aceasta caracteristică este dezactivată implicit. Pentru mai multe informații, te rog sa consulți documentația.", "storage_template_path_length": "Limita de lungime pentru calea aproximativă: {length, number}/{limit, number}", "storage_template_settings": "Șablon stocare", - "storage_template_settings_description": "", + "storage_template_settings_description": "Gestionează structura folderelor și numele fișierelor pentru activele încărcate", + "storage_template_user_label": "{label} este eticheta de stocare a utilizatorului", "system_settings": "Setǎri de sistem", "theme_custom_css_settings": "CSS personalizat", - "theme_custom_css_settings_description": "", - "theme_settings": "", - "theme_settings_description": "", - "thumbnail_generation_job_description": "", + "theme_custom_css_settings_description": "Foile de stil în cascadă (CSS) permit personalizarea designului Immich.", + "theme_settings": "Setări temă", + "theme_settings_description": "Gestionează personalizarea interfeței web Immich", + "these_files_matched_by_checksum": "Aceste fișiere sunt comparate folosind sumele de control", + "thumbnail_generation_job": "Gerează miniaturi", + "thumbnail_generation_job_description": "Generează miniaturi mari, mici și estompate pentru fiecare resursă, precum și miniaturi pentru fiecare persoană", "transcode_policy_description": "", - "transcoding_acceleration_api": "", - "transcoding_acceleration_api_description": "", + "transcoding_acceleration_api": "API de accelerare", + "transcoding_acceleration_api_description": "API-ul care va interacționa cu dispozitivul tău pentru a accelera transcodarea. Această setare este 'best effort': va reveni la transcodarea software în caz de eșec. VP9 poate funcționa sau nu, în funcție de hardware-ul tău.", "transcoding_acceleration_nvenc": "NVENC (necesitǎ GPU NVIDIA)", "transcoding_acceleration_qsv": "Quick Sync (necesitǎ CPU Intel de generația a 7-a sau mai mare)", "transcoding_acceleration_rkmpp": "RKMPP (doar pe SOC-uri Rockchip)", "transcoding_acceleration_vaapi": "VAAPI", "transcoding_accepted_audio_codecs": "Codec-uri audio acceptate", - "transcoding_accepted_audio_codecs_description": "", + "transcoding_accepted_audio_codecs_description": "Selectează care codec-uri audio nu trebuie să fie transcodificate. Se utilizează doar pentru anumite politici de transcodare.", + "transcoding_accepted_containers": "Containere acceptate", + "transcoding_accepted_containers_description": "Selectează formatele de containere care nu trebuie să fie remuxate în MP4. Se utilizează doar pentru anumite politici de transcodare.", "transcoding_accepted_video_codecs": "Codec-uri video acceptate", - "transcoding_accepted_video_codecs_description": "", - "transcoding_advanced_options_description": "", + "transcoding_accepted_video_codecs_description": "Selectează codec-urile video care nu trebuie să fie transcodificate. Se utilizează doar pentru anumite politici de transcodare.", + "transcoding_advanced_options_description": "Opțiuni pe care majoritatea utilizatorilor nu ar trebui să fie necesar să le schimbe", "transcoding_audio_codec": "Codec audio", - "transcoding_audio_codec_description": "", - "transcoding_bitrate_description": "", - "transcoding_constant_quality_mode": "", - "transcoding_constant_quality_mode_description": "", - "transcoding_constant_rate_factor": "", - "transcoding_constant_rate_factor_description": "", - "transcoding_disabled_description": "", - "transcoding_hardware_acceleration": "", - "transcoding_hardware_acceleration_description": "", - "transcoding_hardware_decoding": "", - "transcoding_hardware_decoding_setting_description": "", - "transcoding_hevc_codec": "", + "transcoding_audio_codec_description": "Opus este opțiunea cu cea mai bună calitate, dar are o compatibilitate mai scăzută cu dispozitivele sau software-ul mai vechi.", + "transcoding_bitrate_description": "Videoclipuri cu un bitrate mai mare decât maximul acceptat sau care nu sunt într-un format acceptat", + "transcoding_codecs_learn_more": "Pentru a afla mai multe despre terminologia folosită aici, consultă documentația FFmpeg pentru codec-ul H.264, codec-ul HEVC și codec-ul VP9.", + "transcoding_constant_quality_mode": "Mod de calitate constantă", + "transcoding_constant_quality_mode_description": "ICQ este mai bun decât CQP, dar unele dispozitive de accelerare hardware nu suportă acest mod. Setarea acestei opțiuni va prefera modul specificat atunci când folosești codificarea bazată pe calitate. Ignorat de NVENC deoarece nu suportă ICQ.", + "transcoding_constant_rate_factor": "Factor de rată constantă (-crf)", + "transcoding_constant_rate_factor_description": "Nivelul de calitate al videoclipului. Valorile tipice sunt 23 pentru H.264, 28 pentru HEVC, 31 pentru VP9 și 35 pentru AV1. Cu cât valoarea este mai mică, cu atât calitatea este mai bună, dar se generează fișiere mai mari.", + "transcoding_disabled_description": "Nu transcodifică niciun videoclip; acest lucru poate afecta redarea pe anumite dispozitive", + "transcoding_hardware_acceleration": "Accelerare Hardware", + "transcoding_hardware_acceleration_description": "Experimental; mult mai rapid, dar va avea o calitate mai scăzută la același bitrate", + "transcoding_hardware_decoding": "Decodare hardware", + "transcoding_hardware_decoding_setting_description": "Se aplică doar pentru NVENC, QSV și RKMPP. Activează accelerarea completă în loc de doar accelerarea codificării. S-ar putea să nu funcționeze pentru toate videoclipurile.", + "transcoding_hevc_codec": "codec HEVC", "transcoding_max_b_frames": "", - "transcoding_max_b_frames_description": "", - "transcoding_max_bitrate": "", - "transcoding_max_bitrate_description": "", - "transcoding_max_keyframe_interval": "", - "transcoding_max_keyframe_interval_description": "", - "transcoding_optimal_description": "", - "transcoding_preferred_hardware_device": "", - "transcoding_preferred_hardware_device_description": "", - "transcoding_preset_preset": "", - "transcoding_preset_preset_description": "", - "transcoding_reference_frames": "", - "transcoding_reference_frames_description": "", - "transcoding_required_description": "", - "transcoding_settings": "", - "transcoding_settings_description": "", - "transcoding_target_resolution": "", - "transcoding_target_resolution_description": "", - "transcoding_temporal_aq": "", - "transcoding_temporal_aq_description": "", - "transcoding_threads": "", - "transcoding_threads_description": "", - "transcoding_tone_mapping": "", - "transcoding_tone_mapping_description": "", - "transcoding_tone_mapping_npl": "", - "transcoding_tone_mapping_npl_description": "", - "transcoding_transcode_policy": "", - "transcoding_two_pass_encoding": "", - "transcoding_two_pass_encoding_setting_description": "", + "transcoding_max_b_frames_description": "Valorile mai mari îmbunătățesc eficiența compresiei, dar încetinesc codarea. Este posibil să nu fie compatibile cu accelerarea hardware pe dispozitivele mai vechi. 0 dezactivează cadrele B, în timp ce -1 setează această valoare automat.", + "transcoding_max_bitrate": "Bitrate maxim", + "transcoding_max_bitrate_description": "Setarea unei rate maxime de biți poate face dimensiunile fișierelor mai previzibile, cu un cost minor asupra calității. La 720p, valorile tipice sunt 2600k pentru VP9 sau HEVC, sau 4500k pentru H.264. Dezactivat dacă este setat la 0.", + "transcoding_max_keyframe_interval": "Interval maxim între cadre cheie", + "transcoding_max_keyframe_interval_description": "Setează distanța maximă între cadrele cheie. Valorile mai mici reduc eficiența compresiei, dar îmbunătățesc timpii de căutare și pot îmbunătăți calitatea în scenele cu mișcare rapidă. 0 setează această valoare automat.", + "transcoding_optimal_description": "Videoclipuri cu rezoluție mai mare decât cea țintă sau care nu sunt într-un format acceptat", + "transcoding_preferred_hardware_device": "Dispozitiv hardware preferat", + "transcoding_preferred_hardware_device_description": "Se aplică doar la VAAPI și QSV. Setează nodul DRI utilizat pentru transcodarea hardware.", + "transcoding_preset_preset": "Presetare (-preset)", + "transcoding_preset_preset_description": "Viteza de compresie. Presetările mai lente produc fișiere mai mici și îmbunătățesc calitatea atunci când vizezi o anumită rată de biți. VP9 ignoră vitezele de compresie mai mari decât 'mai rapid'.", + "transcoding_reference_frames": "Cadre de referință", + "transcoding_reference_frames_description": "Numărul de cadre de referință atunci când se comprimă un cadru dat. Valorile mai mari îmbunătățesc eficiența compresiei, dar încetinesc codarea. 0 setează această valoare automat.", + "transcoding_required_description": "Numai videoclipuri care nu sunt într-un format acceptat", + "transcoding_settings": "Setări de transcodare video", + "transcoding_settings_description": "Gestionează rezoluția și informațiile de codare ale fișierelor video", + "transcoding_target_resolution": "Rezoluția țintă", + "transcoding_target_resolution_description": "Rezoluțiile mai mari pot păstra mai multe detalii, dar necesită mai mult timp pentru codare, au dimensiuni mai mari ale fișierelor și pot reduce răspunsul aplicației.", + "transcoding_temporal_aq": "AQ temporal", + "transcoding_temporal_aq_description": "Se aplică doar la NVENC. Îmbunătățește calitatea scenelor cu detalii mari și mișcare redusă. Poate să nu fie compatibil cu dispozitivele mai vechi.", + "transcoding_threads": "Fire", + "transcoding_threads_description": "Valorile mai mari conduc la o codare mai rapidă, dar lasă mai puțin spațiu serverului pentru a procesa alte sarcini în timp ce este activ. Această valoare nu ar trebui să fie mai mare decât numărul de nuclee CPU. Maximizați utilizarea dacă este setat la 0.", + "transcoding_tone_mapping": "Mapare tonuri", + "transcoding_tone_mapping_description": "Încearcă să păstreze aspectul videoclipurilor HDR atunci când sunt convertite în SDR. Fiecare algoritm face compromisuri diferite pentru culoare, detalii și strălucire. Hable păstrează detaliile, Mobius păstrează culoarea, iar Reinhard păstrează strălucirea.", + "transcoding_tone_mapping_npl": "Mapare tonuri NPL", + "transcoding_tone_mapping_npl_description": "Culorile vor fi ajustate pentru a arăta normal pe un ecran cu această strălucire. În mod contraintuitiv, valorile mai mici cresc strălucirea videoclipului și invers, deoarece compensează pentru strălucirea ecranului. 0 setează această valoare automat.", + "transcoding_transcode_policy": "Politica de transcodare", + "transcoding_transcode_policy_description": "Politica pentru când un videoclip ar trebui să fie transcodificat. Videoclipurile HDR vor fi întotdeauna transcodificate (cu excepția cazului în care transcodarea este dezactivată).", + "transcoding_two_pass_encoding": "Codare în două treceri", + "transcoding_two_pass_encoding_setting_description": "Transcodificare în două treceri pentru a produce videoclipuri codificate mai bine. Când rata maximă de biți este activată (necesară pentru a funcționa cu H.264 și HEVC), acest mod utilizează un interval de rată de biți bazat pe rata maximă de biți și ignoră CRF. Pentru VP9, CRF poate fi utilizat dacă rata maximă de biți este dezactivată.", "transcoding_video_codec": "Codec video", "transcoding_video_codec_description": "VP9 are eficiențǎ mare și compatibilitate web, însǎ transcodarea este de duratǎ mai mare. HEVC se comportǎ asemǎnǎtor, însǎ are compatibilitate web mai micǎ. H.264 este foarte compatibil și rapid în transcodare, însǎ genereazǎ fișiere mult mai mari. AV1 este cel mai eficient codec dar nu este compatibil cu dispozitivele mai vechi.", - "trash_enabled_description": "", + "trash_enabled_description": "Activează funcțiile Coș de gunoi", "trash_number_of_days": "Numǎr de zile", "trash_number_of_days_description": "Numǎr de zile pentru pǎstrarea fișierelor în coșul de gunoi pânǎ la ștergerea permanentǎ", "trash_settings": "Setǎri coș de gunoi", "trash_settings_description": "Gestioneazǎ setǎrile coșului de gunoi", - "user_delete_delay_settings": "", - "user_delete_delay_settings_description": "", + "untracked_files": "Fișiere neurmărite", + "untracked_files_description": "Aceste fișiere nu sunt urmărite de aplicație. Ele pot fi rezultatul unor mutări eșuate, încărcări întrerupte sau pot rămâne în urmă din cauza unei erori", + "user_delete_delay": "Contul și resursele utilizatorului {user} vor fi programate pentru ștergere permanentă în {delay, plural, one {# zi} other {# zile}}.", + "user_delete_delay_settings": "Întârziere la ștergere", + "user_delete_delay_settings_description": "Numărul de zile după eliminare până la ștergerea permanentă a contului și a resurselor unui utilizator. Procesul de ștergere a utilizatorului rulează la miezul nopții pentru a verifica utilizatorii care sunt pregătiți pentru ștergere. Modificările aduse acestei setări vor fi evaluate la următoarea execuție.", + "user_delete_immediately": "Contul și resursele utilizatorului {user} vor fi puse în coadă pentru ștergere permanentă imediat.", + "user_delete_immediately_checkbox": "Pune utilizatorul și resursele în coadă pentru ștergere imediată", + "user_management": "Gestionarea Utilizatorilor", + "user_password_has_been_reset": "Parola utilizatorului a fost resetată:", + "user_password_reset_description": "Vă rugăm să furnizați utilizatorului parola temporară și să îi informați că va trebui să o schimbe la următoarea autentificare.", + "user_restore_description": "Contul utilizatorului {user} va fi restaurat.", + "user_restore_scheduled_removal": "Restaurare utilizator - ștergere programată pe {date, date, long}", "user_settings": "Setǎri utilizator", "user_settings_description": "Gestioneazǎ setǎrile utilizatorului", - "version_check_enabled_description": "Activeazǎ verificarea periodicǎ pe GitHub pentru versiuni noi", + "user_successfully_removed": "Utilizatorul {email} a fost eliminat cu succes.", + "version_check_enabled_description": "Activează verificarea versiunii", + "version_check_implications": "Funcția de verificare a versiunii se bazează pe comunicarea periodică cu github.com", "version_check_settings": "Verificare versiune", "version_check_settings_description": "Activeazǎ/dezactiveazǎ notificarea unei noi versiuni", - "video_conversion_job_description": "Transcodeazǎ videoclipurile pentru compatibilitate cu browsere și dispozitive" + "video_conversion_job": "Transcodați videoclipuri", + "video_conversion_job_description": "Transcodați videoclipurile pentru o compatibilitate mai mare cu browserele și dispozitivele" }, "admin_email": "E-mailul administratorului", - "admin_password": "Parola administratorului", + "admin_password": "Parolă administrator", "administration": "Administrare", "advanced": "Avansat", + "age_months": "Vârstă {months, plural, one {# lună} other {# luni}}", + "age_year_months": "Vârstă de 1 an, {months, plural, one {# lună} other {# luni}}", "album_added": "Album adăugat", "album_added_notification_setting_description": "Primiți o notificare prin e-mail când sunteți adăugat la un album partajat", "album_cover_updated": "Coperta albumului a fost actualizată", - "album_info_updated": "Informațiile albumului au fost actualizate", - "album_name": "Nume de album", - "album_options": "Opțiuni de album", + "album_delete_confirmation": "Ești sigur că vrei să ștergi albumul {album}?", + "album_delete_confirmation_description": "Dacă acest album este partajat, alți utilizatori nu vor mai putea accesa.", + "album_info_updated": "Informații album actualizate", + "album_leave": "Lăsați albumul?", + "album_leave_confirmation": "Ești sigur că dorești să părăsești {album}?", + "album_name": "Nume album", + "album_options": "Opțiuni album", + "album_remove_user": "Eliminare utilizator?", + "album_remove_user_confirmation": "Ești sigur că dorești eliminarea {user}?", + "album_share_no_users": "Se pare că ai partajat acest album cu toți utilizatorii sau nu ai niciun utilizator cu care să-l partajezi.", "album_updated": "Album actualizat", "album_updated_setting_description": "Primiți o notificare prin e-mail când un album partajat are elemente noi", + "album_user_left": "A părăsit {album}", + "album_user_removed": "{user} eliminat", + "album_with_link_access": "Permite oricui cu link-ul să vadă fotografiile și persoanele din acest album.", "albums": "Albume", "albums_count": "{count, plural, one {{count, number} Album} other {{count, number} Albume}}", "all": "Toate", @@ -334,40 +366,58 @@ "allow_edits": "Permite editări", "allow_public_user_to_download": "Permite utilizatorului public să descarce", "allow_public_user_to_upload": "Permite utilizatorului public să încarce", + "anti_clockwise": "În sens invers acelor de ceasornic", "api_key": "Cheie API", "api_key_description": "Această valoare va fi afișată o singură dată. Vă rugăm să vă asigurați că o copiați înainte de a închide fereastra.", "api_key_empty": "Numele cheii API nu trebuie să fie gol", "api_keys": "Chei API", - "app_settings": "Setări în aplicație", + "app_settings": "Setări Aplicație", "appears_in": "Apare în", "archive": "Arhivă", "archive_or_unarchive_photo": "Arhiveazǎ sau dezarhiveazǎ fotografia", + "archive_size": "Mărime arhivă", + "archive_size_description": "Configurează dimensiunea arhivei pentru descărcări (în GiB)", "archived": "", - "archived_count": "{count, plural, one {S-a arhivat #}, other {S-au arhivat #}}", + "archived_count": "{count, plural, other {Arhivat/e#}}", + "are_these_the_same_person": "Sunt aceștia aceeași persoană?", "are_you_sure_to_do_this": "Sunteți sigur că doriți să faceți acest lucru?", "asset_added_to_album": "Adăugat la album", - "asset_adding_to_album": "Se adauga la album...", + "asset_adding_to_album": "Se adaugă la album...", "asset_description_updated": "Descrierea activelor a fost actualizată", - "asset_filename_is_offline": "Activul {filename} este offline", - "asset_has_unassigned_faces": "Activul are fețe neatribuite", - "asset_hashing": "Hasurare...", + "asset_filename_is_offline": "Resursa {filename} este offline", + "asset_has_unassigned_faces": "Resursa are fețe neatribuite", + "asset_hashing": "Hașurare...", "asset_offline": "Resursă offline", - "asset_offline_description": "Acest activ este offline. Immich nu poate accesa locația fișierului său. Vă rugăm să vă asigurați că activul este disponibil și apoi să efectuați o nouă scanare a bibliotecii.", + "asset_offline_description": "Această resursă este offline. Immich nu poate accesa locația fișierului său. Vă rugăm să vă asigurați că resursa este disponibilă și apoi să efectuați o nouă scanare a bibliotecii.", "asset_skipped": "Sărit", + "asset_skipped_in_trash": "În gunoi", "asset_uploaded": "Încărcat", - "asset_uploading": "Se incărca...", + "asset_uploading": "Se incarcă...", "assets": "Resurse", + "assets_added_count": "Adăugat {count, plural, one {# resursă} other {# resurse}}", + "assets_added_to_album_count": "Am adăugat {count, plural, one {# resursă} other {# resurse}} în album", + "assets_added_to_name_count": "Am adăugat {count, plural, one {# resursă} other {# resurse}} în {hasName, select, true {{name}} other {albumul nou}}", + "assets_count": "{count, plural, one {# resursă} other {# resurse}}", + "assets_moved_to_trash_count": "Am mutat {count, plural, one {# resursă} other {# resurse}} în coșul de gunoi", + "assets_permanently_deleted_count": "Șters permanent {count, plural, one {# resursă} other {# resurse}}", + "assets_removed_count": "Eliminat {count, plural, one {# resursă} other {# resurse}}", + "assets_restore_confirmation": "Ești sigur că vrei să restaurezi toate resursele tale din coșul de gunoi? Nu poți anula această acțiune!", + "assets_restored_count": "Restaurat {count, plural, one {# resursă} other {# resurse}}", + "assets_trashed_count": "Mutat în coșul de gunoi {count, plural, one {# resursă} other {# resurse}}", + "assets_were_part_of_album_count": "{count, plural, one {Resursa era} other {Resursele erau}} deja parte din album", "authorized_devices": "Dispozitive autorizate", "back": "Înapoi", "back_close_deselect": "Înapoi, închidere sau deselectare", - "backward": "Invers", + "backward": "În sens invers", "birthdate_saved": "Data nașterii salvată cu succes", "birthdate_set_description": "Data nașterii este utilizată pentru a calcula vârsta acestei persoane la momentul realizării fotografiei.", "blurred_background": "Fundal neclar", "build": "Construiți", - "build_image": "Construiți o imagine", - "bulk_delete_duplicates_confirmation": "Sunteți sigur că doriți să ștergeți în masă {count, plural, one {# duplicate asset} other {# duplicate assets}}? Acest lucru va păstra cel mai mare activ din fiecare grup și va șterge definitiv toate celelalte duplicate. Nu puteți anula această acțiune!", - "buy": "Cumpără Immich", + "build_image": "Construiți imagine", + "bulk_delete_duplicates_confirmation": "Ești sigur că vrei să ștergi în masă {count, plural, one {# resursă duplicată} other {# resurse duplicate}}? Aceasta va păstra cea mai mare resursă din fiecare grup și va șterge permanent toate celelalte duplicate. Nu poți anula această acțiune!", + "bulk_keep_duplicates_confirmation": "Ești sigur că vrei să păstrezi {count, plural, one {# resursă duplicată} other {# resurse duplicate}}? Aceasta va rezolva toate grupurile duplicate fără a șterge nimic.", + "bulk_trash_duplicates_confirmation": "Ești sigur că vrei să muți în coșul de gunoi {count, plural, one {# resursă duplicată} other {# resurse duplicate}}? Aceasta va păstra cea mai mare resursă din fiecare grup și va muta în coșul de gunoi toate celelalte duplicate.", + "buy": "Achiziționează Immich", "camera": "Camerǎ", "camera_brand": "Marcǎ cameră", "camera_model": "Model cameră", @@ -381,37 +431,41 @@ "cant_search_people": "", "cant_search_places": "", "change_date": "Schimbă dată", - "change_expiration_time": "Shimbă data expirării", + "change_expiration_time": "Shimbă dată expirare", "change_location": "Schimbă locația", - "change_name": "Schimbă numele", - "change_name_successfully": "Schimbă numele cu succes", - "change_password": "Schimbă parola", - "change_password_description": "Aceasta este fie prima dată când vă conectați la sistem, fie vi s-a solicitat să vă schimbați parola. Vă rugăm să introduceți noua parolă mai jos.", + "change_name": "Schimbă nume", + "change_name_successfully": "Schimbare nume cu succes", + "change_password": "Schimbă Parolă", + "change_password_description": "Aceasta este fie prima dată când te conectezi în sistem, fie s-a făcut o solicitare pentru a schimba parola ta. Te rog să introduci noua parolă mai jos.", "change_your_password": "Schimbă-ți parola", - "changed_visibility_successfully": "Schimbă visibilitate cu succes", - "check_logs": "Verificarea logurilor", - "choose_matching_people_to_merge": "Alegeți persoanele potrivite pentru fuzionare", + "changed_visibility_successfully": "Schimbare vizibilitate cu succes", + "check_all": "Selectează Tot", + "check_logs": "Verifică Jurnale", + "choose_matching_people_to_merge": "Alegeți persoanele care se potrivesc pentru a le fuziona", "city": "Oraș", - "clear": "ȘTERGE", - "clear_all": "Șterge tot", + "clear": "Curăță", + "clear_all": "Curăță tot", + "clear_all_recent_searches": "Curăță toate căutările recente", "clear_message": "Șterge mesajul", - "clear_value": "Valoare clară", + "clear_value": "Șterge valoare", + "clockwise": "În sensul acelor de ceas", "close": "Închide", - "collapse": "Colaps", - "collapse_all": "Închideți pe toate", + "collapse": "Restrânge", + "collapse_all": "Restrânge pe toate", + "color": "Culoare", "color_theme": "Tema de culoare", "comment_deleted": "Comentariu șters", - "comment_options": "Opțiuni de comentariu", - "comments_and_likes": "Comentarii și aprecieri", + "comment_options": "Opțiuni comentariu", + "comments_and_likes": "Comentarii & aprecieri", "comments_are_disabled": "Comentariile sunt dezactivate", "confirm": "Confirmați", "confirm_admin_password": "Confirmați parola de administrator", "confirm_delete_shared_link": "Sunteți sigur că doriți să ștergeți acest link partajat?", "confirm_password": "Confirmați parola", - "contain": "Conține", + "contain": "Încadrează", "context": "Context", "continue": "Continuați", - "copied_image_to_clipboard": "Copiat imaginea în clipboard.", + "copied_image_to_clipboard": "Imaginea copiată în clipboard.", "copied_to_clipboard": "Copiat în clipboard!", "copy_error": "Eroare de copiere", "copy_file_path": "Copiați calea fișierului", @@ -421,64 +475,70 @@ "copy_password": "Copiați parola", "copy_to_clipboard": "Copiere în Clipboard", "country": "Țara", - "cover": "Acoperire", - "covers": "Acoperiri", + "cover": "Umple fereastra", + "covers": "Acoperă", "create": "Creează", "create_album": "Creează album", - "create_library": "Crearea bibliotecii", + "create_library": "Creează bibliotecă", "create_link": "Creează link", "create_link_to_share": "Creează link pentru a distribui", "create_link_to_share_description": "Permiteți oricui are link-ul să vadă fotografia (fotografiile) selectată(e)", "create_new_person": "Creați o persoană nouă", - "create_new_person_hint": "Atribuiți activele selectate unei persoane noi", - "create_new_user": "Crearea unui nou utilizator", + "create_new_person_hint": "Atribuiți resursele selectate unei persoane noi", + "create_new_user": "Creează utilizator nou", + "create_tag": "Creează etichetă", + "create_tag_description": "Creează o etichetă nouă. Pentru etichete imbricate, te rog să introduci calea completă a etichetei, inclusiv bare oblice (/).", "create_user": "Creează utilizator", "created": "Creat", "current_device": "Dispozitiv curent", - "custom_locale": "Local personalizat", + "custom_locale": "Setare regională personalizată", "custom_locale_description": "Formatați datele și numerele în funcție de limbă și regiune", - "dark": "Întuneric", - "date_after": "Data după", + "dark": "Întunecat", + "date_after": "Dată după", "date_and_time": "Dată și Oră", - "date_before": "Data anterioară", + "date_before": "Dată anterioară", "date_of_birth_saved": "Data nașterii salvată cu succes", "date_range": "Interval de date", - "day": "Ziua", + "day": "Zi", "deduplicate_all": "Deduplicați toate", - "default_locale": "Local implicit", - "default_locale_description": "Formatați datele și numerele în funcție de locația browserului dvs.", + "default_locale": "Setare regionlă implicită", + "default_locale_description": "Formatați datele și numerele în funcție de regiunea browserului dvs", "delete": "Șterge", "delete_album": "Șterge album", "delete_api_key_prompt": "Sunteți sigur că doriți să ștergeți această cheie API?", "delete_duplicates_confirmation": "Sunteți sigur că doriți să ștergeți permanent aceste duplicate?", - "delete_key": "Tasta de ștergere", - "delete_library": "Ștergeți biblioteca", - "delete_link": "Ștergeți linkul", - "delete_shared_link": "Ștergeți link-ul partajat", - "delete_user": "Ștergeți utilizatorul", + "delete_key": "Șterge cheie", + "delete_library": "Șterge biblioteca", + "delete_link": "Șterge linkul", + "delete_shared_link": "Șterge link-ul partajat", + "delete_tag": "Șterge etichetă", + "delete_tag_confirmation_prompt": "Ești sigur că vrei să ștergi eticheta {tagName} ?", + "delete_user": "Șterge utilizator", "deleted_shared_link": "Link partajat șters", "description": "Descriere", - "details": "DETALII", + "details": "Detalii", "direction": "Direcție", "disabled": "Dezactivat", - "disallow_edits": "Interziceți editările", + "disallow_edits": "Interzice modificările", "discover": "Descoperiți", - "dismiss_all_errors": "Eliminați toate erorile", - "dismiss_error": "Anulați eroarea", + "dismiss_all_errors": "Ignoră toate erorile", + "dismiss_error": "Ignorați eroarea", "display_options": "Opțiuni de afișare", "display_order": "Ordine de afișare", "display_original_photos": "Afișați fotografiile originale", - "display_original_photos_setting_description": "Preferați să afișați fotografia originală atunci când vizualizați un bun în loc de miniaturi atunci când bunul original este compatibil cu web. Acest lucru poate duce la o viteză mai mică de afișare a fotografiilor.", - "do_not_show_again": "Nu mai afișați acest mesaj", + "display_original_photos_setting_description": "Preferă să afișezi fotografia originală atunci când vizualizezi o resursă, în loc de miniaturi, atunci când resursa originală este compatibilă cu web-ul. Aceasta poate duce la viteze mai lente de afișare a fotografiilor.", + "do_not_show_again": "Nu mai afișa acest mesaj", "done": "Gata", "download": "Descarcă", + "download_include_embedded_motion_videos": "Videoclipuri încorporate", + "download_include_embedded_motion_videos_description": "Include videoclipurile încorporate în fotografiile în mișcare ca fișier separat", "download_settings": "Descarcă", - "download_settings_description": "Gestionați setările legate de descărcarea activelor", - "downloading": "Descărcare", - "downloading_asset_filename": "Descărcarea activului {filename}", - "drop_files_to_upload": "Aruncați fișiere oriunde pentru a le încărca", + "download_settings_description": "Gestionați setările legate de descărcarea resurselor", + "downloading": "Se descarcă", + "downloading_asset_filename": "Se descarcă resursa {filename}", + "drop_files_to_upload": "Trage fișierele aici pentru a le încărca", "duplicates": "Duplicate", - "duplicates_description": "Rezolvați fiecare grup indicând care, dacă există, sunt duplicate", + "duplicates_description": "Rezolvați fiecare grup indicând care sunt duplicate, dacă există", "duration": "Durată", "durations": { "days": "", @@ -487,13 +547,13 @@ "months": "", "years": "" }, - "edit": "Editare", - "edit_album": "Editare album", - "edit_avatar": "Editare avatar", - "edit_date": "Editează data", - "edit_date_and_time": "Editarea datei și orei", + "edit": "Modifică", + "edit_album": "Modificare album", + "edit_avatar": "Modificare avatar", + "edit_date": "Modifică data", + "edit_date_and_time": "Modifică data și ora", "edit_exclusion_pattern": "Editarea modelului de excludere", - "edit_faces": "Editează fețele", + "edit_faces": "Modifică fețele", "edit_import_path": "Editarea căii de import", "edit_import_paths": "Editarea căilor de import", "edit_key": "Tastă de editare", diff --git a/web/src/lib/i18n/ru.json b/web/src/lib/i18n/ru.json index bd725a11cfa34..44b9e48f954f5 100644 --- a/web/src/lib/i18n/ru.json +++ b/web/src/lib/i18n/ru.json @@ -1,7 +1,7 @@ { "about": "О продукте", "account": "Учётная запись", - "account_settings": "Настройки учётной записи", + "account_settings": "Настройки аккаунта", "acknowledge": "Подтвердить", "action": "Действие", "actions": "Действия", @@ -661,6 +661,7 @@ "unable_to_get_comments_number": "Не удалось получить количество комментариев", "unable_to_get_shared_link": "Не удалось получить общую ссылку", "unable_to_hide_person": "Невозможно скрыть персону", + "unable_to_link_motion_video": "Не удается связать движущееся видео", "unable_to_link_oauth_account": "Не удается связать учетную запись OAuth", "unable_to_load_album": "Невозможно загрузить альбом", "unable_to_load_asset_activity": "Не удалось загрузить активность объекта", @@ -701,6 +702,7 @@ "unable_to_submit_job": "Невозможно отправить задание", "unable_to_trash_asset": "Невозможно удалить актив", "unable_to_unlink_account": "Не удалось отсоединить учетную запись", + "unable_to_unlink_motion_video": "Не удается отсоединить движущееся видео", "unable_to_update_album_cover": "Невозможно обновить обложку альбома", "unable_to_update_album_info": "Невозможно обновить информацию об альбоме", "unable_to_update_library": "Не удалось обновить библиотеку", @@ -1291,6 +1293,7 @@ "unknown_album": "Неизвестный альбом", "unknown_year": "Неизвестный Год", "unlimited": "Не ограничено", + "unlink_motion_video": "Отсоединить движущееся видео", "unlink_oauth": "Отключить OAuth", "unlinked_oauth_account": "Отключить аккаунт OAuth", "unnamed_album": "Альбом без названия", diff --git a/web/src/lib/i18n/sk.json b/web/src/lib/i18n/sk.json index d6c066a4cc840..cd164a0ccf40b 100644 --- a/web/src/lib/i18n/sk.json +++ b/web/src/lib/i18n/sk.json @@ -112,7 +112,7 @@ "map_reverse_geocoding": "", "map_reverse_geocoding_enable_description": "", "map_reverse_geocoding_settings": "", - "map_settings": "", + "map_settings": "Mapa", "map_settings_description": "", "map_style_description": "", "metadata_extraction_job_description": "", @@ -460,7 +460,7 @@ "expand_all": "", "expire_after": "Expiruje po", "expired": "Vypršalo", - "explore": "", + "explore": "Preskúmať", "extension": "", "external_libraries": "", "failed_to_get_people": "", diff --git a/web/src/lib/i18n/sr_Cyrl.json b/web/src/lib/i18n/sr_Cyrl.json index 7bcd1e3dd8634..1241ad72fe7b3 100644 --- a/web/src/lib/i18n/sr_Cyrl.json +++ b/web/src/lib/i18n/sr_Cyrl.json @@ -661,6 +661,7 @@ "unable_to_get_comments_number": "Није могуће добити број коментара", "unable_to_get_shared_link": "Преузимање дељене везе није успело", "unable_to_hide_person": "Није могуће сакрити особу", + "unable_to_link_motion_video": "Није могуће повезати (link) видео снимак", "unable_to_link_oauth_account": "Није могуће повезати OAuth налог", "unable_to_load_album": "Није могуће учитати албум", "unable_to_load_asset_activity": "Није могуће учитати активност средстава", @@ -701,6 +702,7 @@ "unable_to_submit_job": "Није могуће предати задатак", "unable_to_trash_asset": "Није могуће избацити материјал у отпад", "unable_to_unlink_account": "Није могуће раскинути профил", + "unable_to_unlink_motion_video": "Није могуће прекинути везу са видео снимком", "unable_to_update_album_cover": "Није могуће ажурирати насловницу албума", "unable_to_update_album_info": "Није могуће ажурирати информације о албуму", "unable_to_update_library": "Није могуће ажурирати библиотеку", @@ -1291,6 +1293,7 @@ "unknown_album": "Nepoznat Album", "unknown_year": "Непозната Година", "unlimited": "Неограничено", + "unlink_motion_video": "Прекините везу са видео снимком", "unlink_oauth": "Прекини везу са Oauth-om", "unlinked_oauth_account": "Опозвана веза OAuth налога", "unnamed_album": "Неименовани албум", diff --git a/web/src/lib/i18n/sr_Latn.json b/web/src/lib/i18n/sr_Latn.json index beb2009b4d4a8..26f5483c69ac6 100644 --- a/web/src/lib/i18n/sr_Latn.json +++ b/web/src/lib/i18n/sr_Latn.json @@ -661,6 +661,7 @@ "unable_to_get_comments_number": "Nije moguće dobiti broj komentara", "unable_to_get_shared_link": "Preuzimanje deljene veze nije uspelo", "unable_to_hide_person": "Nije moguće sakriti osobu", + "unable_to_link_motion_video": "Nije moguće povezati video sa slikom", "unable_to_link_oauth_account": "Nije moguće povezati OAuth nalog", "unable_to_load_album": "Nije moguće učitati album", "unable_to_load_asset_activity": "Nije moguće učitati aktivnost sredstava", @@ -701,6 +702,7 @@ "unable_to_submit_job": "Nije moguće predati zadatak", "unable_to_trash_asset": "Nije moguće izbaciti materijal u otpad", "unable_to_unlink_account": "Nije moguće raskinuti profil", + "unable_to_unlink_motion_video": "Nije moguće odvezati video sa slikom", "unable_to_update_album_cover": "Nije moguće ažurirati naslovnicu albuma", "unable_to_update_album_info": "Nije moguće ažurirati informacije o albumu", "unable_to_update_library": "Nije moguće ažurirati biblioteku", @@ -1291,6 +1293,7 @@ "unknown_album": "Nepoznat Album", "unknown_year": "Nepoznata Godina", "unlimited": "Neograničeno", + "unlink_motion_video": "Odveži video od slike", "unlink_oauth": "Prekini vezu sa Oauth-om", "unlinked_oauth_account": "Opozvana veza OAuth naloga", "unnamed_album": "Neimenovani album", diff --git a/web/src/lib/i18n/th.json b/web/src/lib/i18n/th.json index 32336bfb4e9ce..f34fab2a1e6ca 100644 --- a/web/src/lib/i18n/th.json +++ b/web/src/lib/i18n/th.json @@ -25,7 +25,7 @@ "add_to_shared_album": "เพิ่มเข้าอัลบั้มที่แชร์", "added_to_archive": "เพิ่มเข้าที่เก็บถาวร", "added_to_favorites": "เพิ่มเข้ารายการโปรด", - "added_to_favorites_count": "{count} รูปถูกเพิ่มเข้ารายการโปรด", + "added_to_favorites_count": "{count, number} รูปถูกเพิ่มเข้ารายการโปรด", "admin": { "add_exclusion_pattern_description": "เพิ่มรูปแบบการยกเว้น การ Glob โดยใช้ *, ** และ ? ถูกรองรับ ถ้าต้องการละเว้นไฟล์ทั้งหมดในไดเร็กทอรีใดๆที่ชื่อว่า \"Raw\" ให้ใช้ \"**/Raw/**\" ถ้าต้องการละเว้นไฟล์ทั้งหมดที่ลงท้ายด้วย \".tif\" ให้ใช้ \"**/*.tif\" ถ้าต้องการละเว้นพาธที่เริ่มจากไดเรกทอรีบนสุดให้ใช้ \"/พาธ/ที่ต้องการ/ละเว้น/**\"", "authentication_settings": "ตั้งค่าการเข้าถึง", @@ -129,16 +129,21 @@ "map_enable_description": "เปิดใช้งานแผนที่", "map_gps_settings": "การตั้งค่าแผนที่และ GPS", "map_gps_settings_description": "จัดการการตั้งค่าแผนที่และ GPS (Reverse Geocoding)", + "map_implications": "ฟีเจอร์แผนที่ต้องการบริการแผ่นแผนที่จากภายนอก (tiles.immich.cloud)", "map_light_style": "แบบสว่าง", "map_manage_reverse_geocoding_settings": "จัดการการตั้งค่าแปลงพิกัดภูมิศาสตร์ ", "map_reverse_geocoding": "ประมวลผลชื่อทางภูมิศาสตร์", "map_reverse_geocoding_enable_description": "เปิดใช้งานประมวลผลชื่อทางภูมิศาสตร์", "map_reverse_geocoding_settings": "การตั้งค่าประมวลผลชื่อทางภูมิศาสตร์", - "map_settings": "การตั้งค่าแผนที่", + "map_settings": "แผนที่", "map_settings_description": "จัดการการตั้งค่าแผนที่", "map_style_description": "URL ไปยังธีมแผนที่ style.json", "metadata_extraction_job": "ดึงข้อมูล metadata", - "metadata_extraction_job_description": "ดึงข้อมูล metadata จากสื่อ เช่น GPS และความละเอียด", + "metadata_extraction_job_description": "ดึงข้อมูล metadata จากสื่อ เช่น GPS ใบหน้าและความละเอียด", + "metadata_faces_import_setting": "เปิดการนำเข้าข้อมูลใบหน้า", + "metadata_faces_import_setting_description": "นำเข้าข้อมูลใบหน้าจาก EXIF ของไฟล์ภาพและไฟล์ประกอบ", + "metadata_settings": "การตั้งค่า Metadata", + "metadata_settings_description": "จัดการการตั้งค่า Metadata", "migration_job": "การโยกย้าย", "migration_job_description": "ย้ายภาพตัวอย่างสื่อและใบหน้าไปยังโครงสร้างโฟลเดอร์ล่าสุด", "no_paths_added": "ไม่ได้เพิ่มพาธ", @@ -173,7 +178,9 @@ "oauth_issuer_url": "ผู้ออก URL", "oauth_mobile_redirect_uri": "URI เปลี่ยนเส้นทางบนโทรศัพท์", "oauth_mobile_redirect_uri_override": "แทนที่ URI เปลี่ยนเส้นทางบนโทรศัพท์", - "oauth_mobile_redirect_uri_override_description": "เปิดเมื่อ 'app.immich:/' เป็น URI เปลี่ยนเส้นทางที่ไม่ถูกต้อง", + "oauth_mobile_redirect_uri_override_description": "เปิดเมื่อ OAuth ไม่รองรับ URI บนอุปกรณ์ เช่น '{callback}'", + "oauth_profile_signing_algorithm": "อัลกอริทึมการรับรองบัญชีผู้ใช้", + "oauth_profile_signing_algorithm_description": "อัลกอริทึมใช้ในการรับรองบัญชีผู้ใช้", "oauth_scope": "ขอบเขต", "oauth_settings": "OAuth", "oauth_settings_description": "จัดการการตั้งค่าล็อกอินผ่าน OAuth", @@ -818,7 +825,7 @@ "status": "สถานะ", "stop_motion_photo": "", "stop_photo_sharing": "หยุดแชร์รูปภาพ?", - "storage": "ที่จัดเก็บ", + "storage": "พื้นที่จัดเก็บ", "storage_label": "", "submit": "ส่ง", "suggestions": "ข้อเสนอแนะ", diff --git a/web/src/lib/i18n/uk.json b/web/src/lib/i18n/uk.json index ce72fde8b42ff..3e24ccacc458d 100644 --- a/web/src/lib/i18n/uk.json +++ b/web/src/lib/i18n/uk.json @@ -660,6 +660,7 @@ "unable_to_get_comments_number": "Не вдалося отримати кількість коментарів", "unable_to_get_shared_link": "Не вдалося отримати спільне посилання", "unable_to_hide_person": "Неможливо приховати людину", + "unable_to_link_motion_video": "Не вдається зв'язати рухоме відео", "unable_to_link_oauth_account": "Не вдається прив'язати обліковий запис OAuth", "unable_to_load_album": "Неможливо завантажити альбом", "unable_to_load_asset_activity": "Неможливо завантажити активність активу", @@ -700,6 +701,7 @@ "unable_to_submit_job": "Не вдалося відправити завдання", "unable_to_trash_asset": "Неможливо вилучити актив", "unable_to_unlink_account": "Не вдається відв'язати обліковий запис", + "unable_to_unlink_motion_video": "Не вдається від'єднати рухоме відео", "unable_to_update_album_cover": "Неможливо оновити обкладинку альбому", "unable_to_update_album_info": "Неможливо оновити інформацію про альбом", "unable_to_update_library": "Не вдалося оновити бібліотеку", @@ -845,6 +847,7 @@ "license_trial_info_4": "Будь ласка, розгляньте можливість придбання ліцензії для підтримки подальшого розвитку сервісу", "light": "Світла", "like_deleted": "Лайк видалено", + "link_motion_video": "Посилання на рухоме відео", "link_options": "Налаштування посилання", "link_to_oauth": "Приєднання до OAuth", "linked_oauth_account": "Приєднаний акаунт OAuth", @@ -1288,6 +1291,7 @@ "unknown_album": "", "unknown_year": "Невідомий рік", "unlimited": "Без обмежень", + "unlink_motion_video": "Від'єднати рухоме відео", "unlink_oauth": "Від'єднайте OAuth", "unlinked_oauth_account": "Відключити акаунт OAuth", "unnamed_album": "Альбом без назви", diff --git a/web/src/lib/i18n/vi.json b/web/src/lib/i18n/vi.json index e94eb7a46471f..ec8c8d4e7f61a 100644 --- a/web/src/lib/i18n/vi.json +++ b/web/src/lib/i18n/vi.json @@ -660,6 +660,7 @@ "unable_to_get_comments_number": "Không thể lấy số lượng bình luận", "unable_to_get_shared_link": "Không thể lấy liên kết chia sẻ", "unable_to_hide_person": "Không thể ẩn người", + "unable_to_link_motion_video": "Không thể liên kết video chuyển động", "unable_to_link_oauth_account": "Không thể liên kết tài khoản OAuth", "unable_to_load_album": "Không thể tải album", "unable_to_load_asset_activity": "Không thể tải hoạt động của ảnh", @@ -700,6 +701,7 @@ "unable_to_submit_job": "Không thể gửi tác vụ", "unable_to_trash_asset": "Không thể chuyển ảnh vào thùng rác", "unable_to_unlink_account": "Không thể hủy liên kết tài khoản", + "unable_to_unlink_motion_video": "Không thể hủy liên kết video chuyển động", "unable_to_update_album_cover": "Không thể cập nhật ảnh bìa album", "unable_to_update_album_info": "Không thể cập nhật thông tin album", "unable_to_update_library": "Không thể cập nhật thư viện", @@ -1261,6 +1263,7 @@ "unknown_album": "", "unknown_year": "Năm không xác định", "unlimited": "Không giới hạn", + "unlink_motion_video": "Hủy liên kết video chuyển động", "unlink_oauth": "Huỷ liên kết OAuth", "unlinked_oauth_account": "Đã huỷ liên kết tài khoản OAuth", "unnamed_album": "Album chưa đặt tên", diff --git a/web/src/lib/i18n/zh_SIMPLIFIED.json b/web/src/lib/i18n/zh_SIMPLIFIED.json index e879365f410bb..08c236dcbf81c 100644 --- a/web/src/lib/i18n/zh_SIMPLIFIED.json +++ b/web/src/lib/i18n/zh_SIMPLIFIED.json @@ -27,7 +27,7 @@ "added_to_favorites": "添加到收藏", "added_to_favorites_count": "添加{count, number}项到收藏", "admin": { - "add_exclusion_pattern_description": "添加排除规则。支持使用 *、** 和 ? 通配符。比如要忽略名为 “Raw” 的任何目录中的所有文件,请使用 “**/Raw/**”;要忽略所有以 “.tif” 结尾的文件,请使用 “**/*.tif”;要忽略绝对路径,请使用 “/path/to/ignore/**”。", + "add_exclusion_pattern_description": "添加排除规则。支持使用 *、** 和 ? 通配符。比如要忽略任何名为 “Raw” 的文件夹中的所有文件,请使用 “**/Raw/**”;要忽略所有以 “.tif” 结尾的文件,请使用 “**/*.tif”;要忽略绝对路径,请使用 “/path/to/ignore/**”。", "authentication_settings": "认证设置", "authentication_settings_description": "管理密码、OAuth 和其它认证设置", "authentication_settings_disable_all": "确定要禁用所有的登录方式?此操作将完全禁止登录。", @@ -119,7 +119,7 @@ "machine_learning_settings": "机器学习设置", "machine_learning_settings_description": "管理机器学习功能和设置", "machine_learning_smart_search": "智能搜索", - "machine_learning_smart_search_description": "使用CLIP相似度进行图像语义搜索", + "machine_learning_smart_search_description": "使用CLIP以文搜图、智能搜图", "machine_learning_smart_search_enabled": "启用智能搜索", "machine_learning_smart_search_enabled_description": "如果禁用,则不会对图像编码以用于智能搜索。", "machine_learning_url_description": "机器学习服务器的URL", @@ -152,8 +152,8 @@ "note_cannot_be_changed_later": "注意:此项一旦设定,以后无法更改!", "note_unlimited_quota": "提示:输入0表示无限制", "notification_email_from_address": "发件人地址", - "notification_email_from_address_description": "发件人邮箱地址,例如“Immich 服务器 ”", - "notification_email_host_description": "邮件服务器主机(例如 smtp.immich.app)", + "notification_email_from_address_description": "发件人邮箱地址,例如“张三<12345@qq.com>”", + "notification_email_host_description": "服务器地址:(例如:smtp.qq.com)", "notification_email_ignore_certificate_errors": "忽略证书错误", "notification_email_ignore_certificate_errors_description": "忽略TLS证书验证错误(不建议)", "notification_email_password_description": "与邮件服务器进行身份验证时使用的密码", @@ -171,7 +171,7 @@ "oauth_auto_launch_description": "在登录页面自动启动OAuth登录", "oauth_auto_register": "自动注册", "oauth_auto_register_description": "使用OAuth登录后自动注册新用户", - "oauth_button_text": "按钮文本", + "oauth_button_text": "按钮名称", "oauth_client_id": "客户端ID", "oauth_client_secret": "客户端密钥", "oauth_enable_description": "使用OAuth登录", @@ -320,7 +320,7 @@ "user_management": "用户管理", "user_password_has_been_reset": "该用户的密码被重置:", "user_password_reset_description": "请向用户提供临时密码,并告知他们下次登录时需要更改密码。", - "user_restore_description": "{user}的账户将被恢复。", + "user_restore_description": "账户“{user}”将被恢复。", "user_restore_scheduled_removal": "恢复用户 - 计划于{date, date, long}删除", "user_settings": "用户设置", "user_settings_description": "管理用户设置", @@ -465,7 +465,7 @@ "confirm_delete_shared_link": "您确定要删除此共享链接吗?", "confirm_password": "确认密码", "contain": "包含", - "context": "图像语义搜索", + "context": "以文搜图", "continue": "继续", "copied_image_to_clipboard": "已复制图片至剪贴板。", "copied_to_clipboard": "已复制到剪切板!", @@ -496,12 +496,12 @@ "custom_locale": "自定义地区", "custom_locale_description": "日期和数字显示格式跟随语言和地区", "dark": "深色", - "date_after": "日期之后", + "date_after": "开始日期", "date_and_time": "日期与时间", - "date_before": "日期之前", + "date_before": "结束日期", "date_of_birth_saved": "出生日期保存成功", "date_range": "日期范围", - "day": "天", + "day": "日", "deduplicate_all": "删除所有重复项", "default_locale": "默认地区", "default_locale_description": "根据您的浏览器地区设置日期和数字显示格式", @@ -681,7 +681,7 @@ "unable_to_remove_library": "无法移除图库", "unable_to_remove_offline_files": "无法移除离线文件", "unable_to_remove_partner": "无法移除同伴", - "unable_to_remove_reaction": "无法移除反应", + "unable_to_remove_reaction": "无法移除回应", "unable_to_remove_user": "无法移除用户", "unable_to_repair_items": "无法修复项目", "unable_to_reset_password": "无法重置密码", @@ -761,7 +761,7 @@ "group_no": "未分组", "group_owner": "按所有者分组", "group_year": "按年分组", - "has_quota": "有限额", + "has_quota": "配额大小", "hi_user": "你好,{name}({email})", "hide_all_people": "隐藏所有人物", "hide_gallery": "隐藏相册", @@ -769,7 +769,7 @@ "hide_password": "隐藏密码", "hide_person": "隐藏人物", "hide_unnamed_people": "隐藏未命名的人物", - "host": "主机", + "host": "服务器", "hour": "时", "image": "图片", "image_alt_text_date": "在{date}拍摄的{isVideo, select, true {视频} other {照片}}", @@ -793,7 +793,7 @@ "in_albums": "在{count, plural, one {#个相册} other {#个相册}}中", "in_archive": "在归档中", "include_archived": "包括已归档", - "include_shared_albums": "包含共享相册", + "include_shared_albums": "包括共享相册", "include_shared_partner_assets": "包括同伴共享项目", "individual_share": "个人分享", "info": "信息", @@ -930,7 +930,7 @@ "no_shared_albums_message": "创建相册以共享照片和视频", "not_in_any_album": "不在任何相册中", "note_apply_storage_label_to_previously_uploaded assets": "提示:要将存储标签应用于之前上传的项目,运行以下命令", - "note_unlimited_quota": "注:输入 0 表示无限制配额", + "note_unlimited_quota": "注:输入 0 表示无限配额", "notes": "提示", "notification_toggle_setting_description": "启用邮件通知", "notifications": "通知", @@ -1060,7 +1060,7 @@ "rating_count": "{count, plural, one {#星} other {#星}}", "rating_description": "在信息面板中展示EXIF星级", "raw": "Raw", - "reaction_options": "反应选项", + "reaction_options": "回应选项", "read_changelog": "阅读更新日志", "reassign": "重新指派", "reassigned_assets_to_existing_person": "重新指派{count, plural, one {#个项目} other {#个项目}}到{name, select, null {已存在的人物} other {{name}}}", @@ -1081,7 +1081,7 @@ "remove_assets_album_confirmation": "确定要从项目中移除{count, plural, one {#个项目} other {#个项目}}?", "remove_assets_shared_link_confirmation": "确定要从共享链接中移除{count, plural, one {#个项目} other {#个项目}}?", "remove_assets_title": "移除项目?", - "remove_custom_date_range": "根据自定义日期范围移除", + "remove_custom_date_range": "取消自定义日期范围", "remove_from_album": "从相册中移除", "remove_from_favorites": "移出收藏", "remove_from_shared_link": "从共享链接中移除", @@ -1181,16 +1181,16 @@ "share": "共享", "shared": "共享", "shared_by": "共享自", - "shared_by_user": "由{user}共享", + "shared_by_user": "由“{user}”共享", "shared_by_you": "你的共享", - "shared_from_partner": "来自{partner}的照片", + "shared_from_partner": "来自“{partner}”的照片", "shared_link_options": "共享链接选项", "shared_links": "共享链接", "shared_photos_and_videos_count": "{assetCount, plural, other {#项已共享照片&视频。}}", - "shared_with_partner": "与{partner}共享", + "shared_with_partner": "与“{partner}”共享", "sharing": "共享", "sharing_enter_password": "请输入密码后查看此页面。", - "sharing_sidebar_description": "在侧边栏中显示共享链接", + "sharing_sidebar_description": "在侧边栏中显示“共享”链接", "shift_to_permanent_delete": "按住Shift键永久删除项目", "show_album_options": "显示相册选项", "show_albums": "显示相册", @@ -1216,9 +1216,9 @@ "sign_out": "注销", "sign_up": "注册", "size": "大小", - "skip_to_content": "跳到内容", - "skip_to_folders": "跳到文件夹", - "skip_to_tags": "跳到标签", + "skip_to_content": "跳转到内容", + "skip_to_folders": "跳转到文件夹", + "skip_to_tags": "跳转到标签", "slideshow": "幻灯片放映", "slideshow_settings": "放映设置", "sort_albums_by": "相册排序依据...", @@ -1241,15 +1241,15 @@ "status": "状态", "stop_motion_photo": "定格照片", "stop_photo_sharing": "停止共享照片?", - "stop_photo_sharing_description": "{partner}将不能访问你的照片。", + "stop_photo_sharing_description": "“{partner}”将不能访问你的照片。", "stop_sharing_photos_with_user": "停止与此用户共享照片", "storage": "存储空间", "storage_label": "存储标签", - "storage_usage": "总量:{available},已用{used}", + "storage_usage": "总量:{available}/已用:{used}", "submit": "提交", "suggestions": "建议", "sunrise_on_the_beach": "海滩上的日出", - "swap_merge_direction": "交换合并方向", + "swap_merge_direction": "互换合并方向", "sync": "同步", "tag": "标签", "tag_assets": "标记项目", @@ -1262,7 +1262,7 @@ "template": "模版", "theme": "主题", "theme_selection": "主题选项", - "theme_selection_description": "根据浏览器的系统首选项自动设置主题色", + "theme_selection_description": "跟随浏览器自动设置主题颜色", "they_will_be_merged_together": "项目将会合并到一起", "time_based_memories": "基于时间的回忆", "timezone": "时区", @@ -1319,15 +1319,15 @@ "upload_success": "上传成功,刷新页面查看新上传的项目。", "url": "URL", "usage": "用量", - "use_custom_date_range": "使用自定义日期范围", + "use_custom_date_range": "自定义日期范围", "user": "用户", "user_id": "用户ID", "user_license_settings": "授权", "user_license_settings_description": "管理你的授权", - "user_liked": "{user}点赞了{type, select, photo {该照片} video {该视频} asset {该项目} other {它}}", + "user_liked": "“{user}”点赞了{type, select, photo {该照片} video {该视频} asset {该项目} other {它}}", "user_purchase_settings": "购买", "user_purchase_settings_description": "管理购买订单", - "user_role_set": "设置{user}为{role}", + "user_role_set": "设置“{user}”为“{role}”", "user_usage_detail": "用户用量详情", "username": "用户名", "users": "用户", @@ -1336,7 +1336,7 @@ "variables": "变量", "version": "版本", "version_announcement_closing": "你的朋友,Alex", - "version_announcement_message": "嗨,伙计,当前应用出新版本了,请抽空阅读一下发行说明,并及时更新你的docker-compose.yml.env文件,避免存在错误配置,特别是当你是使用WatchTower或其它类似的自动升级工具时。", + "version_announcement_message": "嗨,朋友,当前应用出新版本了,请抽空阅读一下发行说明,并及时更新你的docker-compose.yml.env文件,避免存在配置错误,特别是当你是使用WatchTower或其它类似的自动升级工具时。", "video": "视频", "video_hover_setting": "鼠标悬停时播放视频缩略图", "video_hover_setting_description": "当鼠标悬停在项目上时播放视频缩略图。即使禁用了这个功能,也可以通过将鼠标悬停在播放图标上来开始播放。", @@ -1353,7 +1353,7 @@ "view_stack": "查看堆叠项目", "viewer": "预览", "visibility_changed": "{count, plural, one {#个人物} other {#个人物}}的可见性已修改", - "waiting": "队列中", + "waiting": "准备处理", "warning": "警告", "week": "周", "welcome": "欢迎", From 4a1ff6abce9a94e0f7d0921922edeae9879de5d7 Mon Sep 17 00:00:00 2001 From: Fynn Petersen-Frey <10599762+fyfrey@users.noreply.github.com> Date: Mon, 16 Sep 2024 22:26:14 +0200 Subject: [PATCH 034/123] refactor(mobile): repositories for album service (#12701) * refactor(mobile): repositories for album service * review feedback, first service unit test --- mobile/lib/entities/album.entity.dart | 3 +- mobile/lib/interfaces/album.interface.dart | 21 +++ mobile/lib/interfaces/asset.interface.dart | 8 ++ mobile/lib/interfaces/backup.interface.dart | 5 + mobile/lib/interfaces/user.interface.dart | 5 + mobile/lib/repositories/album.repository.dart | 85 ++++++++++++ mobile/lib/repositories/asset.repository.dart | 31 +++++ .../lib/repositories/backup.repository.dart | 20 +++ mobile/lib/repositories/user.repository.dart | 20 +++ mobile/lib/services/album.service.dart | 126 ++++++++---------- mobile/lib/services/background.service.dart | 19 ++- mobile/test/repository.mocks.dart | 13 ++ mobile/test/service.mocks.dart | 10 ++ mobile/test/services/album.service.test.dart | 52 ++++++++ 14 files changed, 347 insertions(+), 71 deletions(-) create mode 100644 mobile/lib/interfaces/album.interface.dart create mode 100644 mobile/lib/interfaces/asset.interface.dart create mode 100644 mobile/lib/interfaces/backup.interface.dart create mode 100644 mobile/lib/interfaces/user.interface.dart create mode 100644 mobile/lib/repositories/album.repository.dart create mode 100644 mobile/lib/repositories/asset.repository.dart create mode 100644 mobile/lib/repositories/backup.repository.dart create mode 100644 mobile/lib/repositories/user.repository.dart create mode 100644 mobile/test/repository.mocks.dart create mode 100644 mobile/test/service.mocks.dart create mode 100644 mobile/test/services/album.service.test.dart diff --git a/mobile/lib/entities/album.entity.dart b/mobile/lib/entities/album.entity.dart index c05b849dcd26d..b20cec97c33a5 100644 --- a/mobile/lib/entities/album.entity.dart +++ b/mobile/lib/entities/album.entity.dart @@ -164,12 +164,13 @@ class Album { } extension AssetsHelper on IsarCollection { - Future store(Album a) async { + Future store(Album a) async { await put(a); await a.owner.save(); await a.thumbnail.save(); await a.sharedUsers.save(); await a.assets.save(); + return a; } } diff --git a/mobile/lib/interfaces/album.interface.dart b/mobile/lib/interfaces/album.interface.dart new file mode 100644 index 0000000000000..c2ba650b6f407 --- /dev/null +++ b/mobile/lib/interfaces/album.interface.dart @@ -0,0 +1,21 @@ +import 'package:immich_mobile/entities/album.entity.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/entities/user.entity.dart'; + +abstract interface class IAlbumRepository { + Future count({bool? local}); + Future create(Album album); + Future getById(int id); + Future getByName( + String name, { + bool? shared, + bool? remote, + }); + Future update(Album album); + Future delete(int albumId); + Future> getAll({bool? shared}); + Future removeUsers(Album album, List users); + Future addAssets(Album album, List assets); + Future removeAssets(Album album, List assets); + Future recalculateMetadata(Album album); +} diff --git a/mobile/lib/interfaces/asset.interface.dart b/mobile/lib/interfaces/asset.interface.dart new file mode 100644 index 0000000000000..46425ba617cda --- /dev/null +++ b/mobile/lib/interfaces/asset.interface.dart @@ -0,0 +1,8 @@ +import 'package:immich_mobile/entities/album.entity.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/entities/user.entity.dart'; + +abstract interface class IAssetRepository { + Future> getByAlbum(Album album, {User? notOwnedBy}); + Future deleteById(List ids); +} diff --git a/mobile/lib/interfaces/backup.interface.dart b/mobile/lib/interfaces/backup.interface.dart new file mode 100644 index 0000000000000..e343a9d39019f --- /dev/null +++ b/mobile/lib/interfaces/backup.interface.dart @@ -0,0 +1,5 @@ +import 'package:immich_mobile/entities/backup_album.entity.dart'; + +abstract interface class IBackupRepository { + Future> getIdsBySelection(BackupSelection backup); +} diff --git a/mobile/lib/interfaces/user.interface.dart b/mobile/lib/interfaces/user.interface.dart new file mode 100644 index 0000000000000..d9841a1187595 --- /dev/null +++ b/mobile/lib/interfaces/user.interface.dart @@ -0,0 +1,5 @@ +import 'package:immich_mobile/entities/user.entity.dart'; + +abstract interface class IUserRepository { + Future> getByIds(List ids); +} diff --git a/mobile/lib/repositories/album.repository.dart b/mobile/lib/repositories/album.repository.dart new file mode 100644 index 0000000000000..08c939aa6ca87 --- /dev/null +++ b/mobile/lib/repositories/album.repository.dart @@ -0,0 +1,85 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/album.entity.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/interfaces/album.interface.dart'; +import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:isar/isar.dart'; + +final albumRepositoryProvider = + Provider((ref) => AlbumRepository(ref.watch(dbProvider))); + +class AlbumRepository implements IAlbumRepository { + final Isar _db; + + AlbumRepository( + this._db, + ); + + @override + Future count({bool? local}) { + if (local == true) return _db.albums.where().localIdIsNotNull().count(); + if (local == false) return _db.albums.where().remoteIdIsNotNull().count(); + return _db.albums.count(); + } + + @override + Future create(Album album) => + _db.writeTxn(() => _db.albums.store(album)); + + @override + Future getByName(String name, {bool? shared, bool? remote}) { + var query = _db.albums.filter().nameEqualTo(name); + if (shared != null) { + query = query.sharedEqualTo(shared); + } + if (remote == true) { + query = query.localIdIsNull(); + } else if (remote == false) { + query = query.remoteIdIsNull(); + } + return query.findFirst(); + } + + @override + Future update(Album album) => + _db.writeTxn(() => _db.albums.store(album)); + + @override + Future delete(int albumId) => + _db.writeTxn(() => _db.albums.delete(albumId)); + + @override + Future> getAll({bool? shared}) { + final baseQuery = _db.albums.filter(); + QueryBuilder? query; + if (shared != null) { + query = baseQuery.sharedEqualTo(true); + } + return query?.findAll() ?? _db.albums.where().findAll(); + } + + @override + Future getById(int id) => _db.albums.get(id); + + @override + Future removeUsers(Album album, List users) => + _db.writeTxn(() => album.sharedUsers.update(unlink: users)); + + @override + Future addAssets(Album album, List assets) => + _db.writeTxn(() => album.assets.update(link: assets)); + + @override + Future removeAssets(Album album, List assets) => + _db.writeTxn(() => album.assets.update(unlink: assets)); + + @override + Future recalculateMetadata(Album album) async { + album.startDate = await album.assets.filter().fileCreatedAtProperty().min(); + album.endDate = await album.assets.filter().fileCreatedAtProperty().max(); + album.lastModifiedAssetTimestamp = + await album.assets.filter().updatedAtProperty().max(); + return album; + } +} diff --git a/mobile/lib/repositories/asset.repository.dart b/mobile/lib/repositories/asset.repository.dart new file mode 100644 index 0000000000000..ea05feab38f68 --- /dev/null +++ b/mobile/lib/repositories/asset.repository.dart @@ -0,0 +1,31 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/album.entity.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/interfaces/asset.interface.dart'; +import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:isar/isar.dart'; + +final assetRepositoryProvider = + Provider((ref) => AssetRepository(ref.watch(dbProvider))); + +class AssetRepository implements IAssetRepository { + final Isar _db; + + AssetRepository( + this._db, + ); + + @override + Future> getByAlbum(Album album, {User? notOwnedBy}) { + var query = album.assets.filter(); + if (notOwnedBy != null) { + query = query.not().ownerIdEqualTo(notOwnedBy.isarId); + } + return query.findAll(); + } + + @override + Future deleteById(List ids) => + _db.writeTxn(() => _db.assets.deleteAll(ids)); +} diff --git a/mobile/lib/repositories/backup.repository.dart b/mobile/lib/repositories/backup.repository.dart new file mode 100644 index 0000000000000..c9d93f787769b --- /dev/null +++ b/mobile/lib/repositories/backup.repository.dart @@ -0,0 +1,20 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/backup_album.entity.dart'; +import 'package:immich_mobile/interfaces/backup.interface.dart'; +import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:isar/isar.dart'; + +final backupRepositoryProvider = + Provider((ref) => BackupRepository(ref.watch(dbProvider))); + +class BackupRepository implements IBackupRepository { + final Isar _db; + + BackupRepository( + this._db, + ); + + @override + Future> getIdsBySelection(BackupSelection backup) => + _db.backupAlbums.filter().selectionEqualTo(backup).idProperty().findAll(); +} diff --git a/mobile/lib/repositories/user.repository.dart b/mobile/lib/repositories/user.repository.dart new file mode 100644 index 0000000000000..cd87eb17ecb24 --- /dev/null +++ b/mobile/lib/repositories/user.repository.dart @@ -0,0 +1,20 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/interfaces/user.interface.dart'; +import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:isar/isar.dart'; + +final userRepositoryProvider = + Provider((ref) => UserRepository(ref.watch(dbProvider))); + +class UserRepository implements IUserRepository { + final Isar _db; + + UserRepository( + this._db, + ); + + @override + Future> getByIds(List ids) async => + (await _db.users.getAllById(ids)).cast(); +} diff --git a/mobile/lib/services/album.service.dart b/mobile/lib/services/album.service.dart index ef56f9bf6c12a..92302a0d88f29 100644 --- a/mobile/lib/services/album.service.dart +++ b/mobile/lib/services/album.service.dart @@ -5,6 +5,10 @@ import 'dart:io'; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/interfaces/album.interface.dart'; +import 'package:immich_mobile/interfaces/asset.interface.dart'; +import 'package:immich_mobile/interfaces/backup.interface.dart'; +import 'package:immich_mobile/interfaces/user.interface.dart'; import 'package:immich_mobile/models/albums/album_add_asset_response.model.dart'; import 'package:immich_mobile/entities/backup_album.entity.dart'; import 'package:immich_mobile/entities/album.entity.dart'; @@ -12,11 +16,13 @@ import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/providers/api.provider.dart'; -import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:immich_mobile/repositories/album.repository.dart'; +import 'package:immich_mobile/repositories/asset.repository.dart'; +import 'package:immich_mobile/repositories/backup.repository.dart'; +import 'package:immich_mobile/repositories/user.repository.dart'; import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/sync.service.dart'; import 'package:immich_mobile/services/user.service.dart'; -import 'package:isar/isar.dart'; import 'package:logging/logging.dart'; import 'package:openapi/api.dart'; import 'package:photo_manager/photo_manager.dart'; @@ -26,7 +32,10 @@ final albumServiceProvider = Provider( ref.watch(apiServiceProvider), ref.watch(userServiceProvider), ref.watch(syncServiceProvider), - ref.watch(dbProvider), + ref.watch(albumRepositoryProvider), + ref.watch(assetRepositoryProvider), + ref.watch(userRepositoryProvider), + ref.watch(backupRepositoryProvider), ), ); @@ -34,7 +43,10 @@ class AlbumService { final ApiService _apiService; final UserService _userService; final SyncService _syncService; - final Isar _db; + final IAlbumRepository _albumRepository; + final IAssetRepository _assetRepository; + final IUserRepository _userRepository; + final IBackupRepository _backupAlbumRepository; final Logger _log = Logger('AlbumService'); Completer _localCompleter = Completer()..complete(false); Completer _remoteCompleter = Completer()..complete(false); @@ -43,16 +55,12 @@ class AlbumService { this._apiService, this._userService, this._syncService, - this._db, + this._albumRepository, + this._assetRepository, + this._userRepository, + this._backupAlbumRepository, ); - QueryBuilder - selectedAlbumsQuery() => - _db.backupAlbums.filter().selectionEqualTo(BackupSelection.select); - QueryBuilder - excludedAlbumsQuery() => - _db.backupAlbums.filter().selectionEqualTo(BackupSelection.exclude); - /// Checks all selected device albums for changes of albums and their assets /// Updates the local database and returns `true` if there were any changes Future refreshDeviceAlbums() async { @@ -65,12 +73,12 @@ class AlbumService { final Stopwatch sw = Stopwatch()..start(); bool changes = false; try { - final List excludedIds = - await excludedAlbumsQuery().idProperty().findAll(); - final List selectedIds = - await selectedAlbumsQuery().idProperty().findAll(); + final List excludedIds = await _backupAlbumRepository + .getIdsBySelection(BackupSelection.exclude); + final List selectedIds = await _backupAlbumRepository + .getIdsBySelection(BackupSelection.select); if (selectedIds.isEmpty) { - final numLocal = await _db.albums.where().localIdIsNotNull().count(); + final numLocal = await _albumRepository.count(local: true); if (numLocal > 0) { _syncService.removeAllLocalAlbumsAndAssets(); } @@ -194,8 +202,8 @@ class AlbumService { ), ); if (remote != null) { - Album album = await Album.remote(remote); - await _db.writeTxn(() => _db.albums.store(album)); + final Album album = await Album.remote(remote); + await _albumRepository.create(album); return album; } } catch (e) { @@ -212,8 +220,7 @@ class AlbumService { for (int round = 0;; round++) { final proposedName = "$baseName${round == 0 ? "" : " ($round)"}"; - if (null == - await _db.albums.filter().nameEqualTo(proposedName).findFirst()) { + if (null == await _albumRepository.getByName(proposedName)) { return proposedName; } } @@ -268,20 +275,15 @@ class AlbumService { Future _updateAssets( int albumId, { - Iterable add = const [], - Iterable remove = const [], - }) { - return _db.writeTxn(() async { - final album = await _db.albums.get(albumId); - if (album == null) return; - await album.assets.update(link: add, unlink: remove); - album.startDate = - await album.assets.filter().fileCreatedAtProperty().min(); - album.endDate = await album.assets.filter().fileCreatedAtProperty().max(); - album.lastModifiedAssetTimestamp = - await album.assets.filter().updatedAtProperty().max(); - await _db.albums.put(album); - }); + List add = const [], + List remove = const [], + }) async { + final album = await _albumRepository.getById(albumId); + if (album == null) return; + await _albumRepository.addAssets(album, add); + await _albumRepository.removeAssets(album, remove); + await _albumRepository.recalculateMetadata(album); + await _albumRepository.update(album); } Future addAdditionalUserToAlbum( @@ -298,13 +300,9 @@ class AlbumService { AddUsersDto(albumUsers: albumUsers), ); if (result != null) { - album.sharedUsers - .addAll((await _db.users.getAllById(sharedUserIds)).cast()); + album.sharedUsers.addAll(await _userRepository.getByIds(sharedUserIds)); album.shared = result.shared; - await _db.writeTxn(() async { - await _db.albums.put(album); - await album.sharedUsers.save(); - }); + await _albumRepository.update(album); return true; } } catch (e) { @@ -321,7 +319,7 @@ class AlbumService { ); if (result != null) { album.activityEnabled = enabled; - await _db.writeTxn(() => _db.albums.put(album)); + await _albumRepository.update(album); return true; } } catch (e) { @@ -332,29 +330,29 @@ class AlbumService { Future deleteAlbum(Album album) async { try { - final userId = Store.get(StoreKey.currentUser).isarId; - if (album.owner.value?.isarId == userId) { + final user = Store.get(StoreKey.currentUser); + if (album.owner.value?.isarId == user.isarId) { await _apiService.albumsApi.deleteAlbum(album.remoteId!); } if (album.shared) { final foreignAssets = - await album.assets.filter().not().ownerIdEqualTo(userId).findAll(); - await _db.writeTxn(() => _db.albums.delete(album.id)); - final List albums = - await _db.albums.filter().sharedEqualTo(true).findAll(); + await _assetRepository.getByAlbum(album, notOwnedBy: user); + await _albumRepository.delete(album.id); + + final List albums = await _albumRepository.getAll(shared: true); final List existing = []; - for (Album a in albums) { + for (Album album in albums) { existing.addAll( - await a.assets.filter().not().ownerIdEqualTo(userId).findAll(), + await _assetRepository.getByAlbum(album, notOwnedBy: user), ); } final List idsToRemove = _syncService.sharedAssetsToRemove(foreignAssets, existing); if (idsToRemove.isNotEmpty) { - await _db.writeTxn(() => _db.assets.deleteAll(idsToRemove)); + await _assetRepository.deleteById(idsToRemove); } } else { - await _db.writeTxn(() => _db.albums.delete(album.id)); + await _albumRepository.delete(album.id); } return true; } catch (e) { @@ -390,7 +388,7 @@ class AlbumService { : response .where((e) => e.success) .map((e) => assets.firstWhere((a) => a.remoteId == e.id)); - await _updateAssets(album.id, remove: toRemove); + await _updateAssets(album.id, remove: toRemove.toList()); return true; } } catch (e) { @@ -410,12 +408,10 @@ class AlbumService { ); album.sharedUsers.remove(user); - await _db.writeTxn(() async { - await album.sharedUsers.update(unlink: [user]); - final a = await _db.albums.get(album.id); - // trigger watcher - await _db.albums.put(a!); - }); + await _albumRepository.removeUsers(album, [user]); + final a = await _albumRepository.getById(album.id); + // trigger watcher + await _albumRepository.update(a!); return true; } catch (e) { @@ -436,7 +432,7 @@ class AlbumService { ), ); album.name = newAlbumTitle; - await _db.writeTxn(() => _db.albums.put(album)); + await _albumRepository.update(album); return true; } catch (e) { @@ -445,14 +441,8 @@ class AlbumService { } } - Future getAlbumByName(String name, bool remoteOnly) async { - return _db.albums - .filter() - .optional(remoteOnly, (q) => q.localIdIsNull()) - .nameEqualTo(name) - .sharedEqualTo(false) - .findFirst(); - } + Future getAlbumByName(String name, bool remoteOnly) => + _albumRepository.getByName(name, remote: remoteOnly ? true : null); /// /// Add the uploaded asset to the selected albums diff --git a/mobile/lib/services/background.service.dart b/mobile/lib/services/background.service.dart index fc3feb174d582..0d4d547434034 100644 --- a/mobile/lib/services/background.service.dart +++ b/mobile/lib/services/background.service.dart @@ -12,6 +12,10 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/main.dart'; import 'package:immich_mobile/models/backup/backup_candidate.model.dart'; import 'package:immich_mobile/models/backup/success_upload_asset.model.dart'; +import 'package:immich_mobile/repositories/album.repository.dart'; +import 'package:immich_mobile/repositories/asset.repository.dart'; +import 'package:immich_mobile/repositories/backup.repository.dart'; +import 'package:immich_mobile/repositories/user.repository.dart'; import 'package:immich_mobile/services/album.service.dart'; import 'package:immich_mobile/services/hash.service.dart'; import 'package:immich_mobile/services/localization.service.dart'; @@ -355,12 +359,23 @@ class BackgroundService { AppSettingsService settingService = AppSettingsService(); AppSettingsService settingsService = AppSettingsService(); PartnerService partnerService = PartnerService(apiService, db); + AlbumRepository albumRepository = AlbumRepository(db); + AssetRepository assetRepository = AssetRepository(db); + UserRepository userRepository = UserRepository(db); + BackupRepository backupAlbumRepository = BackupRepository(db); HashService hashService = HashService(db, this); SyncService syncSerive = SyncService(db, hashService); UserService userService = UserService(apiService, db, syncSerive, partnerService); - AlbumService albumService = - AlbumService(apiService, userService, syncSerive, db); + AlbumService albumService = AlbumService( + apiService, + userService, + syncSerive, + albumRepository, + assetRepository, + userRepository, + backupAlbumRepository, + ); BackupService backupService = BackupService(apiService, db, settingService, albumService); diff --git a/mobile/test/repository.mocks.dart b/mobile/test/repository.mocks.dart new file mode 100644 index 0000000000000..e54d82739e5b8 --- /dev/null +++ b/mobile/test/repository.mocks.dart @@ -0,0 +1,13 @@ +import 'package:immich_mobile/interfaces/album.interface.dart'; +import 'package:immich_mobile/interfaces/asset.interface.dart'; +import 'package:immich_mobile/interfaces/backup.interface.dart'; +import 'package:immich_mobile/interfaces/user.interface.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockAlbumRepository extends Mock implements IAlbumRepository {} + +class MockAssetRepository extends Mock implements IAssetRepository {} + +class MockUserRepository extends Mock implements IUserRepository {} + +class MockBackupRepository extends Mock implements IBackupRepository {} diff --git a/mobile/test/service.mocks.dart b/mobile/test/service.mocks.dart new file mode 100644 index 0000000000000..ba4c129e5c2bc --- /dev/null +++ b/mobile/test/service.mocks.dart @@ -0,0 +1,10 @@ +import 'package:immich_mobile/services/api.service.dart'; +import 'package:immich_mobile/services/sync.service.dart'; +import 'package:immich_mobile/services/user.service.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockApiService extends Mock implements ApiService {} + +class MockUserService extends Mock implements UserService {} + +class MockSyncService extends Mock implements SyncService {} diff --git a/mobile/test/services/album.service.test.dart b/mobile/test/services/album.service.test.dart new file mode 100644 index 0000000000000..790a0eba356b9 --- /dev/null +++ b/mobile/test/services/album.service.test.dart @@ -0,0 +1,52 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/entities/backup_album.entity.dart'; +import 'package:immich_mobile/services/album.service.dart'; +import 'package:mocktail/mocktail.dart'; +import '../repository.mocks.dart'; +import '../service.mocks.dart'; + +void main() { + late AlbumService sut; + late MockApiService apiService; + late MockUserService userService; + late MockSyncService syncService; + late MockAlbumRepository albumRepository; + late MockAssetRepository assetRepository; + late MockUserRepository userRepository; + late MockBackupRepository backupRepository; + + setUp(() { + apiService = MockApiService(); + userService = MockUserService(); + syncService = MockSyncService(); + albumRepository = MockAlbumRepository(); + assetRepository = MockAssetRepository(); + userRepository = MockUserRepository(); + backupRepository = MockBackupRepository(); + + sut = AlbumService( + apiService, + userService, + syncService, + albumRepository, + assetRepository, + userRepository, + backupRepository, + ); + }); + + group('refreshDeviceAlbums', () { + test('empty selection with one album in db', () async { + when(() => backupRepository.getIdsBySelection(BackupSelection.exclude)) + .thenAnswer((_) async => []); + when(() => backupRepository.getIdsBySelection(BackupSelection.select)) + .thenAnswer((_) async => []); + when(() => albumRepository.count(local: true)).thenAnswer((_) async => 1); + when(() => syncService.removeAllLocalAlbumsAndAssets()) + .thenAnswer((_) async => true); + final result = await sut.refreshDeviceAlbums(); + expect(result, false); + verify(() => syncService.removeAllLocalAlbumsAndAssets()); + }); + }); +} From b74b20824a1c0aa238a08e59a327307526016ad3 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Mon, 16 Sep 2024 16:49:12 -0400 Subject: [PATCH 035/123] feat: tag cleanup job (#12654) --- mobile/openapi/README.md | 3 + mobile/openapi/lib/api.dart | 2 + mobile/openapi/lib/api/jobs_api.dart | 39 ++++++++ mobile/openapi/lib/api_client.dart | 4 + mobile/openapi/lib/api_helper.dart | 3 + mobile/openapi/lib/model/job_create_dto.dart | 98 +++++++++++++++++++ mobile/openapi/lib/model/manual_job_name.dart | 88 +++++++++++++++++ open-api/immich-openapi-specs.json | 52 ++++++++++ open-api/typescript-sdk/src/fetch-client.ts | 17 ++++ server/src/controllers/job.controller.ts | 10 +- server/src/dtos/job.dto.ts | 7 ++ server/src/enum.ts | 6 ++ server/src/interfaces/job.interface.ts | 6 ++ server/src/interfaces/tag.interface.ts | 1 + server/src/repositories/job.repository.ts | 3 + server/src/repositories/tag.repository.ts | 39 +++++++- server/src/services/job.service.ts | 28 +++++- server/src/services/microservices.service.ts | 3 + server/src/services/tag.service.ts | 6 ++ .../test/repositories/tag.repository.mock.ts | 1 + .../shared-components/combobox.svelte | 2 +- web/src/lib/i18n/en.json | 6 ++ web/src/routes/admin/jobs-status/+page.svelte | 62 +++++++++++- 23 files changed, 476 insertions(+), 10 deletions(-) create mode 100644 mobile/openapi/lib/model/job_create_dto.dart create mode 100644 mobile/openapi/lib/model/manual_job_name.dart diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 36b2c7bbf4613..16f293f81a6d3 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -124,6 +124,7 @@ Class | Method | HTTP request | Description *FileReportsApi* | [**fixAuditFiles**](doc//FileReportsApi.md#fixauditfiles) | **POST** /reports/fix | *FileReportsApi* | [**getAuditFiles**](doc//FileReportsApi.md#getauditfiles) | **GET** /reports | *FileReportsApi* | [**getFileChecksums**](doc//FileReportsApi.md#getfilechecksums) | **POST** /reports/checksum | +*JobsApi* | [**createJob**](doc//JobsApi.md#createjob) | **POST** /jobs | *JobsApi* | [**getAllJobsStatus**](doc//JobsApi.md#getalljobsstatus) | **GET** /jobs | *JobsApi* | [**sendJobCommand**](doc//JobsApi.md#sendjobcommand) | **PUT** /jobs/{id} | *LibrariesApi* | [**createLibrary**](doc//LibrariesApi.md#createlibrary) | **POST** /libraries | @@ -330,6 +331,7 @@ Class | Method | HTTP request | Description - [JobCommand](doc//JobCommand.md) - [JobCommandDto](doc//JobCommandDto.md) - [JobCountsDto](doc//JobCountsDto.md) + - [JobCreateDto](doc//JobCreateDto.md) - [JobName](doc//JobName.md) - [JobSettingsDto](doc//JobSettingsDto.md) - [JobStatusDto](doc//JobStatusDto.md) @@ -341,6 +343,7 @@ Class | Method | HTTP request | Description - [LoginCredentialDto](doc//LoginCredentialDto.md) - [LoginResponseDto](doc//LoginResponseDto.md) - [LogoutResponseDto](doc//LogoutResponseDto.md) + - [ManualJobName](doc//ManualJobName.md) - [MapMarkerResponseDto](doc//MapMarkerResponseDto.md) - [MapReverseGeocodeResponseDto](doc//MapReverseGeocodeResponseDto.md) - [MapTheme](doc//MapTheme.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 091e900145ab3..915c70f08eb26 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -144,6 +144,7 @@ part 'model/image_format.dart'; part 'model/job_command.dart'; part 'model/job_command_dto.dart'; part 'model/job_counts_dto.dart'; +part 'model/job_create_dto.dart'; part 'model/job_name.dart'; part 'model/job_settings_dto.dart'; part 'model/job_status_dto.dart'; @@ -155,6 +156,7 @@ part 'model/log_level.dart'; part 'model/login_credential_dto.dart'; part 'model/login_response_dto.dart'; part 'model/logout_response_dto.dart'; +part 'model/manual_job_name.dart'; part 'model/map_marker_response_dto.dart'; part 'model/map_reverse_geocode_response_dto.dart'; part 'model/map_theme.dart'; diff --git a/mobile/openapi/lib/api/jobs_api.dart b/mobile/openapi/lib/api/jobs_api.dart index 5f9501d126f8e..78afc15c93580 100644 --- a/mobile/openapi/lib/api/jobs_api.dart +++ b/mobile/openapi/lib/api/jobs_api.dart @@ -16,6 +16,45 @@ class JobsApi { final ApiClient apiClient; + /// Performs an HTTP 'POST /jobs' operation and returns the [Response]. + /// Parameters: + /// + /// * [JobCreateDto] jobCreateDto (required): + Future createJobWithHttpInfo(JobCreateDto jobCreateDto,) async { + // ignore: prefer_const_declarations + final path = r'/jobs'; + + // ignore: prefer_final_locals + Object? postBody = jobCreateDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + path, + 'POST', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [JobCreateDto] jobCreateDto (required): + Future createJob(JobCreateDto jobCreateDto,) async { + final response = await createJobWithHttpInfo(jobCreateDto,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + } + /// Performs an HTTP 'GET /jobs' operation and returns the [Response]. Future getAllJobsStatusWithHttpInfo() async { // ignore: prefer_const_declarations diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 9ec00aecc87aa..6a40de730c002 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -343,6 +343,8 @@ class ApiClient { return JobCommandDto.fromJson(value); case 'JobCountsDto': return JobCountsDto.fromJson(value); + case 'JobCreateDto': + return JobCreateDto.fromJson(value); case 'JobName': return JobNameTypeTransformer().decode(value); case 'JobSettingsDto': @@ -365,6 +367,8 @@ class ApiClient { return LoginResponseDto.fromJson(value); case 'LogoutResponseDto': return LogoutResponseDto.fromJson(value); + case 'ManualJobName': + return ManualJobNameTypeTransformer().decode(value); case 'MapMarkerResponseDto': return MapMarkerResponseDto.fromJson(value); case 'MapReverseGeocodeResponseDto': diff --git a/mobile/openapi/lib/api_helper.dart b/mobile/openapi/lib/api_helper.dart index 8dcef880f59a4..0f3cc41097276 100644 --- a/mobile/openapi/lib/api_helper.dart +++ b/mobile/openapi/lib/api_helper.dart @@ -97,6 +97,9 @@ String parameterToString(dynamic value) { if (value is LogLevel) { return LogLevelTypeTransformer().encode(value).toString(); } + if (value is ManualJobName) { + return ManualJobNameTypeTransformer().encode(value).toString(); + } if (value is MapTheme) { return MapThemeTypeTransformer().encode(value).toString(); } diff --git a/mobile/openapi/lib/model/job_create_dto.dart b/mobile/openapi/lib/model/job_create_dto.dart new file mode 100644 index 0000000000000..a4734791bbced --- /dev/null +++ b/mobile/openapi/lib/model/job_create_dto.dart @@ -0,0 +1,98 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class JobCreateDto { + /// Returns a new [JobCreateDto] instance. + JobCreateDto({ + required this.name, + }); + + ManualJobName name; + + @override + bool operator ==(Object other) => identical(this, other) || other is JobCreateDto && + other.name == name; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (name.hashCode); + + @override + String toString() => 'JobCreateDto[name=$name]'; + + Map toJson() { + final json = {}; + json[r'name'] = this.name; + return json; + } + + /// Returns a new [JobCreateDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static JobCreateDto? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return JobCreateDto( + name: ManualJobName.fromJson(json[r'name'])!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = JobCreateDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = JobCreateDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of JobCreateDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = JobCreateDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'name', + }; +} + diff --git a/mobile/openapi/lib/model/manual_job_name.dart b/mobile/openapi/lib/model/manual_job_name.dart new file mode 100644 index 0000000000000..7e8d9d51b2bab --- /dev/null +++ b/mobile/openapi/lib/model/manual_job_name.dart @@ -0,0 +1,88 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + + +class ManualJobName { + /// Instantiate a new enum with the provided [value]. + const ManualJobName._(this.value); + + /// The underlying value of this enum member. + final String value; + + @override + String toString() => value; + + String toJson() => value; + + static const personCleanup = ManualJobName._(r'person-cleanup'); + static const tagCleanup = ManualJobName._(r'tag-cleanup'); + static const userCleanup = ManualJobName._(r'user-cleanup'); + + /// List of all possible values in this [enum][ManualJobName]. + static const values = [ + personCleanup, + tagCleanup, + userCleanup, + ]; + + static ManualJobName? fromJson(dynamic value) => ManualJobNameTypeTransformer().decode(value); + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = ManualJobName.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } +} + +/// Transformation class that can [encode] an instance of [ManualJobName] to String, +/// and [decode] dynamic data back to [ManualJobName]. +class ManualJobNameTypeTransformer { + factory ManualJobNameTypeTransformer() => _instance ??= const ManualJobNameTypeTransformer._(); + + const ManualJobNameTypeTransformer._(); + + String encode(ManualJobName data) => data.value; + + /// Decodes a [dynamic value][data] to a ManualJobName. + /// + /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, + /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] + /// cannot be decoded successfully, then an [UnimplementedError] is thrown. + /// + /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, + /// and users are still using an old app with the old code. + ManualJobName? decode(dynamic data, {bool allowNull = true}) { + if (data != null) { + switch (data) { + case r'person-cleanup': return ManualJobName.personCleanup; + case r'tag-cleanup': return ManualJobName.tagCleanup; + case r'user-cleanup': return ManualJobName.userCleanup; + default: + if (!allowNull) { + throw ArgumentError('Unknown enum value to decode: $data'); + } + } + } + return null; + } + + /// Singleton [ManualJobNameTypeTransformer] instance. + static ManualJobNameTypeTransformer? _instance; +} + diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index b4ec4505b9e2d..af79815563c70 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -2561,6 +2561,39 @@ "tags": [ "Jobs" ] + }, + "post": { + "operationId": "createJob", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JobCreateDto" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Jobs" + ] } }, "/jobs/{id}": { @@ -9269,6 +9302,17 @@ ], "type": "object" }, + "JobCreateDto": { + "properties": { + "name": { + "$ref": "#/components/schemas/ManualJobName" + } + }, + "required": [ + "name" + ], + "type": "object" + }, "JobName": { "enum": [ "thumbnailGeneration", @@ -9511,6 +9555,14 @@ ], "type": "object" }, + "ManualJobName": { + "enum": [ + "person-cleanup", + "tag-cleanup", + "user-cleanup" + ], + "type": "string" + }, "MapMarkerResponseDto": { "properties": { "city": { diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 9350bd5604507..da57313692dc2 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -548,6 +548,9 @@ export type AllJobStatusResponseDto = { thumbnailGeneration: JobStatusDto; videoConversion: JobStatusDto; }; +export type JobCreateDto = { + name: ManualJobName; +}; export type JobCommandDto = { command: JobCommand; force: boolean; @@ -1941,6 +1944,15 @@ export function getAllJobsStatus(opts?: Oazapfts.RequestOpts) { ...opts })); } +export function createJob({ jobCreateDto }: { + jobCreateDto: JobCreateDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText("/jobs", oazapfts.json({ + ...opts, + method: "POST", + body: jobCreateDto + }))); +} export function sendJobCommand({ id, jobCommandDto }: { id: JobName; jobCommandDto: JobCommandDto; @@ -3364,6 +3376,11 @@ export enum EntityType { Asset = "ASSET", Album = "ALBUM" } +export enum ManualJobName { + PersonCleanup = "person-cleanup", + TagCleanup = "tag-cleanup", + UserCleanup = "user-cleanup" +} export enum JobName { ThumbnailGeneration = "thumbnailGeneration", MetadataExtraction = "metadataExtraction", diff --git a/server/src/controllers/job.controller.ts b/server/src/controllers/job.controller.ts index 2aa5920fab7b8..7da19e207fce0 100644 --- a/server/src/controllers/job.controller.ts +++ b/server/src/controllers/job.controller.ts @@ -1,6 +1,6 @@ -import { Body, Controller, Get, Param, Put } from '@nestjs/common'; +import { Body, Controller, Get, Param, Post, Put } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; -import { AllJobStatusResponseDto, JobCommandDto, JobIdParamDto, JobStatusDto } from 'src/dtos/job.dto'; +import { AllJobStatusResponseDto, JobCommandDto, JobCreateDto, JobIdParamDto, JobStatusDto } from 'src/dtos/job.dto'; import { Authenticated } from 'src/middleware/auth.guard'; import { JobService } from 'src/services/job.service'; @@ -15,6 +15,12 @@ export class JobController { return this.service.getAllJobsStatus(); } + @Post() + @Authenticated({ admin: true }) + createJob(@Body() dto: JobCreateDto): Promise { + return this.service.create(dto); + } + @Put(':id') @Authenticated({ admin: true }) sendJobCommand(@Param() { id }: JobIdParamDto, @Body() dto: JobCommandDto): Promise { diff --git a/server/src/dtos/job.dto.ts b/server/src/dtos/job.dto.ts index b7d8cf59bf55a..895f710b7a782 100644 --- a/server/src/dtos/job.dto.ts +++ b/server/src/dtos/job.dto.ts @@ -1,5 +1,6 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsEnum, IsNotEmpty } from 'class-validator'; +import { ManualJobName } from 'src/enum'; import { JobCommand, QueueName } from 'src/interfaces/job.interface'; import { ValidateBoolean } from 'src/validation'; @@ -20,6 +21,12 @@ export class JobCommandDto { force!: boolean; } +export class JobCreateDto { + @IsEnum(ManualJobName) + @ApiProperty({ type: 'string', enum: ManualJobName, enumName: 'ManualJobName' }) + name!: ManualJobName; +} + export class JobCountsDto { @ApiProperty({ type: 'integer' }) active!: number; diff --git a/server/src/enum.ts b/server/src/enum.ts index 32254854e4c5a..d76d97371ce48 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -186,3 +186,9 @@ export enum SourceType { MACHINE_LEARNING = 'machine-learning', EXIF = 'exif', } + +export enum ManualJobName { + PERSON_CLEANUP = 'person-cleanup', + TAG_CLEANUP = 'tag-cleanup', + USER_CLEANUP = 'user-cleanup', +} diff --git a/server/src/interfaces/job.interface.ts b/server/src/interfaces/job.interface.ts index a0533fa63f9c0..d0a15bfa5dc00 100644 --- a/server/src/interfaces/job.interface.ts +++ b/server/src/interfaces/job.interface.ts @@ -60,6 +60,9 @@ export enum JobName { STORAGE_TEMPLATE_MIGRATION = 'storage-template-migration', STORAGE_TEMPLATE_MIGRATION_SINGLE = 'storage-template-migration-single', + // tags + TAG_CLEANUP = 'tag-cleanup', + // migration QUEUE_MIGRATION = 'queue-migration', MIGRATE_ASSET = 'migrate-asset', @@ -262,6 +265,9 @@ export type JobItem = | { name: JobName.CLEAN_OLD_AUDIT_LOGS; data?: IBaseJob } | { name: JobName.CLEAN_OLD_SESSION_TOKENS; data?: IBaseJob } + // Tags + | { name: JobName.TAG_CLEANUP; data?: IBaseJob } + // Asset Deletion | { name: JobName.PERSON_CLEANUP; data?: IBaseJob } | { name: JobName.ASSET_DELETION; data: IAssetDeleteJob } diff --git a/server/src/interfaces/tag.interface.ts b/server/src/interfaces/tag.interface.ts index aca9c223d552b..16a34d6ac4960 100644 --- a/server/src/interfaces/tag.interface.ts +++ b/server/src/interfaces/tag.interface.ts @@ -17,4 +17,5 @@ export interface ITagRepository extends IBulkAsset { upsertAssetTags({ assetId, tagIds }: { assetId: string; tagIds: string[] }): Promise; upsertAssetIds(items: AssetTagItem[]): Promise; + deleteEmptyTags(): Promise; } diff --git a/server/src/repositories/job.repository.ts b/server/src/repositories/job.repository.ts index f64e5175e5127..2981fa4bddcd8 100644 --- a/server/src/repositories/job.repository.ts +++ b/server/src/repositories/job.repository.ts @@ -41,6 +41,9 @@ export const JOBS_TO_QUEUE: Record = { [JobName.GENERATE_THUMBHASH]: QueueName.THUMBNAIL_GENERATION, [JobName.GENERATE_PERSON_THUMBNAIL]: QueueName.THUMBNAIL_GENERATION, + // tags + [JobName.TAG_CLEANUP]: QueueName.BACKGROUND_TASK, + // metadata [JobName.QUEUE_METADATA_EXTRACTION]: QueueName.METADATA_EXTRACTION, [JobName.METADATA_EXTRACTION]: QueueName.METADATA_EXTRACTION, diff --git a/server/src/repositories/tag.repository.ts b/server/src/repositories/tag.repository.ts index 9389aeb13b4e3..1a5415b8dbb08 100644 --- a/server/src/repositories/tag.repository.ts +++ b/server/src/repositories/tag.repository.ts @@ -1,10 +1,11 @@ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; import { Chunked, ChunkedSet, DummyValue, GenerateSql } from 'src/decorators'; import { TagEntity } from 'src/entities/tag.entity'; +import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { AssetTagItem, ITagRepository } from 'src/interfaces/tag.interface'; import { Instrumentation } from 'src/utils/instrumentation'; -import { DataSource, In, Repository } from 'typeorm'; +import { DataSource, In, Repository, TreeRepository } from 'typeorm'; @Instrumentation() @Injectable() @@ -12,7 +13,11 @@ export class TagRepository implements ITagRepository { constructor( @InjectDataSource() private dataSource: DataSource, @InjectRepository(TagEntity) private repository: Repository, - ) {} + @InjectRepository(TagEntity) private tree: TreeRepository, + @Inject(ILoggerRepository) private logger: ILoggerRepository, + ) { + this.logger.setContext(TagRepository.name); + } get(id: string): Promise { return this.repository.findOne({ where: { id } }); @@ -174,6 +179,34 @@ export class TagRepository implements ITagRepository { }); } + async deleteEmptyTags() { + await this.dataSource.transaction(async (manager) => { + const ids = new Set(); + const tags = await manager.find(TagEntity); + for (const tag of tags) { + const count = await manager + .createQueryBuilder('assets', 'asset') + .innerJoin( + 'asset.tags', + 'asset_tags', + 'asset_tags.id IN (SELECT id_descendant FROM tags_closure WHERE id_ancestor = :tagId)', + { tagId: tag.id }, + ) + .getCount(); + + if (count === 0) { + this.logger.debug(`Found empty tag: ${tag.id} - ${tag.value}`); + ids.add(tag.id); + } + } + + if (ids.size > 0) { + await manager.delete(TagEntity, { id: In([...ids]) }); + this.logger.log(`Deleted ${ids.size} empty tags`); + } + }); + } + private async save(partial: Partial): Promise { const { id } = await this.repository.save(partial); return this.repository.findOneOrFail({ where: { id } }); diff --git a/server/src/services/job.service.ts b/server/src/services/job.service.ts index aa61ccf3cb229..03a6edf126e3a 100644 --- a/server/src/services/job.service.ts +++ b/server/src/services/job.service.ts @@ -2,8 +2,8 @@ import { BadRequestException, Inject, Injectable } from '@nestjs/common'; import { snakeCase } from 'lodash'; import { SystemConfigCore } from 'src/cores/system-config.core'; import { mapAsset } from 'src/dtos/asset-response.dto'; -import { AllJobStatusResponseDto, JobCommandDto, JobStatusDto } from 'src/dtos/job.dto'; -import { AssetType } from 'src/enum'; +import { AllJobStatusResponseDto, JobCommandDto, JobCreateDto, JobStatusDto } from 'src/dtos/job.dto'; +import { AssetType, ManualJobName } from 'src/enum'; import { IAssetRepository } from 'src/interfaces/asset.interface'; import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface'; import { @@ -22,6 +22,26 @@ import { IMetricRepository } from 'src/interfaces/metric.interface'; import { IPersonRepository } from 'src/interfaces/person.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +const asJobItem = (dto: JobCreateDto): JobItem => { + switch (dto.name) { + case ManualJobName.TAG_CLEANUP: { + return { name: JobName.TAG_CLEANUP }; + } + + case ManualJobName.PERSON_CLEANUP: { + return { name: JobName.PERSON_CLEANUP }; + } + + case ManualJobName.USER_CLEANUP: { + return { name: JobName.USER_DELETE_CHECK }; + } + + default: { + throw new BadRequestException('Invalid job name'); + } + } +}; + @Injectable() export class JobService { private configCore: SystemConfigCore; @@ -39,6 +59,10 @@ export class JobService { this.configCore = SystemConfigCore.create(systemMetadataRepository, logger); } + async create(dto: JobCreateDto): Promise { + await this.jobRepository.queue(asJobItem(dto)); + } + async handleCommand(queueName: QueueName, dto: JobCommandDto): Promise { this.logger.debug(`Handling command: queue=${queueName},force=${dto.force}`); diff --git a/server/src/services/microservices.service.ts b/server/src/services/microservices.service.ts index 025400cc9bde3..df4b072d56400 100644 --- a/server/src/services/microservices.service.ts +++ b/server/src/services/microservices.service.ts @@ -15,6 +15,7 @@ import { SessionService } from 'src/services/session.service'; import { SmartInfoService } from 'src/services/smart-info.service'; import { StorageTemplateService } from 'src/services/storage-template.service'; import { StorageService } from 'src/services/storage.service'; +import { TagService } from 'src/services/tag.service'; import { UserService } from 'src/services/user.service'; import { VersionService } from 'src/services/version.service'; import { otelShutdown } from 'src/utils/instrumentation'; @@ -34,6 +35,7 @@ export class MicroservicesService { private sessionService: SessionService, private storageTemplateService: StorageTemplateService, private storageService: StorageService, + private tagService: TagService, private userService: UserService, private duplicateService: DuplicateService, private versionService: VersionService, @@ -93,6 +95,7 @@ export class MicroservicesService { [JobName.NOTIFY_ALBUM_INVITE]: (data) => this.notificationService.handleAlbumInvite(data), [JobName.NOTIFY_ALBUM_UPDATE]: (data) => this.notificationService.handleAlbumUpdate(data), [JobName.NOTIFY_SIGNUP]: (data) => this.notificationService.handleUserSignup(data), + [JobName.TAG_CLEANUP]: () => this.tagService.handleTagCleanup(), [JobName.VERSION_CHECK]: () => this.versionService.handleVersionCheck(), }); } diff --git a/server/src/services/tag.service.ts b/server/src/services/tag.service.ts index 97b0ef1be6843..cc6d64f749d20 100644 --- a/server/src/services/tag.service.ts +++ b/server/src/services/tag.service.ts @@ -14,6 +14,7 @@ import { TagEntity } from 'src/entities/tag.entity'; import { Permission } from 'src/enum'; import { IAccessRepository } from 'src/interfaces/access.interface'; import { IEventRepository } from 'src/interfaces/event.interface'; +import { JobStatus } from 'src/interfaces/job.interface'; import { AssetTagItem, ITagRepository } from 'src/interfaces/tag.interface'; import { checkAccess, requireAccess } from 'src/utils/access'; import { addAssets, removeAssets } from 'src/utils/asset.util'; @@ -138,6 +139,11 @@ export class TagService { return results; } + async handleTagCleanup() { + await this.repository.deleteEmptyTags(); + return JobStatus.SUCCESS; + } + private async findOrFail(id: string) { const tag = await this.repository.get(id); if (!tag) { diff --git a/server/test/repositories/tag.repository.mock.ts b/server/test/repositories/tag.repository.mock.ts index a3fc0e77e0312..acc2b59f6d686 100644 --- a/server/test/repositories/tag.repository.mock.ts +++ b/server/test/repositories/tag.repository.mock.ts @@ -17,5 +17,6 @@ export const newTagRepositoryMock = (): Mocked => { addAssetIds: vitest.fn(), removeAssetIds: vitest.fn(), upsertAssetIds: vitest.fn(), + deleteEmptyTags: vitest.fn(), }; }; diff --git a/web/src/lib/components/shared-components/combobox.svelte b/web/src/lib/components/shared-components/combobox.svelte index d3e022a75933c..7c71fe8aeaed7 100644 --- a/web/src/lib/components/shared-components/combobox.svelte +++ b/web/src/lib/components/shared-components/combobox.svelte @@ -220,7 +220,7 @@ role="listbox" id={listboxId} transition:fly={{ duration: 250 }} - class="absolute text-left text-sm w-full max-h-64 overflow-y-auto bg-white dark:bg-gray-800 border-t-0 border-gray-300 dark:border-gray-900 rounded-b-xl z-10" + class="absolute text-left text-sm w-full max-h-64 overflow-y-auto bg-white dark:bg-gray-800 border-t-0 border-gray-300 dark:border-gray-900 rounded-b-xl z-[10000]" class:border={isOpen} tabindex="-1" > diff --git a/web/src/lib/i18n/en.json b/web/src/lib/i18n/en.json index f880dab34737a..a788666050643 100644 --- a/web/src/lib/i18n/en.json +++ b/web/src/lib/i18n/en.json @@ -41,6 +41,7 @@ "confirm_email_below": "To confirm, type \"{email}\" below", "confirm_reprocess_all_faces": "Are you sure you want to reprocess all faces? This will also clear named people.", "confirm_user_password_reset": "Are you sure you want to reset {user}'s password?", + "create_job": "Create job", "disable_login": "Disable login", "duplicate_detection_job_description": "Run machine learning on assets to detect similar images. Relies on Smart Search", "exclusion_pattern_description": "Exclusion patterns lets you ignore files and folders when scanning your library. This is useful if you have folders that contain files you don't want to import, such as RAW files.", @@ -68,6 +69,7 @@ "image_thumbnail_resolution": "Thumbnail resolution", "image_thumbnail_resolution_description": "Used when viewing groups of photos (main timeline, album view, etc.). Higher resolutions can preserve more detail but take longer to encode, have larger file sizes, and can reduce app responsiveness.", "job_concurrency": "{job} concurrency", + "job_created": "Job created", "job_not_concurrency_safe": "This job is not concurrency-safe.", "job_settings": "Job Settings", "job_settings_description": "Manage job concurrency", @@ -196,6 +198,7 @@ "password_settings": "Password Login", "password_settings_description": "Manage password login settings", "paths_validated_successfully": "All paths validated successfully", + "person_cleanup_job": "Person cleanup", "quota_size_gib": "Quota Size (GiB)", "refreshing_all_libraries": "Refreshing all libraries", "registration": "Admin Registration", @@ -209,6 +212,7 @@ "reset_settings_to_recent_saved": "Reset settings to the recent saved settings", "scanning_library_for_changed_files": "Scanning library for changed files", "scanning_library_for_new_files": "Scanning library for new files", + "search_jobs": "Search jobs...", "send_welcome_email": "Send welcome email", "server_external_domain_settings": "External domain", "server_external_domain_settings_description": "Domain for public shared links, including http(s)://", @@ -236,6 +240,7 @@ "storage_template_settings_description": "Manage the folder structure and file name of the upload asset", "storage_template_user_label": "{label} is the user's Storage Label", "system_settings": "System Settings", + "tag_cleanup_job": "Tag cleanup", "theme_custom_css_settings": "Custom CSS", "theme_custom_css_settings_description": "Cascading Style Sheets allow the design of Immich to be customized.", "theme_settings": "Theme Settings", @@ -309,6 +314,7 @@ "trash_settings_description": "Manage trash settings", "untracked_files": "Untracked Files", "untracked_files_description": "These files are not tracked by the application. They can be the results of failed moves, interrupted uploads, or left behind due to a bug", + "user_cleanup_job": "User cleanup", "user_delete_delay": "{user}'s account and assets will be scheduled for permanent deletion in {delay, plural, one {# day} other {# days}}.", "user_delete_delay_settings": "Delete delay", "user_delete_delay_settings_description": "Number of days after removal to permanently delete a user's account and assets. The user deletion job runs at midnight to check for users that are ready for deletion. Changes to this setting will be evaluated at the next execution.", diff --git a/web/src/routes/admin/jobs-status/+page.svelte b/web/src/routes/admin/jobs-status/+page.svelte index dcd6630a01c56..16c2541e61b53 100644 --- a/web/src/routes/admin/jobs-status/+page.svelte +++ b/web/src/routes/admin/jobs-status/+page.svelte @@ -3,10 +3,17 @@ import LinkButton from '$lib/components/elements/buttons/link-button.svelte'; import Icon from '$lib/components/elements/icon.svelte'; import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte'; + import Combobox, { type ComboBoxOption } from '$lib/components/shared-components/combobox.svelte'; + import ConfirmDialog from '$lib/components/shared-components/dialog/confirm-dialog.svelte'; + import { + notificationController, + NotificationType, + } from '$lib/components/shared-components/notification/notification'; import { AppRoute } from '$lib/constants'; import { asyncTimeout } from '$lib/utils'; - import { getAllJobsStatus, type AllJobStatusResponseDto } from '@immich/sdk'; - import { mdiCog } from '@mdi/js'; + import { handleError } from '$lib/utils/handle-error'; + import { createJob, getAllJobsStatus, ManualJobName, type AllJobStatusResponseDto } from '@immich/sdk'; + import { mdiCog, mdiPlus } from '@mdi/js'; import { onDestroy, onMount } from 'svelte'; import { t } from 'svelte-i18n'; import type { PageData } from './$types'; @@ -16,6 +23,8 @@ let jobs: AllJobStatusResponseDto; let running = true; + let isOpen = false; + let selectedJob: ComboBoxOption | undefined = undefined; onMount(async () => { while (running) { @@ -27,10 +36,38 @@ onDestroy(() => { running = false; }); + + const options = [ + { title: $t('admin.person_cleanup_job'), value: ManualJobName.PersonCleanup }, + { title: $t('admin.tag_cleanup_job'), value: ManualJobName.TagCleanup }, + { title: $t('admin.user_cleanup_job'), value: ManualJobName.UserCleanup }, + ].map(({ value, title }) => ({ id: value, label: title, value })); + + const handleCancel = () => (isOpen = false); + + const handleCreate = async () => { + if (!selectedJob) { + return; + } + + try { + await createJob({ jobCreateDto: { name: selectedJob.value as ManualJobName } }); + notificationController.show({ message: $t('admin.job_created'), type: NotificationType.Info }); + handleCancel(); + } catch (error) { + handleError(error, $t('errors.unable_to_submit_job')); + } + };
+ (isOpen = true)}> +
+ + {$t('admin.create_job')} +
+
@@ -46,3 +83,24 @@ + +{#if isOpen} + +
+
+ +
+
+
+{/if} From 186b4e133336300a1ead4876c9838e0a23b310c9 Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 16 Sep 2024 15:51:03 -0500 Subject: [PATCH 036/123] feat(web): improve UI/UX for settings pages (#12626) * fix(web): local date time for buckets * feat(web): improve UI/UX for setting pages * search admin settings and icon * clean up * fix translation file * Update web/src/routes/admin/system-settings/+page.svelte Co-authored-by: Ben <45583362+ben-basten@users.noreply.github.com> * Update web/src/lib/components/shared-components/settings/setting-accordion.svelte Co-authored-by: Ben <45583362+ben-basten@users.noreply.github.com> * better search bar on smaller screen * lint * template syntax --------- Co-authored-by: Jason Rasmussen Co-authored-by: Ben <45583362+ben-basten@users.noreply.github.com> --- .../settings/auth/auth-settings.svelte | 2 +- .../settings/setting-accordion.svelte | 22 +++++-- .../feature-settings.svelte | 2 +- .../user-settings-list.svelte | 57 ++++++++++++++--- web/src/lib/i18n/en.json | 1 + .../routes/admin/system-settings/+page.svelte | 62 +++++++++++++++++-- 6 files changed, 126 insertions(+), 20 deletions(-) diff --git a/web/src/lib/components/admin-page/settings/auth/auth-settings.svelte b/web/src/lib/components/admin-page/settings/auth/auth-settings.svelte index 37f875c604f16..9b0e4b32706b5 100644 --- a/web/src/lib/components/admin-page/settings/auth/auth-settings.svelte +++ b/web/src/lib/components/admin-page/settings/auth/auth-settings.svelte @@ -71,7 +71,7 @@
-
+
-
+
{/each} diff --git a/web/src/lib/components/shared-components/settings/setting-switch.svelte b/web/src/lib/components/shared-components/settings/setting-switch.svelte index d933b27ab54fb..24b539f0a1c22 100644 --- a/web/src/lib/components/shared-components/settings/setting-switch.svelte +++ b/web/src/lib/components/shared-components/settings/setting-switch.svelte @@ -43,11 +43,5 @@
- onToggle(detail)} - ariaDescribedBy={subtitleId} - /> +
diff --git a/web/src/lib/components/shared-components/show-shortcuts.svelte b/web/src/lib/components/shared-components/show-shortcuts.svelte index ebc0dd688c1a6..2bd1b8976bd17 100644 --- a/web/src/lib/components/shared-components/show-shortcuts.svelte +++ b/web/src/lib/components/shared-components/show-shortcuts.svelte @@ -1,9 +1,8 @@ - dispatch('close')}> +
{#if shortcuts.general.length > 0}
diff --git a/web/src/lib/components/user-settings-page/device-card.svelte b/web/src/lib/components/user-settings-page/device-card.svelte index 676e9843641c3..d43977ea08a77 100644 --- a/web/src/lib/components/user-settings-page/device-card.svelte +++ b/web/src/lib/components/user-settings-page/device-card.svelte @@ -15,14 +15,10 @@ mdiUbuntu, } from '@mdi/js'; import { DateTime, type ToRelativeCalendarOptions } from 'luxon'; - import { createEventDispatcher } from 'svelte'; import { t } from 'svelte-i18n'; export let device: SessionResponseDto; - - const dispatcher = createEventDispatcher<{ - delete: void; - }>(); + export let onDelete: (() => void) | undefined = undefined; const options: ToRelativeCalendarOptions = { unit: 'days', @@ -68,14 +64,14 @@
- {#if !device.current} + {#if !device.current && onDelete}
dispatcher('delete')} + on:click={onDelete} />
{/if} diff --git a/web/src/lib/components/user-settings-page/device-list.svelte b/web/src/lib/components/user-settings-page/device-list.svelte index 57299bb46fb22..26e03c35d8acd 100644 --- a/web/src/lib/components/user-settings-page/device-list.svelte +++ b/web/src/lib/components/user-settings-page/device-list.svelte @@ -68,7 +68,7 @@ {$t('other_devices').toUpperCase()} {#each otherDevices as device, index} - handleDelete(device)} /> + handleDelete(device)} /> {#if index !== otherDevices.length - 1}
{/if} diff --git a/web/src/lib/components/user-settings-page/partner-selection-modal.svelte b/web/src/lib/components/user-settings-page/partner-selection-modal.svelte index 3cff1cd1de2bc..8ab747aa276da 100644 --- a/web/src/lib/components/user-settings-page/partner-selection-modal.svelte +++ b/web/src/lib/components/user-settings-page/partner-selection-modal.svelte @@ -1,19 +1,18 @@ - dispatch('close')}> +

{$t('settings').toUpperCase()}

@@ -68,14 +64,14 @@ title={$t('comments_and_likes')} subtitle={$t('let_others_respond')} checked={album.isActivityEnabled} - on:toggle={() => dispatch('toggleEnableActivity')} + onToggle={onToggleEnabledActivity} />
{$t('people').toUpperCase()}
-
{/key} @@ -152,10 +151,8 @@ rounded="full" disabled={Object.keys(selectedUsers).length === 0} on:click={() => - dispatch( - 'select', - Object.values(selectedUsers).map(({ user, ...rest }) => ({ userId: user.id, ...rest })), - )}>{$t('add')} ({ userId: user.id, ...rest })))} + >{$t('add')}
{/if} @@ -166,7 +163,7 @@ -
- onSelect(detail)} /> + diff --git a/web/src/lib/components/faces-page/merge-suggestion-modal.svelte b/web/src/lib/components/faces-page/merge-suggestion-modal.svelte index d781e1cc562fb..f869790ebab0b 100644 --- a/web/src/lib/components/faces-page/merge-suggestion-modal.svelte +++ b/web/src/lib/components/faces-page/merge-suggestion-modal.svelte @@ -4,7 +4,6 @@ import { getPeopleThumbnailUrl } from '$lib/utils'; import { type PersonResponseDto } from '@immich/sdk'; import { mdiArrowLeft, mdiMerge } from '@mdi/js'; - import { createEventDispatcher } from 'svelte'; import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte'; import Button from '../elements/buttons/button.svelte'; import CircleIconButton from '../elements/buttons/circle-icon-button.svelte'; @@ -13,25 +12,22 @@ export let personMerge1: PersonResponseDto; export let personMerge2: PersonResponseDto; export let potentialMergePeople: PersonResponseDto[]; + export let onReject: () => void; + export let onConfirm: ([personMerge1, personMerge2]: [PersonResponseDto, PersonResponseDto]) => void; + export let onClose: () => void; let choosePersonToMerge = false; const title = personMerge2.name; - const dispatch = createEventDispatcher<{ - reject: void; - confirm: [PersonResponseDto, PersonResponseDto]; - close: void; - }>(); - - const changePersonToMerge = (newperson: PersonResponseDto) => { - const index = potentialMergePeople.indexOf(newperson); + const changePersonToMerge = (newPerson: PersonResponseDto) => { + const index = potentialMergePeople.indexOf(newPerson); [potentialMergePeople[index], personMerge2] = [personMerge2, potentialMergePeople[index]]; choosePersonToMerge = false; }; - dispatch('close')}> +
{#if !choosePersonToMerge}
@@ -105,7 +101,7 @@

{$t('they_will_be_merged_together')}

- - + + diff --git a/web/src/lib/components/faces-page/people-card.svelte b/web/src/lib/components/faces-page/people-card.svelte index 21f48e42ebfc4..6791a26232e48 100644 --- a/web/src/lib/components/faces-page/people-card.svelte +++ b/web/src/lib/components/faces-page/people-card.svelte @@ -9,7 +9,6 @@ mdiDotsVertical, mdiEyeOffOutline, } from '@mdi/js'; - import { createEventDispatcher } from 'svelte'; import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte'; import MenuOption from '../shared-components/context-menu/menu-option.svelte'; import { t } from 'svelte-i18n'; @@ -18,19 +17,12 @@ export let person: PersonResponseDto; export let preload = false; - - type MenuItemEvent = 'change-name' | 'set-birth-date' | 'merge-people' | 'hide-person'; - let dispatch = createEventDispatcher<{ - 'change-name': void; - 'set-birth-date': void; - 'merge-people': void; - 'hide-person': void; - }>(); + export let onChangeName: () => void; + export let onSetBirthDate: () => void; + export let onMergePeople: () => void; + export let onHidePerson: () => void; let showVerticalDots = false; - const onMenuClick = (event: MenuItemEvent) => { - dispatch(event); - };
- onMenuClick('hide-person')} icon={mdiEyeOffOutline} text={$t('hide_person')} /> - onMenuClick('change-name')} icon={mdiAccountEditOutline} text={$t('change_name')} /> - onMenuClick('set-birth-date')} - icon={mdiCalendarEditOutline} - text={$t('set_date_of_birth')} - /> - onMenuClick('merge-people')} - icon={mdiAccountMultipleCheckOutline} - text={$t('merge_people')} - /> + + + +
{/if} diff --git a/web/src/lib/components/faces-page/people-list.svelte b/web/src/lib/components/faces-page/people-list.svelte index 5130baf30b89a..230c8750aedee 100644 --- a/web/src/lib/components/faces-page/people-list.svelte +++ b/web/src/lib/components/faces-page/people-list.svelte @@ -1,6 +1,5 @@ - +

{$t('birthdate_set_description')}

- handleSubmit()} autocomplete="off" id="set-birth-date-form"> + onUpdate(birthDate)} autocomplete="off" id="set-birth-date-form">
- + diff --git a/web/src/lib/components/faces-page/unmerge-face-selector.svelte b/web/src/lib/components/faces-page/unmerge-face-selector.svelte index c89c8338d3791..753e46c2199f0 100644 --- a/web/src/lib/components/faces-page/unmerge-face-selector.svelte +++ b/web/src/lib/components/faces-page/unmerge-face-selector.svelte @@ -10,7 +10,7 @@ type PersonResponseDto, } from '@immich/sdk'; import { mdiMerge, mdiPlus } from '@mdi/js'; - import { createEventDispatcher, onMount } from 'svelte'; + import { onMount } from 'svelte'; import { quintOut } from 'svelte/easing'; import { fly } from 'svelte/transition'; import Button from '../elements/buttons/button.svelte'; @@ -23,6 +23,8 @@ export let assetIds: string[]; export let personAssets: PersonResponseDto; + export let onConfirm: () => void; + export let onClose: () => void; let people: PersonResponseDto[] = []; let selectedPerson: PersonResponseDto | null = null; @@ -34,11 +36,6 @@ $: peopleToNotShow = selectedPerson ? [personAssets, selectedPerson] : [personAssets]; - let dispatch = createEventDispatcher<{ - confirm: void; - close: void; - }>(); - const selectedPeople: AssetFaceUpdateItem[] = []; for (const assetId of assetIds) { @@ -50,10 +47,6 @@ people = data.people; }); - const onClose = () => { - dispatch('close'); - }; - const handleSelectedPerson = (person: PersonResponseDto) => { if (selectedPerson && selectedPerson.id === person.id) { handleRemoveSelectedPerson(); @@ -87,7 +80,7 @@ } showLoadingSpinnerCreate = false; - dispatch('confirm'); + onConfirm(); }; const handleReassign = async () => { @@ -113,7 +106,7 @@ } showLoadingSpinnerReassign = false; - dispatch('confirm'); + onConfirm(); }; @@ -123,7 +116,7 @@ transition:fly={{ y: 500, duration: 100, easing: quintOut }} class="absolute left-0 top-0 z-[9999] h-full w-full bg-immich-bg dark:bg-immich-dark-bg" > - +
@@ -180,7 +173,7 @@
{/if} - handleSelectedPerson(detail)} /> + diff --git a/web/src/lib/components/forms/api-key-secret.svelte b/web/src/lib/components/forms/api-key-secret.svelte index b7bf8e1836270..f43e1da38e83a 100644 --- a/web/src/lib/components/forms/api-key-secret.svelte +++ b/web/src/lib/components/forms/api-key-secret.svelte @@ -1,20 +1,15 @@ - handleDone()}> +

{$t('api_key_description')} @@ -28,6 +23,6 @@ - + diff --git a/web/src/lib/components/forms/change-password-form.svelte b/web/src/lib/components/forms/change-password-form.svelte index 799dde7ef3787..cbf2ff07f0f00 100644 --- a/web/src/lib/components/forms/change-password-form.svelte +++ b/web/src/lib/components/forms/change-password-form.svelte @@ -1,10 +1,11 @@ diff --git a/web/src/lib/components/forms/create-user-form.svelte b/web/src/lib/components/forms/create-user-form.svelte index 8f049685a4930..9c4b83002b77a 100644 --- a/web/src/lib/components/forms/create-user-form.svelte +++ b/web/src/lib/components/forms/create-user-form.svelte @@ -5,13 +5,14 @@ import { ByteUnit, convertToBytes } from '$lib/utils/byte-units'; import { handleError } from '$lib/utils/handle-error'; import { createUserAdmin } from '@immich/sdk'; - import { createEventDispatcher } from 'svelte'; import { t } from 'svelte-i18n'; import Button from '../elements/buttons/button.svelte'; import Slider from '../elements/slider.svelte'; import PasswordField from '../shared-components/password-field.svelte'; export let onClose: () => void; + export let onSubmit: () => void; + export let onCancel: () => void; let error: string; let success: string; @@ -39,10 +40,6 @@ canCreateUser = true; } } - const dispatch = createEventDispatcher<{ - submit: void; - cancel: void; - }>(); async function registerUser() { if (canCreateUser && !isCreatingUser) { @@ -63,7 +60,7 @@ success = $t('new_user_created'); - dispatch('submit'); + onSubmit(); return; } catch (error) { @@ -132,7 +129,7 @@ {/if} - + diff --git a/web/src/lib/components/forms/edit-user-form.svelte b/web/src/lib/components/forms/edit-user-form.svelte index b326565122d87..0079a695bc3f7 100644 --- a/web/src/lib/components/forms/edit-user-form.svelte +++ b/web/src/lib/components/forms/edit-user-form.svelte @@ -5,7 +5,6 @@ import { handleError } from '$lib/utils/handle-error'; import { updateUserAdmin, type UserAdminResponseDto } from '@immich/sdk'; import { mdiAccountEditOutline } from '@mdi/js'; - import { createEventDispatcher } from 'svelte'; import Button from '../elements/buttons/button.svelte'; import { dialogController } from '$lib/components/shared-components/dialog/dialog'; import { t } from 'svelte-i18n'; @@ -15,6 +14,8 @@ export let canResetPassword = true; export let newPassword: string; export let onClose: () => void; + export let onResetPasswordSuccess: () => void; + export let onEditSuccess: () => void; let error: string; let success: string; @@ -27,12 +28,6 @@ !!quotaSize && convertToBytes(Number(quotaSize), ByteUnit.GiB) > $serverInfo.diskSizeRaw; - const dispatch = createEventDispatcher<{ - close: void; - resetPasswordSuccess: void; - editSuccess: void; - }>(); - const editUser = async () => { try { const { id, email, name, storageLabel } = user; @@ -46,7 +41,7 @@ }, }); - dispatch('editSuccess'); + onEditSuccess(); } catch (error) { handleError(error, $t('errors.unable_to_update_user')); } @@ -72,7 +67,7 @@ }, }); - dispatch('resetPasswordSuccess'); + onResetPasswordSuccess(); } catch (error) { handleError(error, $t('errors.unable_to_reset_password')); } diff --git a/web/src/lib/components/forms/library-exclusion-pattern-form.svelte b/web/src/lib/components/forms/library-exclusion-pattern-form.svelte index c09f1fbaf6bbb..05d47c0a0fbee 100644 --- a/web/src/lib/components/forms/library-exclusion-pattern-form.svelte +++ b/web/src/lib/components/forms/library-exclusion-pattern-form.svelte @@ -1,5 +1,4 @@ - -

handleSubmit()} autocomplete="off" id="add-exclusion-pattern-form"> + + onSubmit(exclusionPattern)} autocomplete="off" id="add-exclusion-pattern-form">

{$t('admin.exclusion_pattern_description')}

@@ -53,9 +47,9 @@

- + {#if isEditing} - + {/if} diff --git a/web/src/lib/components/forms/library-import-path-form.svelte b/web/src/lib/components/forms/library-import-path-form.svelte index f82d5733866bf..8bfca80aecb32 100644 --- a/web/src/lib/components/forms/library-import-path-form.svelte +++ b/web/src/lib/components/forms/library-import-path-form.svelte @@ -1,5 +1,4 @@ - -
handleSubmit()} autocomplete="off" id="library-import-path-form"> + + onSubmit(importPath)} autocomplete="off" id="library-import-path-form">

{$t('admin.library_import_path_description')}

@@ -47,9 +41,9 @@
- + {#if isEditing} - + {/if} diff --git a/web/src/lib/components/forms/library-import-paths-form.svelte b/web/src/lib/components/forms/library-import-paths-form.svelte index a2bb3a9686857..9e7ae11a63b48 100644 --- a/web/src/lib/components/forms/library-import-paths-form.svelte +++ b/web/src/lib/components/forms/library-import-paths-form.svelte @@ -1,5 +1,5 @@ -
handleSubmit()} autocomplete="off" class="m-4 flex flex-col gap-2"> + onSubmit({ ...library })} autocomplete="off" class="m-4 flex flex-col gap-2">
- +
diff --git a/web/src/lib/components/forms/library-scan-settings-form.svelte b/web/src/lib/components/forms/library-scan-settings-form.svelte index 5e025a406ae41..a9a42c31f7b24 100644 --- a/web/src/lib/components/forms/library-scan-settings-form.svelte +++ b/web/src/lib/components/forms/library-scan-settings-form.svelte @@ -1,7 +1,7 @@ - -
handleSubmit()} autocomplete="off" id="select-library-owner-form"> + + onSubmit(ownerId)} autocomplete="off" id="select-library-owner-form">

{$t('admin.note_cannot_be_changed_later')}

- +
diff --git a/web/src/lib/components/layouts/user-page-layout.svelte b/web/src/lib/components/layouts/user-page-layout.svelte index 5bca13b06029d..ed232b80cda28 100644 --- a/web/src/lib/components/layouts/user-page-layout.svelte +++ b/web/src/lib/components/layouts/user-page-layout.svelte @@ -21,7 +21,7 @@
{#if !hideNavbar} - openFileUploadDialog()} /> + openFileUploadDialog()} /> {/if} diff --git a/web/src/lib/components/map-page/map-settings-modal.svelte b/web/src/lib/components/map-page/map-settings-modal.svelte index b442396c84152..35df9f2285f1a 100644 --- a/web/src/lib/components/map-page/map-settings-modal.svelte +++ b/web/src/lib/components/map-page/map-settings-modal.svelte @@ -4,7 +4,6 @@ import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte'; import type { MapSettings } from '$lib/stores/preferences.store'; import { Duration } from 'luxon'; - import { createEventDispatcher } from 'svelte'; import { t } from 'svelte-i18n'; import { fly } from 'svelte/transition'; import Button from '../elements/buttons/button.svelte'; @@ -12,19 +11,15 @@ import DateInput from '../elements/date-input.svelte'; export let settings: MapSettings; + export let onClose: () => void; + export let onSave: (settings: MapSettings) => void; + let customDateRange = !!settings.dateAfter || !!settings.dateBefore; - - const dispatch = createEventDispatcher<{ - close: void; - save: MapSettings; - }>(); - - const handleClose = () => dispatch('close'); - +
dispatch('save', settings)} + on:submit|preventDefault={() => onSave(settings)} class="flex flex-col gap-4 text-immich-primary dark:text-immich-dark-primary" id="map-settings-form" > @@ -108,7 +103,7 @@ {/if}
- +
diff --git a/web/src/lib/components/memory-page/memory-viewer.svelte b/web/src/lib/components/memory-page/memory-viewer.svelte index ae6416873eae6..919433f79b4a0 100644 --- a/web/src/lib/components/memory-page/memory-viewer.svelte +++ b/web/src/lib/components/memory-page/memory-viewer.svelte @@ -250,7 +250,7 @@
{#if current && current.memory.assets.length > 0} - goto(AppRoute.PHOTOS)} forceDark> + goto(AppRoute.PHOTOS)} forceDark>

{$memoryLaneTitle(current.memory.yearsAgo)} diff --git a/web/src/lib/components/photos-page/actions/add-to-album.svelte b/web/src/lib/components/photos-page/actions/add-to-album.svelte index 976f4bd9cf03d..d3998510cdc84 100644 --- a/web/src/lib/components/photos-page/actions/add-to-album.svelte +++ b/web/src/lib/components/photos-page/actions/add-to-album.svelte @@ -40,8 +40,8 @@ {#if showAlbumPicker} handleAddToNewAlbum(detail)} - on:album={({ detail }) => handleAddToAlbum(detail)} + onNewAlbum={handleAddToNewAlbum} + onAlbumClick={handleAddToAlbum} onClose={handleHideAlbumPicker} /> {/if} diff --git a/web/src/lib/components/photos-page/actions/change-date-action.svelte b/web/src/lib/components/photos-page/actions/change-date-action.svelte index 6ee775fa69835..114315348d203 100644 --- a/web/src/lib/components/photos-page/actions/change-date-action.svelte +++ b/web/src/lib/components/photos-page/actions/change-date-action.svelte @@ -31,9 +31,5 @@ (isShowChangeDate = true)} /> {/if} {#if isShowChangeDate} - handleConfirm(date)} - on:cancel={() => (isShowChangeDate = false)} - /> + (isShowChangeDate = false)} /> {/if} diff --git a/web/src/lib/components/photos-page/actions/change-location-action.svelte b/web/src/lib/components/photos-page/actions/change-location-action.svelte index 0e19696a4269d..3fe1db4327ae0 100644 --- a/web/src/lib/components/photos-page/actions/change-location-action.svelte +++ b/web/src/lib/components/photos-page/actions/change-location-action.svelte @@ -35,8 +35,5 @@ /> {/if} {#if isShowChangeLocation} - handleConfirm(point)} - on:cancel={() => (isShowChangeLocation = false)} - /> + (isShowChangeLocation = false)} /> {/if} diff --git a/web/src/lib/components/photos-page/actions/delete-assets.svelte b/web/src/lib/components/photos-page/actions/delete-assets.svelte index 5c79e7b221833..6d3275c74d594 100644 --- a/web/src/lib/components/photos-page/actions/delete-assets.svelte +++ b/web/src/lib/components/photos-page/actions/delete-assets.svelte @@ -49,7 +49,7 @@ {#if isShowConfirmation} (isShowConfirmation = false)} + onConfirm={handleDelete} + onCancel={() => (isShowConfirmation = false)} /> {/if} diff --git a/web/src/lib/components/photos-page/asset-date-group.svelte b/web/src/lib/components/photos-page/asset-date-group.svelte index 240b6c2ba2162..b2780cc1a06b0 100644 --- a/web/src/lib/components/photos-page/asset-date-group.svelte +++ b/web/src/lib/components/photos-page/asset-date-group.svelte @@ -8,7 +8,7 @@ import { findTotalOffset, type DateGroup, type ScrollTargetListener } from '$lib/utils/timeline-util'; import type { AssetResponseDto } from '@immich/sdk'; import { mdiCheckCircle, mdiCircleOutline } from '@mdi/js'; - import { createEventDispatcher, onDestroy } from 'svelte'; + import { onDestroy } from 'svelte'; import { fly } from 'svelte/transition'; import Thumbnail from '../assets/thumbnail/thumbnail.svelte'; import { TUNABLES } from '$lib/utils/tunables'; @@ -29,6 +29,9 @@ export let onScrollTarget: ScrollTargetListener | undefined = undefined; export let onAssetInGrid: ((asset: AssetResponseDto) => void) | undefined = undefined; + export let onSelect: ({ title, assets }: { title: string; assets: AssetResponseDto[] }) => void; + export let onSelectAssets: (asset: AssetResponseDto) => void; + export let onSelectAssetCandidates: (asset: AssetResponseDto | null) => void; const componentId = generateId(); $: bucketDate = bucket.bucketDate; @@ -41,11 +44,6 @@ const TITLE_HEIGHT = 51; const { selectedGroup, selectedAssets, assetSelectionCandidates, isMultiSelectState } = assetInteractionStore; - const dispatch = createEventDispatcher<{ - select: { title: string; assets: AssetResponseDto[] }; - selectAssets: AssetResponseDto; - selectAssetCandidates: AssetResponseDto | null; - }>(); let isMouseOverGroup = false; let hoveredDateGroup = ''; @@ -65,10 +63,10 @@ } }; - const handleSelectGroup = (title: string, assets: AssetResponseDto[]) => dispatch('select', { title, assets }); + const handleSelectGroup = (title: string, assets: AssetResponseDto[]) => onSelect({ title, assets }); const assetSelectHandler = (asset: AssetResponseDto, assetsInDateGroup: AssetResponseDto[], groupTitle: string) => { - dispatch('selectAssets', asset); + onSelectAssets(asset); // Check if all assets are selected in a group to toggle the group selection's icon let selectedAssetsInGroupCount = assetsInDateGroup.filter((asset) => $selectedAssets.has(asset)).length; @@ -86,7 +84,7 @@ hoveredDateGroup = groupTitle; if ($isMultiSelectState) { - dispatch('selectAssetCandidates', asset); + onSelectAssetCandidates(asset); } }; diff --git a/web/src/lib/components/photos-page/asset-grid.svelte b/web/src/lib/components/photos-page/asset-grid.svelte index 3bf0c65bc9467..6de36c803e775 100644 --- a/web/src/lib/components/photos-page/asset-grid.svelte +++ b/web/src/lib/components/photos-page/asset-grid.svelte @@ -28,7 +28,7 @@ import { TUNABLES } from '$lib/utils/tunables'; import type { AlbumResponseDto, AssetResponseDto } from '@immich/sdk'; import { throttle } from 'lodash-es'; - import { createEventDispatcher, onDestroy, onMount } from 'svelte'; + import { onDestroy, onMount } from 'svelte'; import Portal from '../shared-components/portal/portal.svelte'; import Scrubber from '../shared-components/scrubber/scrubber.svelte'; import ShowShortcuts from '../shared-components/show-shortcuts.svelte'; @@ -64,6 +64,8 @@ export let isShared = false; export let album: AlbumResponseDto | null = null; export let isShowDeleteConfirmation = false; + export let onSelect: (asset: AssetResponseDto) => void = () => {}; + export let onEscape: () => void = () => {}; let { isViewing: showAssetViewer, asset: viewingAsset, preloadAssets, gridScrollTarget } = assetViewingStore; const { assetSelectionCandidates, assetSelectionStart, selectedGroup, selectedAssets, isMultiSelectState } = @@ -127,8 +129,6 @@ }, } = TUNABLES; - const dispatch = createEventDispatcher<{ select: AssetResponseDto; escape: void }>(); - const isViewportOrigin = () => { return viewport.height === 0 && viewport.width === 0; }; @@ -447,7 +447,7 @@ const ids = await stackAssets(Array.from($selectedAssets)); if (ids) { $assetStore.removeAssets(ids); - dispatch('escape'); + onEscape(); } }; @@ -471,7 +471,7 @@ } const shortcuts: ShortcutOptions[] = [ - { shortcut: { key: 'Escape' }, onShortcut: () => dispatch('escape') }, + { shortcut: { key: 'Escape' }, onShortcut: onEscape }, { shortcut: { key: '?', shift: true }, onShortcut: () => (showShortcuts = !showShortcuts) }, { shortcut: { key: '/' }, onShortcut: () => goto(AppRoute.EXPLORE) }, { shortcut: { key: 'A', ctrl: true }, onShortcut: () => selectAllAssets($assetStore, assetInteractionStore) }, @@ -539,7 +539,7 @@ return !!nextAsset; }; - const handleClose = async ({ detail: { asset } }: { detail: { asset: AssetResponseDto } }) => { + const handleClose = async ({ asset }: { asset: AssetResponseDto }) => { assetViewingStore.showAssetViewer(false); showSkeleton = true; $gridScrollTarget = { at: asset.id }; @@ -554,7 +554,7 @@ case AssetAction.DELETE: { // find the next asset to show or close the viewer // eslint-disable-next-line @typescript-eslint/no-unused-expressions - (await handleNext()) || (await handlePrevious()) || (await handleClose({ detail: { asset: action.asset } })); + (await handleNext()) || (await handlePrevious()) || (await handleClose({ asset: action.asset })); // delete after find the next one assetStore.removeAssets([action.asset.id]); @@ -649,7 +649,7 @@ return; } - dispatch('select', asset); + onSelect(asset); if (singleSelect) { element.scrollTop = 0; @@ -754,8 +754,8 @@ {#if isShowDeleteConfirmation} (isShowDeleteConfirmation = false)} - on:confirm={() => handlePromiseError(trashOrDelete(true))} + onCancel={() => (isShowDeleteConfirmation = false)} + onConfirm={() => handlePromiseError(trashOrDelete(true))} /> {/if} @@ -847,9 +847,9 @@ {onAssetInGrid} {bucket} viewport={safeViewport} - on:select={({ detail: group }) => handleGroupSelect(group.title, group.assets)} - on:selectAssetCandidates={({ detail: asset }) => handleSelectAssetCandidates(asset)} - on:selectAssets={({ detail: asset }) => handleSelectAssets(asset)} + onSelect={({ title, assets }) => handleGroupSelect(title, assets)} + onSelectAssetCandidates={handleSelectAssetCandidates} + onSelectAssets={handleSelectAssets} /> {/if}

@@ -869,9 +869,9 @@ {isShared} {album} onAction={handleAction} - on:previous={handlePrevious} - on:next={handleNext} - on:close={handleClose} + onPrevious={handlePrevious} + onNext={handleNext} + onClose={handleClose} /> {/await} {/if} diff --git a/web/src/lib/components/photos-page/asset-select-control-bar.svelte b/web/src/lib/components/photos-page/asset-select-control-bar.svelte index c802c53454a0d..79a0ea75e6736 100644 --- a/web/src/lib/components/photos-page/asset-select-control-bar.svelte +++ b/web/src/lib/components/photos-page/asset-select-control-bar.svelte @@ -30,7 +30,7 @@ }); - +

{assets.size}

diff --git a/web/src/lib/components/photos-page/delete-asset-dialog.svelte b/web/src/lib/components/photos-page/delete-asset-dialog.svelte index 84782b2d7fcf0..3eff428a7bb5e 100644 --- a/web/src/lib/components/photos-page/delete-asset-dialog.svelte +++ b/web/src/lib/components/photos-page/delete-asset-dialog.svelte @@ -1,5 +1,4 @@ @@ -27,7 +23,7 @@ title={$t('permanently_delete_assets_count', { values: { count: size } })} confirmText={$t('delete')} onConfirm={handleConfirm} - onCancel={() => dispatch('cancel')} + {onCancel} >

diff --git a/web/src/lib/components/shared-components/album-selection-modal.svelte b/web/src/lib/components/shared-components/album-selection-modal.svelte index 0690374c01702..6d28bd12c0332 100644 --- a/web/src/lib/components/shared-components/album-selection-modal.svelte +++ b/web/src/lib/components/shared-components/album-selection-modal.svelte @@ -2,7 +2,7 @@ import Icon from '$lib/components/elements/icon.svelte'; import { getAllAlbums, type AlbumResponseDto } from '@immich/sdk'; import { mdiPlus } from '@mdi/js'; - import { createEventDispatcher, onMount } from 'svelte'; + import { onMount } from 'svelte'; import AlbumListItem from '../asset-viewer/album-list-item.svelte'; import { normalizeSearchString } from '$lib/utils/string-utils'; import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte'; @@ -11,17 +11,15 @@ import { sortAlbums } from '$lib/utils/album-utils'; import { albumViewSettings } from '$lib/stores/preferences.store'; + export let onNewAlbum: (search: string) => void; + export let onAlbumClick: (album: AlbumResponseDto) => void; + let albums: AlbumResponseDto[] = []; let recentAlbums: AlbumResponseDto[] = []; let filteredAlbums: AlbumResponseDto[] = []; let loading = true; let search = ''; - const dispatch = createEventDispatcher<{ - newAlbum: string; - album: AlbumResponseDto; - }>(); - export let shared: boolean; export let onClose: () => void; @@ -40,14 +38,6 @@ { sortBy: $albumViewSettings.sortBy, orderBy: $albumViewSettings.sortOrder }, ); - const handleSelect = (album: AlbumResponseDto) => { - dispatch('album', album); - }; - - const handleNew = () => { - dispatch('newAlbum', search.length > 0 ? search : ''); - }; - const getTitle = () => { if (shared) { return $t('add_to_shared_album'); @@ -81,7 +71,7 @@

@@ -180,7 +162,7 @@ center={lat && lng ? { lat, lng } : undefined} simplified={true} clickable={true} - on:clickedPoint={({ detail: point }) => handleSelect(point)} + onClickPoint={(selected) => (point = selected)} /> {/await}
diff --git a/web/src/lib/components/shared-components/combobox.svelte b/web/src/lib/components/shared-components/combobox.svelte index 7c71fe8aeaed7..241f937be0fd5 100644 --- a/web/src/lib/components/shared-components/combobox.svelte +++ b/web/src/lib/components/shared-components/combobox.svelte @@ -21,7 +21,7 @@ import { fly } from 'svelte/transition'; import Icon from '$lib/components/elements/icon.svelte'; import { mdiMagnify, mdiUnfoldMoreHorizontal, mdiClose } from '@mdi/js'; - import { createEventDispatcher, tick } from 'svelte'; + import { tick } from 'svelte'; import type { FormEventHandler } from 'svelte/elements'; import { shortcuts } from '$lib/actions/shortcut'; import { focusOutside } from '$lib/actions/focus-outside'; @@ -35,6 +35,7 @@ export let options: ComboBoxOption[] = []; export let selectedOption: ComboBoxOption | undefined = undefined; export let placeholder = ''; + export let onSelect: (option: ComboBoxOption | undefined) => void = () => {}; /** * Unique identifier for the combobox. @@ -61,10 +62,6 @@ searchQuery = selectedOption ? selectedOption.label : ''; } - const dispatch = createEventDispatcher<{ - select: ComboBoxOption | undefined; - }>(); - const activate = () => { isActive = true; searchQuery = ''; @@ -105,10 +102,10 @@ optionRefs[0]?.scrollIntoView({ block: 'nearest' }); }; - let onSelect = (option: ComboBoxOption) => { + let handleSelect = (option: ComboBoxOption) => { selectedOption = option; searchQuery = option.label; - dispatch('select', option); + onSelect(option); closeDropdown(); }; @@ -117,7 +114,7 @@ selectedIndex = undefined; selectedOption = undefined; searchQuery = ''; - dispatch('select', selectedOption); + onSelect(selectedOption); }; @@ -188,7 +185,7 @@ shortcut: { key: 'Enter' }, onShortcut: () => { if (selectedIndex !== undefined && filteredOptions.length > 0) { - onSelect(filteredOptions[selectedIndex]); + handleSelect(filteredOptions[selectedIndex]); } closeDropdown(); }, @@ -245,7 +242,7 @@ bind:this={optionRefs[index]} class="text-left w-full px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all cursor-pointer aria-selected:bg-gray-100 aria-selected:dark:bg-gray-700" id={`${listboxId}-${index}`} - on:click={() => onSelect(option)} + on:click={() => handleSelect(option)} role="option" > {option.label} diff --git a/web/src/lib/components/shared-components/control-app-bar.svelte b/web/src/lib/components/shared-components/control-app-bar.svelte index cf128104d18e0..228cd88a86e75 100644 --- a/web/src/lib/components/shared-components/control-app-bar.svelte +++ b/web/src/lib/components/shared-components/control-app-bar.svelte @@ -1,7 +1,7 @@ diff --git a/web/src/lib/components/shared-components/settings/setting-switch.svelte b/web/src/lib/components/shared-components/settings/setting-switch.svelte index 24b539f0a1c22..11716526f85dc 100644 --- a/web/src/lib/components/shared-components/settings/setting-switch.svelte +++ b/web/src/lib/components/shared-components/settings/setting-switch.svelte @@ -1,7 +1,6 @@
diff --git a/web/src/lib/components/user-settings-page/user-api-key-list.svelte b/web/src/lib/components/user-settings-page/user-api-key-list.svelte index 13ec440082e91..a63bdb3ca9cb6 100644 --- a/web/src/lib/components/user-settings-page/user-api-key-list.svelte +++ b/web/src/lib/components/user-settings-page/user-api-key-list.svelte @@ -102,7 +102,7 @@ {/if} {#if secret} - (secret = '')} /> + (secret = '')} /> {/if} {#if editKey} diff --git a/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte b/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte index 2f1efc487cdd2..fd5b68d8c38a6 100644 --- a/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte +++ b/web/src/lib/components/utilities-page/duplicates/duplicates-compare-control.svelte @@ -151,15 +151,15 @@ 1} - on:next={() => { + onNext={() => { const index = getAssetIndex($viewingAsset.id) + 1; setAsset(assets[index % assets.length]); }} - on:previous={() => { + onPrevious={() => { const index = getAssetIndex($viewingAsset.id) - 1 + assets.length; setAsset(assets[index % assets.length]); }} - on:close={() => { + onClose={() => { assetViewingStore.showAssetViewer(false); handlePromiseError(navigate({ targetRoute: 'current', assetId: null })); }} diff --git a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte index 6e75273f3bc2c..57d09ed53a563 100644 --- a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -674,8 +674,8 @@ disabled={!album.isActivityEnabled} {isLiked} numberOfComments={$numberOfComments} - on:favorite={handleFavorite} - on:openActivityTab={handleOpenAndCloseActivityTab} + onFavorite={handleFavorite} + onOpenActivityTab={handleOpenAndCloseActivityTab} />
{/if} @@ -697,10 +697,10 @@ albumId={album.id} {isLiked} bind:reactions - on:addComment={() => updateNumberOfComments(1)} - on:deleteComment={() => updateNumberOfComments(-1)} - on:deleteLike={() => (isLiked = null)} - on:close={handleOpenAndCloseActivityTab} + onAddComment={() => updateNumberOfComments(1)} + onDeleteComment={() => updateNumberOfComments(-1)} + onDeleteLike={() => (isLiked = null)} + onClose={handleOpenAndCloseActivityTab} />
@@ -709,8 +709,8 @@ {#if viewMode === ViewMode.SELECT_USERS} handleAddUsers(users)} - on:share={() => (viewMode = ViewMode.LINK_SHARING)} + onSelect={handleAddUsers} + onShare={() => (viewMode = ViewMode.LINK_SHARING)} onClose={() => (viewMode = ViewMode.VIEW)} /> {/if} @@ -723,8 +723,8 @@ (viewMode = ViewMode.VIEW)} {album} - on:remove={({ detail: userId }) => handleRemoveUser(userId)} - on:refreshAlbum={refreshAlbum} + onRemove={handleRemoveUser} + onRefreshAlbum={refreshAlbum} /> {/if} @@ -737,9 +737,9 @@ albumOrder = order; await setModeToView(); }} - on:close={() => (viewMode = ViewMode.VIEW)} - on:toggleEnableActivity={handleToggleEnableActivity} - on:showSelectSharedUser={() => (viewMode = ViewMode.SELECT_USERS)} + onClose={() => (viewMode = ViewMode.VIEW)} + onToggleEnabledActivity={handleToggleEnableActivity} + onShowSelectSharedUser={() => (viewMode = ViewMode.SELECT_USERS)} /> {/if} diff --git a/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte index 0ea0ed18bb733..2e109823ed175 100644 --- a/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -122,9 +122,9 @@ 1} - on:next={navigateNext} - on:previous={navigatePrevious} - on:close={() => { + onNext={navigateNext} + onPrevious={navigatePrevious} + onClose={() => { assetViewingStore.showAssetViewer(false); handlePromiseError(navigate({ targetRoute: 'current', assetId: null })); }} @@ -137,11 +137,11 @@ {#if showSettingsModal} (showSettingsModal = false)} - on:save={async ({ detail }) => { - const shouldUpdate = !isEqual(omit(detail, 'allowDarkMode'), omit($mapSettings, 'allowDarkMode')); + onClose={() => (showSettingsModal = false)} + onSave={async (settings) => { + const shouldUpdate = !isEqual(omit(settings, 'allowDarkMode'), omit($mapSettings, 'allowDarkMode')); showSettingsModal = false; - $mapSettings = detail; + $mapSettings = settings; if (shouldUpdate) { mapMarkers = await loadMapMarkers(); diff --git a/web/src/routes/(user)/people/+page.svelte b/web/src/routes/(user)/people/+page.svelte index f1a2674e24905..b6d25c48bf937 100644 --- a/web/src/routes/(user)/people/+page.svelte +++ b/web/src/routes/(user)/people/+page.svelte @@ -302,9 +302,9 @@ {personMerge1} {personMerge2} {potentialMergePeople} - on:close={() => (showMergeModal = false)} - on:reject={() => changeName()} - on:confirm={(event) => handleMergeSamePerson(event.detail)} + onClose={() => (showMergeModal = false)} + onReject={changeName} + onConfirm={handleMergeSamePerson} /> {/if} @@ -349,10 +349,10 @@ handleChangeName(person)} - on:set-birth-date={() => handleSetBirthDate(person)} - on:merge-people={() => handleMergePeople(person)} - on:hide-person={() => handleHidePerson(person)} + onChangeName={() => handleChangeName(person)} + onSetBirthDate={() => handleSetBirthDate(person)} + onMergePeople={() => handleMergePeople(person)} + onHidePerson={() => handleHidePerson(person)} /> {:else} @@ -397,8 +397,8 @@ {#if showSetBirthDateModal} (showSetBirthDateModal = false)} - on:updated={(event) => submitBirthDateChange(event.detail)} + onClose={() => (showSetBirthDateModal = false)} + onUpdate={submitBirthDateChange} /> {/if} diff --git a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte index daa5821e8506b..bb648228b93c7 100644 --- a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -347,8 +347,8 @@ a.id)} personAssets={person} - on:close={() => (viewMode = ViewMode.VIEW_ASSETS)} - on:confirm={handleUnmerge} + onClose={() => (viewMode = ViewMode.VIEW_ASSETS)} + onConfirm={handleUnmerge} /> {/if} @@ -357,22 +357,22 @@ {personMerge1} {personMerge2} {potentialMergePeople} - on:close={() => (viewMode = ViewMode.VIEW_ASSETS)} - on:reject={() => changeName()} - on:confirm={(event) => handleMergeSamePerson(event.detail)} + onClose={() => (viewMode = ViewMode.VIEW_ASSETS)} + onReject={changeName} + onConfirm={handleMergeSamePerson} /> {/if} {#if viewMode === ViewMode.BIRTH_DATE} (viewMode = ViewMode.VIEW_ASSETS)} - on:updated={(event) => handleSetBirthDate(event.detail)} + onClose={() => (viewMode = ViewMode.VIEW_ASSETS)} + onUpdate={handleSetBirthDate} /> {/if} {#if viewMode === ViewMode.MERGE_PEOPLE} - handleMerge(detail)} /> + {/if}
@@ -464,7 +464,7 @@ bind:suggestedPeople name={person.name} bind:isSearchingPeople - on:change={(event) => handleNameChange(event.detail)} + onChange={handleNameChange} {thumbnailData} /> {:else} diff --git a/web/src/routes/admin/library-management/+page.svelte b/web/src/routes/admin/library-management/+page.svelte index 74db5628ba3a6..5ce3296a03531 100644 --- a/web/src/routes/admin/library-management/+page.svelte +++ b/web/src/routes/admin/library-management/+page.svelte @@ -267,10 +267,7 @@ {#if toCreateLibrary} - handleCreate(detail.ownerId)} - on:cancel={() => (toCreateLibrary = false)} - /> + (toCreateLibrary = false)} /> {/if} @@ -385,28 +382,20 @@ {#if renameLibrary === index}
- handleUpdate(detail)} - on:cancel={() => (renameLibrary = null)} - /> + (renameLibrary = null)} />
{/if} {#if editImportPaths === index}
- handleUpdate(detail)} - on:cancel={() => (editImportPaths = null)} - /> + (editImportPaths = null)} />
{/if} {#if editScanSettings === index}
handleUpdate(library)} - on:cancel={() => (editScanSettings = null)} + onSubmit={handleUpdate} + onCancel={() => (editScanSettings = null)} />
{/if} diff --git a/web/src/routes/admin/user-management/+page.svelte b/web/src/routes/admin/user-management/+page.svelte index b040ce293c74a..2313b17cb1ea1 100644 --- a/web/src/routes/admin/user-management/+page.svelte +++ b/web/src/routes/admin/user-management/+page.svelte @@ -110,8 +110,8 @@
{#if shouldShowCreateUserForm} (shouldShowCreateUserForm = false)} + onSubmit={onUserCreated} + onCancel={() => (shouldShowCreateUserForm = false)} onClose={() => (shouldShowCreateUserForm = false)} /> {/if} @@ -121,8 +121,8 @@ user={selectedUser} bind:newPassword canResetPassword={selectedUser?.id !== $user.id} - on:editSuccess={onEditUserSuccess} - on:resetPasswordSuccess={onEditPasswordSuccess} + onEditSuccess={onEditUserSuccess} + onResetPasswordSuccess={onEditPasswordSuccess} onClose={() => (shouldShowEditUserForm = false)} /> {/if} diff --git a/web/src/routes/auth/change-password/+page.svelte b/web/src/routes/auth/change-password/+page.svelte index aa23e4e7d24e0..eaf5a88fe20be 100644 --- a/web/src/routes/auth/change-password/+page.svelte +++ b/web/src/routes/auth/change-password/+page.svelte @@ -25,5 +25,5 @@ {$t('change_password_description')}

- + From 8cd3f6b8840a8f8f66c42d40dc694aac2307e930 Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Sat, 21 Sep 2024 00:24:46 +0200 Subject: [PATCH 059/123] fix(web): events as props (#12825) --- .../admin-page/settings/ffmpeg/ffmpeg-settings.svelte | 4 ++-- .../admin-page/settings/image/image-settings.svelte | 4 ++-- .../asset-viewer/video-wrapper-viewer.svelte | 10 +++++++++- web/src/lib/components/faces-page/people-list.svelte | 2 +- web/src/lib/components/faces-page/people-search.svelte | 4 ++-- .../components/faces-page/unmerge-face-selector.svelte | 2 +- web/src/lib/components/forms/tag-asset-form.svelte | 2 +- .../share-page/individual-shared-viewer.svelte | 2 +- .../purchasing/purchase-activation-success.svelte | 2 +- .../search-bar/search-camera-section.svelte | 4 ++-- .../search-bar/search-location-section.svelte | 6 +++--- .../shared-components/settings/setting-combobox.svelte | 9 +-------- .../components/user-settings-page/app-settings.svelte | 10 +++++----- .../user-settings-page/partner-settings.svelte | 2 +- .../user-settings-page/user-purchase-settings.svelte | 2 +- .../[[photos=photos]]/[[assetId=id]]/+page.svelte | 10 +++++----- .../map/[[photos=photos]]/[[assetId=id]]/+page.svelte | 2 +- .../[[photos=photos]]/[[assetId=id]]/+page.svelte | 2 +- .../[[photos=photos]]/[[assetId=id]]/+page.svelte | 8 ++++---- .../routes/(user)/photos/[[assetId=id]]/+page.svelte | 2 +- .../[[photos=photos]]/[[assetId=id]]/+page.svelte | 2 +- web/src/routes/(user)/sharing/sharedlinks/+page.svelte | 2 +- 22 files changed, 47 insertions(+), 46 deletions(-) diff --git a/web/src/lib/components/admin-page/settings/ffmpeg/ffmpeg-settings.svelte b/web/src/lib/components/admin-page/settings/ffmpeg/ffmpeg-settings.svelte index 7ddb71cbdef20..c048a222070a3 100644 --- a/web/src/lib/components/admin-page/settings/ffmpeg/ffmpeg-settings.svelte +++ b/web/src/lib/components/admin-page/settings/ffmpeg/ffmpeg-settings.svelte @@ -99,7 +99,7 @@ ]} name="vcodec" isEdited={config.ffmpeg.targetVideoCodec !== savedConfig.ffmpeg.targetVideoCodec} - on:select={() => (config.ffmpeg.acceptedVideoCodecs = [config.ffmpeg.targetVideoCodec])} + onSelect={() => (config.ffmpeg.acceptedVideoCodecs = [config.ffmpeg.targetVideoCodec])} /> + onSelect={() => config.ffmpeg.acceptedAudioCodecs.includes(config.ffmpeg.targetAudioCodec) ? null : config.ffmpeg.acceptedAudioCodecs.push(config.ffmpeg.targetAudioCodec)} diff --git a/web/src/lib/components/admin-page/settings/image/image-settings.svelte b/web/src/lib/components/admin-page/settings/image/image-settings.svelte index a7b47920fd98b..d6fc814b98e4c 100644 --- a/web/src/lib/components/admin-page/settings/image/image-settings.svelte +++ b/web/src/lib/components/admin-page/settings/image/image-settings.svelte @@ -96,7 +96,7 @@ title={$t('admin.image_prefer_wide_gamut')} subtitle={$t('admin.image_prefer_wide_gamut_setting_description')} checked={config.image.colorspace === Colorspace.P3} - on:toggle={(e) => (config.image.colorspace = e.detail ? Colorspace.P3 : Colorspace.Srgb)} + onToggle={(isChecked) => (config.image.colorspace = isChecked ? Colorspace.P3 : Colorspace.Srgb)} isEdited={config.image.colorspace !== savedConfig.image.colorspace} {disabled} /> @@ -105,7 +105,7 @@ title={$t('admin.image_prefer_embedded_preview')} subtitle={$t('admin.image_prefer_embedded_preview_setting_description')} checked={config.image.extractEmbedded} - on:toggle={() => (config.image.extractEmbedded = !config.image.extractEmbedded)} + onToggle={() => (config.image.extractEmbedded = !config.image.extractEmbedded)} isEdited={config.image.extractEmbedded !== savedConfig.image.extractEmbedded} {disabled} /> diff --git a/web/src/lib/components/asset-viewer/video-wrapper-viewer.svelte b/web/src/lib/components/asset-viewer/video-wrapper-viewer.svelte index ae9fda8c69a7f..5f03784c42258 100644 --- a/web/src/lib/components/asset-viewer/video-wrapper-viewer.svelte +++ b/web/src/lib/components/asset-viewer/video-wrapper-viewer.svelte @@ -15,5 +15,13 @@ {#if projectionType === ProjectionType.EQUIRECTANGULAR} {:else} - + {/if} diff --git a/web/src/lib/components/faces-page/people-list.svelte b/web/src/lib/components/faces-page/people-list.svelte index 230c8750aedee..10626a6a93888 100644 --- a/web/src/lib/components/faces-page/people-list.svelte +++ b/web/src/lib/components/faces-page/people-list.svelte @@ -32,7 +32,7 @@ >
{#each showPeople as person (person.id)} - onSelect(person)} circle border selectable /> + onSelect(person)} circle border selectable /> {/each}
diff --git a/web/src/lib/components/faces-page/people-search.svelte b/web/src/lib/components/faces-page/people-search.svelte index cfd4c8f29a2f5..2a952b8145b25 100644 --- a/web/src/lib/components/faces-page/people-search.svelte +++ b/web/src/lib/components/faces-page/people-search.svelte @@ -83,8 +83,8 @@ bind:name={searchName} {showLoadingSpinner} {placeholder} - on:reset={handleReset} - on:search={({ detail }) => handleSearch(detail.force ?? false)} + onReset={handleReset} + onSearch={({ force }) => handleSearch(force ?? false)} /> {:else}
diff --git a/web/src/lib/components/forms/tag-asset-form.svelte b/web/src/lib/components/forms/tag-asset-form.svelte index 7500a6faac0d3..b5e358ec9664e 100644 --- a/web/src/lib/components/forms/tag-asset-form.svelte +++ b/web/src/lib/components/forms/tag-asset-form.svelte @@ -52,7 +52,7 @@
handleSelect(option)} + onSelect={handleSelect} label={$t('tag')} options={allTags.map((tag) => ({ id: tag.id, label: tag.value, value: tag.id }))} placeholder={$t('search_tags')} diff --git a/web/src/lib/components/share-page/individual-shared-viewer.svelte b/web/src/lib/components/share-page/individual-shared-viewer.svelte index af5c54c9880cd..1b5368b1336e1 100644 --- a/web/src/lib/components/share-page/individual-shared-viewer.svelte +++ b/web/src/lib/components/share-page/individual-shared-viewer.svelte @@ -84,7 +84,7 @@ {/if} {:else} - goto(AppRoute.PHOTOS)} backIcon={mdiArrowLeft} showBackButton={false}> + goto(AppRoute.PHOTOS)} backIcon={mdiArrowLeft} showBackButton={false}> diff --git a/web/src/lib/components/shared-components/purchasing/purchase-activation-success.svelte b/web/src/lib/components/shared-components/purchasing/purchase-activation-success.svelte index 2b8c678543659..3bd462f9976ff 100644 --- a/web/src/lib/components/shared-components/purchasing/purchase-activation-success.svelte +++ b/web/src/lib/components/shared-components/purchasing/purchase-activation-success.svelte @@ -20,7 +20,7 @@ title={$t('show_supporter_badge')} subtitle={$t('show_supporter_badge_description')} bind:checked={$preferences.purchase.showSupportBadge} - on:toggle={({ detail }) => setSupportBadgeVisibility(detail)} + onToggle={setSupportBadgeVisibility} />
diff --git a/web/src/lib/components/shared-components/search-bar/search-camera-section.svelte b/web/src/lib/components/shared-components/search-bar/search-camera-section.svelte index f1cd0c85964cf..3ac8cb8d5aa4f 100644 --- a/web/src/lib/components/shared-components/search-bar/search-camera-section.svelte +++ b/web/src/lib/components/shared-components/search-bar/search-camera-section.svelte @@ -56,7 +56,7 @@
(filters.make = detail?.value)} + onSelect={(option) => (filters.make = option?.value)} options={asComboboxOptions(makes)} placeholder={$t('search_camera_make')} selectedOption={asSelectedOption(makeFilter)} @@ -66,7 +66,7 @@
(filters.model = detail?.value)} + onSelect={(option) => (filters.model = option?.value)} options={asComboboxOptions(models)} placeholder={$t('search_camera_model')} selectedOption={asSelectedOption(modelFilter)} diff --git a/web/src/lib/components/shared-components/search-bar/search-location-section.svelte b/web/src/lib/components/shared-components/search-bar/search-location-section.svelte index ce265d00306b8..71912264ed7ac 100644 --- a/web/src/lib/components/shared-components/search-bar/search-location-section.svelte +++ b/web/src/lib/components/shared-components/search-bar/search-location-section.svelte @@ -73,7 +73,7 @@
(filters.country = detail?.value)} + onSelect={(option) => (filters.country = option?.value)} options={asComboboxOptions(countries)} placeholder={$t('search_country')} selectedOption={asSelectedOption(filters.country)} @@ -83,7 +83,7 @@
(filters.state = detail?.value)} + onSelect={(option) => (filters.state = option?.value)} options={asComboboxOptions(states)} placeholder={$t('search_state')} selectedOption={asSelectedOption(filters.state)} @@ -93,7 +93,7 @@
(filters.city = detail?.value)} + onSelect={(option) => (filters.city = option?.value)} options={asComboboxOptions(cities)} placeholder={$t('search_city')} selectedOption={asSelectedOption(filters.city)} diff --git a/web/src/lib/components/shared-components/settings/setting-combobox.svelte b/web/src/lib/components/shared-components/settings/setting-combobox.svelte index 502cd94cce04f..722af048a5d99 100644 --- a/web/src/lib/components/shared-components/settings/setting-combobox.svelte +++ b/web/src/lib/components/shared-components/settings/setting-combobox.svelte @@ -32,14 +32,7 @@

{subtitle}

- onSelect(detail)} - /> +
diff --git a/web/src/lib/components/user-settings-page/app-settings.svelte b/web/src/lib/components/user-settings-page/app-settings.svelte index de4bbafdd94c9..e6ce8f6aae69c 100644 --- a/web/src/lib/components/user-settings-page/app-settings.svelte +++ b/web/src/lib/components/user-settings-page/app-settings.svelte @@ -99,7 +99,7 @@ title={$t('theme_selection')} subtitle={$t('theme_selection_description')} bind:checked={$colorTheme.system} - on:toggle={handleToggleColorTheme} + onToggle={handleToggleColorTheme} />
@@ -119,7 +119,7 @@ title={$t('default_locale')} subtitle={$t('default_locale_description')} checked={$locale == undefined} - on:toggle={handleToggleLocaleBrowser} + onToggle={handleToggleLocaleBrowser} >

{selectedDate}

@@ -142,7 +142,7 @@ title={$t('display_original_photos')} subtitle={$t('display_original_photos_setting_description')} bind:checked={$alwaysLoadOriginalFile} - on:toggle={() => ($alwaysLoadOriginalFile = !$alwaysLoadOriginalFile)} + onToggle={() => ($alwaysLoadOriginalFile = !$alwaysLoadOriginalFile)} />
@@ -150,7 +150,7 @@ title={$t('video_hover_setting')} subtitle={$t('video_hover_setting_description')} bind:checked={$playVideoThumbnailOnHover} - on:toggle={() => ($playVideoThumbnailOnHover = !$playVideoThumbnailOnHover)} + onToggle={() => ($playVideoThumbnailOnHover = !$playVideoThumbnailOnHover)} />
@@ -158,7 +158,7 @@ title={$t('loop_videos')} subtitle={$t('loop_videos_description')} bind:checked={$loopVideo} - on:toggle={() => ($loopVideo = !$loopVideo)} + onToggle={() => ($loopVideo = !$loopVideo)} />
diff --git a/web/src/lib/components/user-settings-page/partner-settings.svelte b/web/src/lib/components/user-settings-page/partner-settings.svelte index ee57e4c688700..050e2c42f3cac 100644 --- a/web/src/lib/components/user-settings-page/partner-settings.svelte +++ b/web/src/lib/components/user-settings-page/partner-settings.svelte @@ -177,7 +177,7 @@ title={$t('show_in_timeline')} subtitle={$t('show_in_timeline_setting_description')} bind:checked={partner.inTimeline} - on:toggle={({ detail }) => handleShowOnTimelineChanged(partner, detail)} + onToggle={(isChecked) => handleShowOnTimelineChanged(partner, isChecked)} /> {/if}
diff --git a/web/src/lib/components/user-settings-page/user-purchase-settings.svelte b/web/src/lib/components/user-settings-page/user-purchase-settings.svelte index bf0fd3c8746c7..71f76d07c0a85 100644 --- a/web/src/lib/components/user-settings-page/user-purchase-settings.svelte +++ b/web/src/lib/components/user-settings-page/user-purchase-settings.svelte @@ -115,7 +115,7 @@ title={$t('show_supporter_badge')} subtitle={$t('show_supporter_badge_description')} bind:checked={$preferences.purchase.showSupportBadge} - on:toggle={({ detail }) => setSupportBadgeVisibility(detail)} + onToggle={setSupportBadgeVisibility} />
diff --git a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte index 57d09ed53a563..cbdb38192e082 100644 --- a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -470,7 +470,7 @@ {:else} {#if viewMode === ViewMode.VIEW} - goto(backUrl)}> + goto(backUrl)}> {#if isEditor} +

{#if $timelineSelected.size === 0} @@ -554,7 +554,7 @@ {/if} {#if viewMode === ViewMode.SELECT_THUMBNAIL} - (viewMode = ViewMode.VIEW)}> + (viewMode = ViewMode.VIEW)}> {$t('select_album_cover')} {/if} @@ -583,8 +583,8 @@ isSelectionMode={viewMode === ViewMode.SELECT_THUMBNAIL} singleSelect={viewMode === ViewMode.SELECT_THUMBNAIL} showArchiveIcon - on:select={({ detail: asset }) => handleUpdateThumbnail(asset.id)} - on:escape={handleEscape} + onSelect={({ id }) => handleUpdateThumbnail(id)} + onEscape={handleEscape} > {#if viewMode !== ViewMode.SELECT_THUMBNAIL} diff --git a/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte index 2e109823ed175..adbc3cfe699a3 100644 --- a/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -113,7 +113,7 @@ {#if $featureFlags.loaded && $featureFlags.map}

- onViewAssets(event.detail)} /> +
diff --git a/web/src/routes/(user)/partners/[userId]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/partners/[userId]/[[photos=photos]]/[[assetId=id]]/+page.svelte index b580c4faa5454..2caab9de82508 100644 --- a/web/src/routes/(user)/partners/[userId]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/partners/[userId]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -38,7 +38,7 @@ {:else} - goto(AppRoute.SHARING)}> + goto(AppRoute.SHARING)}>

{data.partner.name}'s photos diff --git a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte index bb648228b93c7..83019d67cd869 100644 --- a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -400,7 +400,7 @@ {:else} {#if viewMode === ViewMode.VIEW_ASSETS || viewMode === ViewMode.SUGGEST_MERGE || viewMode === ViewMode.BIRTH_DATE} - goto(previousRoute)}> + goto(previousRoute)}> (viewMode = ViewMode.VIEW_ASSETS)}> + (viewMode = ViewMode.VIEW_ASSETS)}> {$t('select_featured_photo')} {/if} @@ -444,8 +444,8 @@ {assetInteractionStore} isSelectionMode={viewMode === ViewMode.SELECT_PERSON} singleSelect={viewMode === ViewMode.SELECT_PERSON} - on:select={({ detail: asset }) => handleSelectFeaturePhoto(asset)} - on:escape={handleEscape} + onSelect={handleSelectFeaturePhoto} + onEscape={handleEscape} > {#if viewMode === ViewMode.VIEW_ASSETS || viewMode === ViewMode.SUGGEST_MERGE || viewMode === ViewMode.BIRTH_DATE} diff --git a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte index 4649da8205120..ba8ee13cc9f1f 100644 --- a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte @@ -127,7 +127,7 @@ {assetStore} {assetInteractionStore} removeAction={AssetAction.ARCHIVE} - on:escape={handleEscape} + onEscape={handleEscape} withStacked > {#if $preferences.memories.enabled} diff --git a/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte index da85eb49c8267..9c6a8f9e75891 100644 --- a/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -246,7 +246,7 @@

{:else}
- goto(previousRoute)} backIcon={mdiArrowLeft}> + goto(previousRoute)} backIcon={mdiArrowLeft}>
diff --git a/web/src/routes/(user)/sharing/sharedlinks/+page.svelte b/web/src/routes/(user)/sharing/sharedlinks/+page.svelte index 5e934143dff2a..67e80f4703858 100644 --- a/web/src/routes/(user)/sharing/sharedlinks/+page.svelte +++ b/web/src/routes/(user)/sharing/sharedlinks/+page.svelte @@ -52,7 +52,7 @@ }; - goto(AppRoute.SHARING)}> + goto(AppRoute.SHARING)}> {$t('shared_links')} From af7011164589a34f83fa896d756b5c7b1d4c5d81 Mon Sep 17 00:00:00 2001 From: Shubham Date: Sat, 21 Sep 2024 04:31:26 +0530 Subject: [PATCH 060/123] fix(mobile): Issue Selecting Many Albuns for Backup (#12784) * Update backup.provider.dart * Revert "Update backup.provider.dart" This reverts commit ac2b7acef9c4390a61a30884a05589723f572403. * Reapply "Update backup.provider.dart" This reverts commit c9fe934b3bde472a579b465fbd3b21448b819930. * dart formatting --- mobile/lib/providers/backup/backup.provider.dart | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/mobile/lib/providers/backup/backup.provider.dart b/mobile/lib/providers/backup/backup.provider.dart index 9329f9b1f7093..0885f35f77998 100644 --- a/mobile/lib/providers/backup/backup.provider.dart +++ b/mobile/lib/providers/backup/backup.provider.dart @@ -313,6 +313,9 @@ class BackupNotifier extends StateNotifier { /// Those assets are unique and are used as the total assets /// Future _updateBackupAssetCount() async { + // Save to persistent storage + await _updatePersistentAlbumsSelection(); + final duplicatedAssetIds = await _backupService.getDuplicatedAssetIds(); final Set assetsFromSelectedAlbums = {}; final Set assetsFromExcludedAlbums = {}; @@ -408,9 +411,6 @@ class BackupNotifier extends StateNotifier { selectedAlbumsBackupAssetsIds: selectedAlbumsBackupAssets, ); } - - // Save to persistent storage - await _updatePersistentAlbumsSelection(); } /// Get all necessary information for calculating the available albums, From 5a1a841365a842eab345a70c420380cc00606e2e Mon Sep 17 00:00:00 2001 From: Zack Pollard Date: Sat, 21 Sep 2024 00:16:53 +0100 Subject: [PATCH 061/123] fix: rework file handling so we always explicitly create, overwrite or both (#12812) --- server/src/interfaces/storage.interface.ts | 4 +++- server/src/repositories/storage.repository.ts | 12 +++++++++-- server/src/services/metadata.service.spec.ts | 10 ++++----- server/src/services/metadata.service.ts | 2 +- server/src/services/storage.service.spec.ts | 9 ++++++-- server/src/services/storage.service.ts | 21 +++++++++++++++---- .../repositories/storage.repository.mock.ts | 4 +++- 7 files changed, 46 insertions(+), 16 deletions(-) diff --git a/server/src/interfaces/storage.interface.ts b/server/src/interfaces/storage.interface.ts index fec3d66dd5c03..321f7b8367f23 100644 --- a/server/src/interfaces/storage.interface.ts +++ b/server/src/interfaces/storage.interface.ts @@ -35,7 +35,9 @@ export interface IStorageRepository { createZipStream(): ImmichZipStream; createReadStream(filepath: string, mimeType?: string | null): Promise; readFile(filepath: string, options?: FileReadOptions): Promise; - writeFile(filepath: string, buffer: Buffer): Promise; + createFile(filepath: string, buffer: Buffer): Promise; + createOrOverwriteFile(filepath: string, buffer: Buffer): Promise; + overwriteFile(filepath: string, buffer: Buffer): Promise; realpath(filepath: string): Promise; unlink(filepath: string): Promise; unlinkDir(folder: string, options?: { recursive?: boolean; force?: boolean }): Promise; diff --git a/server/src/repositories/storage.repository.ts b/server/src/repositories/storage.repository.ts index c699047ce1575..6fd9bb8b04147 100644 --- a/server/src/repositories/storage.repository.ts +++ b/server/src/repositories/storage.repository.ts @@ -40,8 +40,16 @@ export class StorageRepository implements IStorageRepository { return fs.stat(filepath); } - writeFile(filepath: string, buffer: Buffer) { - return fs.writeFile(filepath, buffer); + createFile(filepath: string, buffer: Buffer) { + return fs.writeFile(filepath, buffer, { flag: 'wx' }); + } + + createOrOverwriteFile(filepath: string, buffer: Buffer) { + return fs.writeFile(filepath, buffer, { flag: 'w' }); + } + + overwriteFile(filepath: string, buffer: Buffer) { + return fs.writeFile(filepath, buffer, { flag: 'r+' }); } rename(source: string, target: string) { diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index 19aaa2ea1a323..4eac4a4cf9574 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -511,7 +511,7 @@ describe(MetadataService.name, () => { await sut.handleMetadataExtraction({ id: assetStub.livePhotoMotionAsset.id }); expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoMotionAsset.id]); - expect(storageMock.writeFile).not.toHaveBeenCalled(); + expect(storageMock.createOrOverwriteFile).not.toHaveBeenCalled(); expect(jobMock.queue).not.toHaveBeenCalled(); expect(jobMock.queueAll).not.toHaveBeenCalled(); expect(assetMock.update).not.toHaveBeenCalledWith( @@ -581,7 +581,7 @@ describe(MetadataService.name, () => { type: AssetType.VIDEO, }); expect(userMock.updateUsage).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.ownerId, 512); - expect(storageMock.writeFile).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.originalPath, video); + expect(storageMock.createFile).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.originalPath, video); expect(assetMock.update).toHaveBeenNthCalledWith(1, { id: assetStub.livePhotoWithOriginalFileName.id, livePhotoVideoId: fileStub.livePhotoMotion.uuid, @@ -624,7 +624,7 @@ describe(MetadataService.name, () => { type: AssetType.VIDEO, }); expect(userMock.updateUsage).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.ownerId, 512); - expect(storageMock.writeFile).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.originalPath, video); + expect(storageMock.createFile).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.originalPath, video); expect(assetMock.update).toHaveBeenNthCalledWith(1, { id: assetStub.livePhotoWithOriginalFileName.id, livePhotoVideoId: fileStub.livePhotoMotion.uuid, @@ -668,7 +668,7 @@ describe(MetadataService.name, () => { type: AssetType.VIDEO, }); expect(userMock.updateUsage).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.ownerId, 512); - expect(storageMock.writeFile).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.originalPath, video); + expect(storageMock.createFile).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.originalPath, video); expect(assetMock.update).toHaveBeenNthCalledWith(1, { id: assetStub.livePhotoWithOriginalFileName.id, livePhotoVideoId: fileStub.livePhotoMotion.uuid, @@ -716,7 +716,7 @@ describe(MetadataService.name, () => { await sut.handleMetadataExtraction({ id: assetStub.livePhotoStillAsset.id }); expect(assetMock.create).toHaveBeenCalledTimes(0); - expect(storageMock.writeFile).toHaveBeenCalledTimes(0); + expect(storageMock.createOrOverwriteFile).toHaveBeenCalledTimes(0); // The still asset gets saved by handleMetadataExtraction, but not the video expect(assetMock.update).toHaveBeenCalledTimes(1); expect(jobMock.queue).toHaveBeenCalledTimes(0); diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index eaa491c3ee7d8..60a1e12a5ac7a 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -529,7 +529,7 @@ export class MetadataService { const existsOnDisk = await this.storageRepository.checkFileExists(motionAsset.originalPath); if (!existsOnDisk) { this.storageCore.ensureFolders(motionAsset.originalPath); - await this.storageRepository.writeFile(motionAsset.originalPath, video); + await this.storageRepository.createFile(motionAsset.originalPath, video); this.logger.log(`Wrote motion photo video to ${motionAsset.originalPath}`); await this.jobRepository.queue({ name: JobName.METADATA_EXTRACTION, data: { id: motionAsset.id } }); } diff --git a/server/src/services/storage.service.spec.ts b/server/src/services/storage.service.spec.ts index b0f38554cb032..930fb3c726e2f 100644 --- a/server/src/services/storage.service.spec.ts +++ b/server/src/services/storage.service.spec.ts @@ -41,6 +41,11 @@ describe(StorageService.name, () => { expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/library'); expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/profile'); expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs'); + expect(storageMock.createFile).toHaveBeenCalledWith('upload/encoded-video/.immich', expect.any(Buffer)); + expect(storageMock.createFile).toHaveBeenCalledWith('upload/library/.immich', expect.any(Buffer)); + expect(storageMock.createFile).toHaveBeenCalledWith('upload/profile/.immich', expect.any(Buffer)); + expect(storageMock.createFile).toHaveBeenCalledWith('upload/thumbs/.immich', expect.any(Buffer)); + expect(storageMock.createFile).toHaveBeenCalledWith('upload/upload/.immich', expect.any(Buffer)); }); it('should throw an error if .immich is missing', async () => { @@ -49,13 +54,13 @@ describe(StorageService.name, () => { await expect(sut.onBootstrap()).rejects.toThrow('Failed to validate folder mount'); - expect(storageMock.writeFile).not.toHaveBeenCalled(); + expect(storageMock.createOrOverwriteFile).not.toHaveBeenCalled(); expect(systemMock.set).not.toHaveBeenCalled(); }); it('should throw an error if .immich is present but read-only', async () => { systemMock.get.mockResolvedValue({ mountFiles: true }); - storageMock.writeFile.mockRejectedValue(new Error("ENOENT: no such file or directory, open '/app/.immich'")); + storageMock.overwriteFile.mockRejectedValue(new Error("ENOENT: no such file or directory, open '/app/.immich'")); await expect(sut.onBootstrap()).rejects.toThrow('Failed to validate folder mount'); diff --git a/server/src/services/storage.service.ts b/server/src/services/storage.service.ts index a8f6a76e747e1..15328b0c21f65 100644 --- a/server/src/services/storage.service.ts +++ b/server/src/services/storage.service.ts @@ -32,7 +32,7 @@ export class StorageService { for (const folder of Object.values(StorageFolder)) { if (!flags.mountFiles) { this.logger.log(`Writing initial mount file for the ${folder} folder`); - await this.verifyWriteAccess(folder); + await this.createMountFile(folder); } await this.verifyReadAccess(folder); @@ -81,17 +81,30 @@ export class StorageService { } } - private async verifyWriteAccess(folder: StorageFolder) { + private async createMountFile(folder: StorageFolder) { const { folderPath, filePath } = this.getMountFilePaths(folder); try { this.storageRepository.mkdirSync(folderPath); - await this.storageRepository.writeFile(filePath, Buffer.from(`${Date.now()}`)); + await this.storageRepository.createFile(filePath, Buffer.from(`${Date.now()}`)); + } catch (error) { + this.logger.error(`Failed to create ${filePath}: ${error}`); + this.logger.error( + `The "${folder}" folder cannot be written to, please make sure the volume is mounted with the correct permissions`, + ); + throw new ImmichStartupError(`Failed to validate folder mount (write to "/${folder}")`); + } + } + + private async verifyWriteAccess(folder: StorageFolder) { + const { filePath } = this.getMountFilePaths(folder); + try { + await this.storageRepository.overwriteFile(filePath, Buffer.from(`${Date.now()}`)); } catch (error) { this.logger.error(`Failed to write ${filePath}: ${error}`); this.logger.error( `The "${folder}" folder cannot be written to, please make sure the volume is mounted with the correct permissions`, ); - throw new ImmichStartupError(`Failed to validate folder mount (write to "/${folder}")`); + throw new ImmichStartupError(`Failed to validate folder mount (write to "/${folder}")`); } } diff --git a/server/test/repositories/storage.repository.mock.ts b/server/test/repositories/storage.repository.mock.ts index 5c2951e097b24..5226e0bb1e985 100644 --- a/server/test/repositories/storage.repository.mock.ts +++ b/server/test/repositories/storage.repository.mock.ts @@ -48,7 +48,9 @@ export const newStorageRepositoryMock = (reset = true): Mocked Date: Sat, 21 Sep 2024 07:29:07 +0700 Subject: [PATCH 062/123] fix(mobile): fix uncaught error in getting file cause hashing procses to be aborted entirely (#12826) * fix(mobile): fix uncaught error in getting file cause hashing procses to be aborted entirely * log error --- mobile/lib/services/hash.service.dart | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/mobile/lib/services/hash.service.dart b/mobile/lib/services/hash.service.dart index 2ec545453f232..94d680972fa1a 100644 --- a/mobile/lib/services/hash.service.dart +++ b/mobile/lib/services/hash.service.dart @@ -65,7 +65,19 @@ class HashService { if (hashes[i] != null) { continue; } - final file = await assets[i].local!.originFile; + + File? file; + + try { + file = await assets[i].local!.originFile; + } catch (error, stackTrace) { + _log.warning( + "Error getting file to hash for asset ${assets[i].localId}, name: ${assets[i].fileName}, created on: ${assets[i].fileCreatedAt}, skipping", + error, + stackTrace, + ); + } + if (file == null) { final fileName = assets[i].fileName; From 7c1ea2dc73219aa06c9b5d3ee90a2a04417279d7 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 22 Sep 2024 07:29:30 +0700 Subject: [PATCH 063/123] chore(deps): update dependency flutter to v3.24.3 (#11738) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- mobile/.fvmrc | 2 +- mobile/pubspec.lock | 2 +- mobile/pubspec.yaml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/mobile/.fvmrc b/mobile/.fvmrc index 971587f297946..ee6eaac06fefc 100644 --- a/mobile/.fvmrc +++ b/mobile/.fvmrc @@ -1,3 +1,3 @@ { - "flutter": "3.24.0" + "flutter": "3.24.3" } diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 7fe33c327058a..aaea00d699bbe 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -1854,4 +1854,4 @@ packages: version: "3.1.2" sdks: dart: ">=3.4.0 <4.0.0" - flutter: ">=3.24.0" + flutter: ">=3.24.3" diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 8787fd85651d7..0f75463547d6b 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -6,7 +6,7 @@ version: 1.115.0+159 environment: sdk: '>=3.3.0 <4.0.0' - flutter: 3.24.0 + flutter: 3.24.3 dependencies: flutter: From 39ea73d654c79bdffe70d4e4804f813b049b512b Mon Sep 17 00:00:00 2001 From: Fynn Petersen-Frey <10599762+fyfrey@users.noreply.github.com> Date: Sun, 22 Sep 2024 15:24:08 +0200 Subject: [PATCH 064/123] chore(mobile): restrict isar use via CI checks (#12840) --- mobile/analysis_options.yaml | 20 +++++++++++++++++++ mobile/lib/pages/library/favorite.page.dart | 2 +- ...e_provider.dart => favorite.provider.dart} | 0 3 files changed, 21 insertions(+), 1 deletion(-) rename mobile/lib/providers/{favorite_provider.dart => favorite.provider.dart} (100%) diff --git a/mobile/analysis_options.yaml b/mobile/analysis_options.yaml index 2783e8f1d1423..8f9d41d73610e 100644 --- a/mobile/analysis_options.yaml +++ b/mobile/analysis_options.yaml @@ -58,6 +58,26 @@ custom_lint: # refactor to make the providers and services testable - lib/providers/backup/{backup,manual_upload}.provider.dart # uses only PMProgressHandler - lib/services/{background,backup}.service.dart # uses only PMProgressHandler + - import_rule_isar: + message: isar must only be used in entities and repositories + restrict: package:isar + allowed: + # required / wanted + - lib/entities/*.entity.dart + - lib/repositories/{album,asset,backup,user}.repository.dart + # acceptable exceptions for the time being + - integration_test/test_utils/general_helper.dart + - lib/main.dart + - lib/routing/router.dart + - lib/utils/{db,image_url_builder,migration,renderlist_generator}.dart + - test/**.dart + # refactor to make the providers and services testable + - lib/pages/common/{album_asset_selection,gallery_viewer}.page.dart + - lib/providers/{archive,asset,authentication,db,favorite,partner,trash,user}.provider.dart + - lib/providers/{album/album,album/shared_album,asset_viewer/asset_stack,asset_viewer/render_list,backup/backup,backup/manual_upload,search/all_motion_photos,search/recently_added_asset}.provider.dart + - lib/services/{asset,asset_description,background,backup,backup_verification,hash,immich_logger,memory,partner,person,search,stack,sync,user}.service.dart + - lib/widgets/asset_grid/{asset_grid_data_structure,thumbnail_image}.dart + - import_rule_openapi: message: openapi must only be used through ApiRepositories restrict: package:openapi diff --git a/mobile/lib/pages/library/favorite.page.dart b/mobile/lib/pages/library/favorite.page.dart index 7462dc8f21519..cc422f88c7fbf 100644 --- a/mobile/lib/pages/library/favorite.page.dart +++ b/mobile/lib/pages/library/favorite.page.dart @@ -2,7 +2,7 @@ import 'package:auto_route/auto_route.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/providers/favorite_provider.dart'; +import 'package:immich_mobile/providers/favorite.provider.dart'; import 'package:immich_mobile/providers/multiselect.provider.dart'; import 'package:immich_mobile/widgets/asset_grid/multiselect_grid.dart'; diff --git a/mobile/lib/providers/favorite_provider.dart b/mobile/lib/providers/favorite.provider.dart similarity index 100% rename from mobile/lib/providers/favorite_provider.dart rename to mobile/lib/providers/favorite.provider.dart From 9abfa6940ca09ec3aa069b74f50a6a67c61e063e Mon Sep 17 00:00:00 2001 From: Fynn Petersen-Frey <10599762+fyfrey@users.noreply.github.com> Date: Mon, 23 Sep 2024 06:11:23 +0200 Subject: [PATCH 065/123] docs: mobile architecture diagram (#12841) --- docs/docs/developer/architecture.mdx | 10 +- .../img/immich_mobile_architecture.drawio | 104 ++++++++++++++++++ .../img/immich_mobile_architecture.svg | 3 + 3 files changed, 116 insertions(+), 1 deletion(-) create mode 100644 docs/docs/developer/img/immich_mobile_architecture.drawio create mode 100644 docs/docs/developer/img/immich_mobile_architecture.svg diff --git a/docs/docs/developer/architecture.mdx b/docs/docs/developer/architecture.mdx index cf004a1119213..7b5debef4c0da 100644 --- a/docs/docs/developer/architecture.mdx +++ b/docs/docs/developer/architecture.mdx @@ -3,6 +3,7 @@ sidebar_position: 1 --- import AppArchitecture from './img/app-architecture.png'; +import MobileArchitecture from './img/immich_mobile_architecture.svg'; # Architecture @@ -28,7 +29,14 @@ All three clients use [OpenAPI](./open-api.md) to auto-generate rest clients for ### Mobile App -The mobile app is written in [Flutter](https://flutter.dev/). It uses [Isar Database](https://isar.dev/) for a local database and [Riverpod](https://riverpod.dev/) for state management. +The mobile app is written in [Dart](https://dart.dev/) using [Flutter](https://flutter.dev/). Below is an architecture overview: + + + +The diagrams shows the target architecture, the current state of the code-base is not always following the architecture yet. New code and contributions should follow this architecture. +Currently, it uses [Isar Database](https://isar.dev/) for a local database and [Riverpod](https://riverpod.dev/) for state management (providers). +Entities and Models are the two types of data classes used. While entities are stored in the on-device database, models are ephemeral and only kept in memory. +The Repositories should be the only place where other data classes are used internally (such as OpenAPI DTOs). However, their interfaces must not use foreign data classes! ### Web Client diff --git a/docs/docs/developer/img/immich_mobile_architecture.drawio b/docs/docs/developer/img/immich_mobile_architecture.drawio new file mode 100644 index 0000000000000..548cda09383f3 --- /dev/null +++ b/docs/docs/developer/img/immich_mobile_architecture.drawio @@ -0,0 +1,104 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/docs/developer/img/immich_mobile_architecture.svg b/docs/docs/developer/img/immich_mobile_architecture.svg new file mode 100644 index 0000000000000..71f28235bf649 --- /dev/null +++ b/docs/docs/developer/img/immich_mobile_architecture.svg @@ -0,0 +1,3 @@ + + +
Mobile App
Mobile App
Services
Services
Repositories
Repositories
Providers
Providers
Pages
Pages
Widgets
Widgets
User
User
platform
system
platform...
on-device
database
on-device...
server
server
OpenAPI
OpenAPI
UI part
UI part
non-UI part
non-UI part
Models
Models
Entities
Entities
\ No newline at end of file From 147747de32a7362db842d95433a4ab1688eece92 Mon Sep 17 00:00:00 2001 From: kurama <52566613+zp33dy@users.noreply.github.com> Date: Mon, 23 Sep 2024 09:40:23 +0200 Subject: [PATCH 066/123] docs: add section for Traefik Reverse Proxy (#12813) * added a section for the Traefik Proxy * minimized the configs * replaced config with a comment. * Update docs/docs/administration/reverse-proxy.md changed timeout values Co-authored-by: dvbthien <89862334+dvbthien@users.noreply.github.com> * changed timeouts back to 10 minutes * fixed typo and set default writeTimeout 600s Leaving it at 0 may be also bad practice * removed whitespace * run `npm run format -- --check -w` --------- Co-authored-by: dvbthien <89862334+dvbthien@users.noreply.github.com> --- docs/docs/administration/reverse-proxy.md | 40 +++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/docs/docs/administration/reverse-proxy.md b/docs/docs/administration/reverse-proxy.md index 1d2488f1192d8..c40fecbdc4c23 100644 --- a/docs/docs/administration/reverse-proxy.md +++ b/docs/docs/administration/reverse-proxy.md @@ -64,3 +64,43 @@ Below is an example config for Apache2 site configuration. ProxyPreserveHost On ``` + +### Traefik Proxy example config + +The example below is for Traefik version 3. + +The most important is to increase the `respondingTimeouts` of the entrypoint used by immich. In this example of entrypoint `websecure` for port `443`. Per default it's set to 60s which leeds to videos stop uploading after 1 minute (Error Code 499). With this config it will fail after 10 minutes which is in most cases enough. Increase it if needed. + +`traefik.yaml` + +```yaml +[...] +entryPoints: + websecure: + address: :443 + # this section needs to be added + transport: + respondingTimeouts: + readTimeout: 600s + idleTimeout: 600s + writeTimeout: 600s +``` + +The second part is in the `docker-compose.yml` file where immich is in. Add the Traefik specific labels like in the example. + +`docker-compose.yml` + +```yaml +services: + immich-server: + [...] + labels: + traefik.enable: true + # increase readingTimeouts for the entrypoint used here + traefik.http.routers.immich.entrypoints: websecure + traefik.http.routers.immich.rule: Host(`immich.your-domain.com`) + traefik.http.services.immich.loadbalancer.server.port: 3001 +``` + +Keep in mind, that Traefik needs to communicate with the network where immich is in, usually done +by adding the Traefik network to the `immich-server`. From b1cdf73a2425cf789aff1e3ab874e05d377dfe0f Mon Sep 17 00:00:00 2001 From: Nuno Antunes Date: Mon, 23 Sep 2024 08:50:18 +0100 Subject: [PATCH 067/123] feat(server): validate rating (#12855) * feat(server): validate exif rating tag * fix(server): change allowed range for rating * refactor: better readibility * docs: comments * remove log line --- server/src/services/metadata.service.spec.ts | 24 ++++++++++++++++++++ server/src/services/metadata.service.ts | 14 +++++++++++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index 4eac4a4cf9574..ad01aa5784afe 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -1107,6 +1107,30 @@ describe(MetadataService.name, () => { }), ); }); + + it('should handle invalid rating value', async () => { + assetMock.getByIds.mockResolvedValue([assetStub.image]); + metadataMock.readTags.mockResolvedValue({ Rating: 6 }); + + await sut.handleMetadataExtraction({ id: assetStub.image.id }); + expect(assetMock.upsertExif).toHaveBeenCalledWith( + expect.objectContaining({ + rating: null, + }), + ); + }); + + it('should handle valid rating value', async () => { + assetMock.getByIds.mockResolvedValue([assetStub.image]); + metadataMock.readTags.mockResolvedValue({ Rating: 5 }); + + await sut.handleMetadataExtraction({ id: assetStub.image.id }); + expect(assetMock.upsertExif).toHaveBeenCalledWith( + expect.objectContaining({ + rating: 5, + }), + ); + }); }); describe('handleQueueSidecar', () => { diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index 60a1e12a5ac7a..bf76be07311b2 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -83,6 +83,18 @@ const validate = (value: T): NonNullable | null => { return value ?? null; }; +const validateRange = (value: number | undefined, min: number, max: number): NonNullable | null => { + // reutilizes the validate function + const val = validate(value); + + // check if the value is within the range + if (val == null || val < min || val > max) { + return null; + } + + return val; +}; + @Injectable() export class MetadataService { private storageCore: StorageCore; @@ -261,7 +273,7 @@ export class MetadataService { // comments description: String(exifTags.ImageDescription || exifTags.Description || '').trim(), profileDescription: exifTags.ProfileDescription || null, - rating: exifTags.Rating ?? null, + rating: validateRange(exifTags.Rating, 0, 5), // grouping livePhotoCID: (exifTags.ContentIdentifier || exifTags.MediaGroupUUID) ?? null, From 0cce7ebf25b8684709ff4a270b74ab1b1f097bec Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Mon, 23 Sep 2024 11:16:25 -0400 Subject: [PATCH 068/123] fix: web e2e (#12869) --- e2e/docker-compose.yml | 5 ----- e2e/playwright.config.ts | 4 +++- e2e/src/setup/docker-compose.ts | 3 ++- e2e/src/utils.ts | 3 +-- server/src/services/storage.service.ts | 5 +++-- 5 files changed, 9 insertions(+), 11 deletions(-) diff --git a/e2e/docker-compose.yml b/e2e/docker-compose.yml index dbb95f176d7e7..6169a4bfa1725 100644 --- a/e2e/docker-compose.yml +++ b/e2e/docker-compose.yml @@ -22,7 +22,6 @@ services: - IMMICH_METRICS=true - IMMICH_ENV=testing volumes: - - upload:/usr/src/app/upload - ./test-assets:/test-assets extra_hosts: - 'auth-server:host-gateway' @@ -44,7 +43,3 @@ services: POSTGRES_DB: immich ports: - 5435:5432 - -volumes: - model-cache: - upload: diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index 55032bd364bee..2576a2c5c945f 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -53,8 +53,10 @@ export default defineConfig({ /* Run your local dev server before starting the tests */ webServer: { - command: 'docker compose up --build -V --remove-orphans', + command: 'docker compose up --build --renew-anon-volumes --force-recreate --remove-orphans', url: 'http://127.0.0.1:2285', + stdout: 'pipe', + stderr: 'pipe', reuseExistingServer: true, }, }); diff --git a/e2e/src/setup/docker-compose.ts b/e2e/src/setup/docker-compose.ts index 3ae87417a2f94..49a702e776c85 100644 --- a/e2e/src/setup/docker-compose.ts +++ b/e2e/src/setup/docker-compose.ts @@ -12,7 +12,8 @@ const setup = async () => { const timeout = setTimeout(() => _reject(new Error('Timeout starting e2e environment')), 60_000); - const child = spawn('docker', ['compose', 'up'], { stdio: 'pipe' }); + const command = 'compose up --build --renew-anon-volumes --force-recreate --remove-orphans'; + const child = spawn('docker', command.split(' '), { stdio: 'pipe' }); child.stdout.on('data', (data) => { const input = data.toString(); diff --git a/e2e/src/utils.ts b/e2e/src/utils.ts index c67e5696975a9..3c9d4284ce49c 100644 --- a/e2e/src/utils.ts +++ b/e2e/src/utils.ts @@ -156,8 +156,7 @@ export const utils = { for (const table of tables) { if (table === 'system_metadata') { - // prevent reverse geocoder from being re-initialized - sql.push(`DELETE FROM "system_metadata" where "key" != 'reverse-geocoding-state';`); + sql.push(`DELETE FROM "system_metadata" where "key" NOT IN ('reverse-geocoding-state', 'system-flags');`); } else { sql.push(`DELETE FROM ${table} CASCADE;`); } diff --git a/server/src/services/storage.service.ts b/server/src/services/storage.service.ts index 15328b0c21f65..1591149dc20d8 100644 --- a/server/src/services/storage.service.ts +++ b/server/src/services/storage.service.ts @@ -25,12 +25,13 @@ export class StorageService { async onBootstrap() { await this.databaseRepository.withLock(DatabaseLock.SystemFileMounts, async () => { const flags = (await this.systemMetadata.get(SystemMetadataKey.SYSTEM_FLAGS)) || { mountFiles: false }; + const enabled = flags.mountFiles ?? false; - this.logger.log('Verifying system mount folder checks'); + this.logger.log(`Verifying system mount folder checks (enabled=${enabled})`); // check each folder exists and is writable for (const folder of Object.values(StorageFolder)) { - if (!flags.mountFiles) { + if (!enabled) { this.logger.log(`Writing initial mount file for the ${folder} folder`); await this.createMountFile(folder); } From 9a4a320cfb82b2cf5a7e273801e4955452a4e524 Mon Sep 17 00:00:00 2001 From: Caesiumhydroxid Date: Mon, 23 Sep 2024 17:38:50 +0200 Subject: [PATCH 069/123] fix(web): Fix same key for delete and stack actions (#12865) Fix same key for delete and stack actions --- .../duplicates/[[photos=photos]]/[[assetId=id]]/+page.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.svelte index 5207cf8445e52..e1029b7ccbfff 100644 --- a/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -42,7 +42,7 @@ { key: ['s'], action: $t('view') }, { key: ['d'], action: $t('unselect_all_duplicates') }, { key: ['⇧', 'c'], action: $t('resolve_duplicates') }, - { key: ['⇧', 'c'], action: $t('stack_duplicates') }, + { key: ['⇧', 's'], action: $t('stack_duplicates') }, ], }; From a7719a94fcac4e52edefe482a7661019708fde53 Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Mon, 23 Sep 2024 17:40:25 +0200 Subject: [PATCH 070/123] fix: normalize external domain (#12831) chore: normalize external domain --- server/src/cores/system-config.core.ts | 4 ++++ .../src/services/system-config.service.spec.ts | 17 +++++++++++++++++ web/src/lib/utils.ts | 6 +----- 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/server/src/cores/system-config.core.ts b/server/src/cores/system-config.core.ts index 7c1434004a437..8ed53344cc02f 100644 --- a/server/src/cores/system-config.core.ts +++ b/server/src/cores/system-config.core.ts @@ -120,6 +120,10 @@ export class SystemConfigCore { } } + if (config.server.externalDomain.length > 0) { + config.server.externalDomain = new URL(config.server.externalDomain).origin; + } + if (!config.ffmpeg.acceptedVideoCodecs.includes(config.ffmpeg.targetVideoCodec)) { config.ffmpeg.acceptedVideoCodecs.push(config.ffmpeg.targetVideoCodec); } diff --git a/server/src/services/system-config.service.spec.ts b/server/src/services/system-config.service.spec.ts index 409cd6a52f360..7e25e0cd46c8c 100644 --- a/server/src/services/system-config.service.spec.ts +++ b/server/src/services/system-config.service.spec.ts @@ -289,6 +289,23 @@ describe(SystemConfigService.name, () => { expect(config.machineLearning.url).toEqual('immich_machine_learning'); }); + const externalDomainTests = [ + { should: 'with a trailing slash', externalDomain: 'https://demo.immich.app/' }, + { should: 'without a trailing slash', externalDomain: 'https://demo.immich.app' }, + { should: 'with a port', externalDomain: 'https://demo.immich.app:42', result: 'https://demo.immich.app:42' }, + ]; + + for (const { should, externalDomain, result } of externalDomainTests) { + it(`should normalize an external domain ${should}`, async () => { + process.env.IMMICH_CONFIG_FILE = 'immich-config.json'; + const partialConfig = { server: { externalDomain } }; + systemMock.readFile.mockResolvedValue(JSON.stringify(partialConfig)); + + const config = await sut.getConfig(); + expect(config.server.externalDomain).toEqual(result ?? 'https://demo.immich.app'); + }); + } + it('should warn for unknown options in yaml', async () => { process.env.IMMICH_CONFIG_FILE = 'immich-config.yaml'; const partialConfig = ` diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts index 29c7552d0c94c..dccb03c9bf55e 100644 --- a/web/src/lib/utils.ts +++ b/web/src/lib/utils.ts @@ -257,11 +257,7 @@ export const copyToClipboard = async (secret: string) => { }; export const makeSharedLinkUrl = (externalDomain: string, key: string) => { - let url = externalDomain || window.location.origin; - if (!url.endsWith('/')) { - url += '/'; - } - return `${url}share/${key}`; + return new URL(`share/${key}`, externalDomain || window.location.origin).href; }; export const oauth = { From 9f8a7e0beac3615fd2b7b3e2f8cbb4d91448e238 Mon Sep 17 00:00:00 2001 From: jschwalbe Date: Mon, 23 Sep 2024 12:09:26 -0400 Subject: [PATCH 071/123] feat(server): sort assets randomly from the API 'api/search/metadata' endpoint by including 'order': 'rand' in the API call. (#12741) feat(server): search metadata random sort order Co-authored-by: Jason Rasmussen --- mobile/openapi/README.md | 3 + mobile/openapi/lib/api.dart | 1 + mobile/openapi/lib/api/assets_api.dart | 7 +- mobile/openapi/lib/api/deprecated_api.dart | 59 ++ mobile/openapi/lib/api/search_api.dart | 47 ++ mobile/openapi/lib/api_client.dart | 2 + .../openapi/lib/model/random_search_dto.dart | 583 ++++++++++++++++++ open-api/immich-openapi-specs.json | 176 +++++- open-api/typescript-sdk/src/fetch-client.ts | 49 ++ server/src/controllers/asset.controller.ts | 2 + server/src/controllers/search.controller.ts | 8 + server/src/dtos/search.dto.ts | 16 +- server/src/interfaces/search.interface.ts | 1 + server/src/repositories/search.repository.ts | 7 +- server/src/services/search.service.ts | 17 + 15 files changed, 967 insertions(+), 11 deletions(-) create mode 100644 mobile/openapi/lib/model/random_search_dto.dart diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 697239fa44e99..c8135519def56 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -116,6 +116,7 @@ Class | Method | HTTP request | Description *AuthenticationApi* | [**signUpAdmin**](doc//AuthenticationApi.md#signupadmin) | **POST** /auth/admin-sign-up | *AuthenticationApi* | [**validateAccessToken**](doc//AuthenticationApi.md#validateaccesstoken) | **POST** /auth/validateToken | *DeprecatedApi* | [**getPersonAssets**](doc//DeprecatedApi.md#getpersonassets) | **GET** /people/{id}/assets | +*DeprecatedApi* | [**getRandom**](doc//DeprecatedApi.md#getrandom) | **GET** /assets/random | *DownloadApi* | [**downloadArchive**](doc//DownloadApi.md#downloadarchive) | **POST** /download/archive | *DownloadApi* | [**getDownloadInfo**](doc//DownloadApi.md#getdownloadinfo) | **POST** /download/info | *DuplicatesApi* | [**getAssetDuplicates**](doc//DuplicatesApi.md#getassetduplicates) | **GET** /duplicates | @@ -172,6 +173,7 @@ Class | Method | HTTP request | Description *SearchApi* | [**searchMetadata**](doc//SearchApi.md#searchmetadata) | **POST** /search/metadata | *SearchApi* | [**searchPerson**](doc//SearchApi.md#searchperson) | **GET** /search/person | *SearchApi* | [**searchPlaces**](doc//SearchApi.md#searchplaces) | **GET** /search/places | +*SearchApi* | [**searchRandom**](doc//SearchApi.md#searchrandom) | **POST** /search/random | *SearchApi* | [**searchSmart**](doc//SearchApi.md#searchsmart) | **POST** /search/smart | *ServerApi* | [**deleteServerLicense**](doc//ServerApi.md#deleteserverlicense) | **DELETE** /server/license | *ServerApi* | [**getAboutInfo**](doc//ServerApi.md#getaboutinfo) | **GET** /server/about | @@ -379,6 +381,7 @@ Class | Method | HTTP request | Description - [PurchaseResponse](doc//PurchaseResponse.md) - [PurchaseUpdate](doc//PurchaseUpdate.md) - [QueueStatusDto](doc//QueueStatusDto.md) + - [RandomSearchDto](doc//RandomSearchDto.md) - [RatingsResponse](doc//RatingsResponse.md) - [RatingsUpdate](doc//RatingsUpdate.md) - [ReactionLevel](doc//ReactionLevel.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 8a1655d35a109..7fa06b04875ee 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -192,6 +192,7 @@ part 'model/places_response_dto.dart'; part 'model/purchase_response.dart'; part 'model/purchase_update.dart'; part 'model/queue_status_dto.dart'; +part 'model/random_search_dto.dart'; part 'model/ratings_response.dart'; part 'model/ratings_update.dart'; part 'model/reaction_level.dart'; diff --git a/mobile/openapi/lib/api/assets_api.dart b/mobile/openapi/lib/api/assets_api.dart index ceba3574cd17a..bd1d5b84847a1 100644 --- a/mobile/openapi/lib/api/assets_api.dart +++ b/mobile/openapi/lib/api/assets_api.dart @@ -449,7 +449,10 @@ class AssetsApi { return null; } - /// Performs an HTTP 'GET /assets/random' operation and returns the [Response]. + /// This property was deprecated in v1.116.0 + /// + /// Note: This method returns the HTTP [Response]. + /// /// Parameters: /// /// * [num] count: @@ -482,6 +485,8 @@ class AssetsApi { ); } + /// This property was deprecated in v1.116.0 + /// /// Parameters: /// /// * [num] count: diff --git a/mobile/openapi/lib/api/deprecated_api.dart b/mobile/openapi/lib/api/deprecated_api.dart index 96cb3c2ef0ad2..bc8f50092a030 100644 --- a/mobile/openapi/lib/api/deprecated_api.dart +++ b/mobile/openapi/lib/api/deprecated_api.dart @@ -71,4 +71,63 @@ class DeprecatedApi { } return null; } + + /// This property was deprecated in v1.116.0 + /// + /// Note: This method returns the HTTP [Response]. + /// + /// Parameters: + /// + /// * [num] count: + Future getRandomWithHttpInfo({ num? count, }) async { + // ignore: prefer_const_declarations + final path = r'/assets/random'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + if (count != null) { + queryParams.addAll(_queryParams('', 'count', count)); + } + + const contentTypes = []; + + + return apiClient.invokeAPI( + path, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// This property was deprecated in v1.116.0 + /// + /// Parameters: + /// + /// * [num] count: + Future?> getRandom({ num? count, }) async { + final response = await getRandomWithHttpInfo( count: count, ); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + final responseBody = await _decodeBodyBytes(response); + return (await apiClient.deserializeAsync(responseBody, 'List') as List) + .cast() + .toList(growable: false); + + } + return null; + } } diff --git a/mobile/openapi/lib/api/search_api.dart b/mobile/openapi/lib/api/search_api.dart index 4b6cdfea78aa4..3b981e0ccb5bb 100644 --- a/mobile/openapi/lib/api/search_api.dart +++ b/mobile/openapi/lib/api/search_api.dart @@ -351,6 +351,53 @@ class SearchApi { return null; } + /// Performs an HTTP 'POST /search/random' operation and returns the [Response]. + /// Parameters: + /// + /// * [RandomSearchDto] randomSearchDto (required): + Future searchRandomWithHttpInfo(RandomSearchDto randomSearchDto,) async { + // ignore: prefer_const_declarations + final path = r'/search/random'; + + // ignore: prefer_final_locals + Object? postBody = randomSearchDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + path, + 'POST', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [RandomSearchDto] randomSearchDto (required): + Future searchRandom(RandomSearchDto randomSearchDto,) async { + final response = await searchRandomWithHttpInfo(randomSearchDto,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'SearchResponseDto',) as SearchResponseDto; + + } + return null; + } + /// Performs an HTTP 'POST /search/smart' operation and returns the [Response]. /// Parameters: /// diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 4976c8a75f331..597a15d5b0562 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -439,6 +439,8 @@ class ApiClient { return PurchaseUpdate.fromJson(value); case 'QueueStatusDto': return QueueStatusDto.fromJson(value); + case 'RandomSearchDto': + return RandomSearchDto.fromJson(value); case 'RatingsResponse': return RatingsResponse.fromJson(value); case 'RatingsUpdate': diff --git a/mobile/openapi/lib/model/random_search_dto.dart b/mobile/openapi/lib/model/random_search_dto.dart new file mode 100644 index 0000000000000..8dbbeb538714d --- /dev/null +++ b/mobile/openapi/lib/model/random_search_dto.dart @@ -0,0 +1,583 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class RandomSearchDto { + /// Returns a new [RandomSearchDto] instance. + RandomSearchDto({ + this.city, + this.country, + this.createdAfter, + this.createdBefore, + this.deviceId, + this.isArchived, + this.isEncoded, + this.isFavorite, + this.isMotion, + this.isNotInAlbum, + this.isOffline, + this.isVisible, + this.lensModel, + this.libraryId, + this.make, + this.model, + this.page, + this.personIds = const [], + this.size, + this.state, + this.takenAfter, + this.takenBefore, + this.trashedAfter, + this.trashedBefore, + this.type, + this.updatedAfter, + this.updatedBefore, + this.withArchived = false, + this.withDeleted, + this.withExif, + this.withPeople, + this.withStacked, + }); + + String? city; + + String? country; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + DateTime? createdAfter; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + DateTime? createdBefore; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + String? deviceId; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? isArchived; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? isEncoded; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? isFavorite; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? isMotion; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? isNotInAlbum; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? isOffline; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? isVisible; + + String? lensModel; + + String? libraryId; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + String? make; + + String? model; + + /// Minimum value: 1 + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + num? page; + + List personIds; + + /// Minimum value: 1 + /// Maximum value: 1000 + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + num? size; + + String? state; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + DateTime? takenAfter; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + DateTime? takenBefore; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + DateTime? trashedAfter; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + DateTime? trashedBefore; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + AssetTypeEnum? type; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + DateTime? updatedAfter; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + DateTime? updatedBefore; + + bool withArchived; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? withDeleted; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? withExif; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? withPeople; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? withStacked; + + @override + bool operator ==(Object other) => identical(this, other) || other is RandomSearchDto && + other.city == city && + other.country == country && + other.createdAfter == createdAfter && + other.createdBefore == createdBefore && + other.deviceId == deviceId && + other.isArchived == isArchived && + other.isEncoded == isEncoded && + other.isFavorite == isFavorite && + other.isMotion == isMotion && + other.isNotInAlbum == isNotInAlbum && + other.isOffline == isOffline && + other.isVisible == isVisible && + other.lensModel == lensModel && + other.libraryId == libraryId && + other.make == make && + other.model == model && + other.page == page && + _deepEquality.equals(other.personIds, personIds) && + other.size == size && + other.state == state && + other.takenAfter == takenAfter && + other.takenBefore == takenBefore && + other.trashedAfter == trashedAfter && + other.trashedBefore == trashedBefore && + other.type == type && + other.updatedAfter == updatedAfter && + other.updatedBefore == updatedBefore && + other.withArchived == withArchived && + other.withDeleted == withDeleted && + other.withExif == withExif && + other.withPeople == withPeople && + other.withStacked == withStacked; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (city == null ? 0 : city!.hashCode) + + (country == null ? 0 : country!.hashCode) + + (createdAfter == null ? 0 : createdAfter!.hashCode) + + (createdBefore == null ? 0 : createdBefore!.hashCode) + + (deviceId == null ? 0 : deviceId!.hashCode) + + (isArchived == null ? 0 : isArchived!.hashCode) + + (isEncoded == null ? 0 : isEncoded!.hashCode) + + (isFavorite == null ? 0 : isFavorite!.hashCode) + + (isMotion == null ? 0 : isMotion!.hashCode) + + (isNotInAlbum == null ? 0 : isNotInAlbum!.hashCode) + + (isOffline == null ? 0 : isOffline!.hashCode) + + (isVisible == null ? 0 : isVisible!.hashCode) + + (lensModel == null ? 0 : lensModel!.hashCode) + + (libraryId == null ? 0 : libraryId!.hashCode) + + (make == null ? 0 : make!.hashCode) + + (model == null ? 0 : model!.hashCode) + + (page == null ? 0 : page!.hashCode) + + (personIds.hashCode) + + (size == null ? 0 : size!.hashCode) + + (state == null ? 0 : state!.hashCode) + + (takenAfter == null ? 0 : takenAfter!.hashCode) + + (takenBefore == null ? 0 : takenBefore!.hashCode) + + (trashedAfter == null ? 0 : trashedAfter!.hashCode) + + (trashedBefore == null ? 0 : trashedBefore!.hashCode) + + (type == null ? 0 : type!.hashCode) + + (updatedAfter == null ? 0 : updatedAfter!.hashCode) + + (updatedBefore == null ? 0 : updatedBefore!.hashCode) + + (withArchived.hashCode) + + (withDeleted == null ? 0 : withDeleted!.hashCode) + + (withExif == null ? 0 : withExif!.hashCode) + + (withPeople == null ? 0 : withPeople!.hashCode) + + (withStacked == null ? 0 : withStacked!.hashCode); + + @override + String toString() => 'RandomSearchDto[city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, deviceId=$deviceId, isArchived=$isArchived, isEncoded=$isEncoded, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, isVisible=$isVisible, lensModel=$lensModel, libraryId=$libraryId, make=$make, model=$model, page=$page, personIds=$personIds, size=$size, state=$state, takenAfter=$takenAfter, takenBefore=$takenBefore, trashedAfter=$trashedAfter, trashedBefore=$trashedBefore, type=$type, updatedAfter=$updatedAfter, updatedBefore=$updatedBefore, withArchived=$withArchived, withDeleted=$withDeleted, withExif=$withExif, withPeople=$withPeople, withStacked=$withStacked]'; + + Map toJson() { + final json = {}; + if (this.city != null) { + json[r'city'] = this.city; + } else { + // json[r'city'] = null; + } + if (this.country != null) { + json[r'country'] = this.country; + } else { + // json[r'country'] = null; + } + if (this.createdAfter != null) { + json[r'createdAfter'] = this.createdAfter!.toUtc().toIso8601String(); + } else { + // json[r'createdAfter'] = null; + } + if (this.createdBefore != null) { + json[r'createdBefore'] = this.createdBefore!.toUtc().toIso8601String(); + } else { + // json[r'createdBefore'] = null; + } + if (this.deviceId != null) { + json[r'deviceId'] = this.deviceId; + } else { + // json[r'deviceId'] = null; + } + if (this.isArchived != null) { + json[r'isArchived'] = this.isArchived; + } else { + // json[r'isArchived'] = null; + } + if (this.isEncoded != null) { + json[r'isEncoded'] = this.isEncoded; + } else { + // json[r'isEncoded'] = null; + } + if (this.isFavorite != null) { + json[r'isFavorite'] = this.isFavorite; + } else { + // json[r'isFavorite'] = null; + } + if (this.isMotion != null) { + json[r'isMotion'] = this.isMotion; + } else { + // json[r'isMotion'] = null; + } + if (this.isNotInAlbum != null) { + json[r'isNotInAlbum'] = this.isNotInAlbum; + } else { + // json[r'isNotInAlbum'] = null; + } + if (this.isOffline != null) { + json[r'isOffline'] = this.isOffline; + } else { + // json[r'isOffline'] = null; + } + if (this.isVisible != null) { + json[r'isVisible'] = this.isVisible; + } else { + // json[r'isVisible'] = null; + } + if (this.lensModel != null) { + json[r'lensModel'] = this.lensModel; + } else { + // json[r'lensModel'] = null; + } + if (this.libraryId != null) { + json[r'libraryId'] = this.libraryId; + } else { + // json[r'libraryId'] = null; + } + if (this.make != null) { + json[r'make'] = this.make; + } else { + // json[r'make'] = null; + } + if (this.model != null) { + json[r'model'] = this.model; + } else { + // json[r'model'] = null; + } + if (this.page != null) { + json[r'page'] = this.page; + } else { + // json[r'page'] = null; + } + json[r'personIds'] = this.personIds; + if (this.size != null) { + json[r'size'] = this.size; + } else { + // json[r'size'] = null; + } + if (this.state != null) { + json[r'state'] = this.state; + } else { + // json[r'state'] = null; + } + if (this.takenAfter != null) { + json[r'takenAfter'] = this.takenAfter!.toUtc().toIso8601String(); + } else { + // json[r'takenAfter'] = null; + } + if (this.takenBefore != null) { + json[r'takenBefore'] = this.takenBefore!.toUtc().toIso8601String(); + } else { + // json[r'takenBefore'] = null; + } + if (this.trashedAfter != null) { + json[r'trashedAfter'] = this.trashedAfter!.toUtc().toIso8601String(); + } else { + // json[r'trashedAfter'] = null; + } + if (this.trashedBefore != null) { + json[r'trashedBefore'] = this.trashedBefore!.toUtc().toIso8601String(); + } else { + // json[r'trashedBefore'] = null; + } + if (this.type != null) { + json[r'type'] = this.type; + } else { + // json[r'type'] = null; + } + if (this.updatedAfter != null) { + json[r'updatedAfter'] = this.updatedAfter!.toUtc().toIso8601String(); + } else { + // json[r'updatedAfter'] = null; + } + if (this.updatedBefore != null) { + json[r'updatedBefore'] = this.updatedBefore!.toUtc().toIso8601String(); + } else { + // json[r'updatedBefore'] = null; + } + json[r'withArchived'] = this.withArchived; + if (this.withDeleted != null) { + json[r'withDeleted'] = this.withDeleted; + } else { + // json[r'withDeleted'] = null; + } + if (this.withExif != null) { + json[r'withExif'] = this.withExif; + } else { + // json[r'withExif'] = null; + } + if (this.withPeople != null) { + json[r'withPeople'] = this.withPeople; + } else { + // json[r'withPeople'] = null; + } + if (this.withStacked != null) { + json[r'withStacked'] = this.withStacked; + } else { + // json[r'withStacked'] = null; + } + return json; + } + + /// Returns a new [RandomSearchDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static RandomSearchDto? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return RandomSearchDto( + city: mapValueOfType(json, r'city'), + country: mapValueOfType(json, r'country'), + createdAfter: mapDateTime(json, r'createdAfter', r''), + createdBefore: mapDateTime(json, r'createdBefore', r''), + deviceId: mapValueOfType(json, r'deviceId'), + isArchived: mapValueOfType(json, r'isArchived'), + isEncoded: mapValueOfType(json, r'isEncoded'), + isFavorite: mapValueOfType(json, r'isFavorite'), + isMotion: mapValueOfType(json, r'isMotion'), + isNotInAlbum: mapValueOfType(json, r'isNotInAlbum'), + isOffline: mapValueOfType(json, r'isOffline'), + isVisible: mapValueOfType(json, r'isVisible'), + lensModel: mapValueOfType(json, r'lensModel'), + libraryId: mapValueOfType(json, r'libraryId'), + make: mapValueOfType(json, r'make'), + model: mapValueOfType(json, r'model'), + page: num.parse('${json[r'page']}'), + personIds: json[r'personIds'] is Iterable + ? (json[r'personIds'] as Iterable).cast().toList(growable: false) + : const [], + size: num.parse('${json[r'size']}'), + state: mapValueOfType(json, r'state'), + takenAfter: mapDateTime(json, r'takenAfter', r''), + takenBefore: mapDateTime(json, r'takenBefore', r''), + trashedAfter: mapDateTime(json, r'trashedAfter', r''), + trashedBefore: mapDateTime(json, r'trashedBefore', r''), + type: AssetTypeEnum.fromJson(json[r'type']), + updatedAfter: mapDateTime(json, r'updatedAfter', r''), + updatedBefore: mapDateTime(json, r'updatedBefore', r''), + withArchived: mapValueOfType(json, r'withArchived') ?? false, + withDeleted: mapValueOfType(json, r'withDeleted'), + withExif: mapValueOfType(json, r'withExif'), + withPeople: mapValueOfType(json, r'withPeople'), + withStacked: mapValueOfType(json, r'withStacked'), + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = RandomSearchDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = RandomSearchDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of RandomSearchDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = RandomSearchDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + }; +} + diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index f48fa989dad57..706ff5b8fb654 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -1646,6 +1646,8 @@ }, "/assets/random": { "get": { + "deprecated": true, + "description": "This property was deprecated in v1.116.0", "operationId": "getRandom", "parameters": [ { @@ -1685,8 +1687,12 @@ } ], "tags": [ - "Assets" - ] + "Assets", + "Deprecated" + ], + "x-immich-lifecycle": { + "deprecatedAt": "v1.116.0" + } } }, "/assets/statistics": { @@ -4677,6 +4683,48 @@ ] } }, + "/search/random": { + "post": { + "operationId": "searchRandom", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RandomSearchDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SearchResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Search" + ] + } + }, "/search/smart": { "post": { "operationId": "searchSmart", @@ -10454,6 +10502,130 @@ ], "type": "object" }, + "RandomSearchDto": { + "properties": { + "city": { + "nullable": true, + "type": "string" + }, + "country": { + "nullable": true, + "type": "string" + }, + "createdAfter": { + "format": "date-time", + "type": "string" + }, + "createdBefore": { + "format": "date-time", + "type": "string" + }, + "deviceId": { + "type": "string" + }, + "isArchived": { + "type": "boolean" + }, + "isEncoded": { + "type": "boolean" + }, + "isFavorite": { + "type": "boolean" + }, + "isMotion": { + "type": "boolean" + }, + "isNotInAlbum": { + "type": "boolean" + }, + "isOffline": { + "type": "boolean" + }, + "isVisible": { + "type": "boolean" + }, + "lensModel": { + "nullable": true, + "type": "string" + }, + "libraryId": { + "format": "uuid", + "nullable": true, + "type": "string" + }, + "make": { + "type": "string" + }, + "model": { + "nullable": true, + "type": "string" + }, + "page": { + "minimum": 1, + "type": "number" + }, + "personIds": { + "items": { + "format": "uuid", + "type": "string" + }, + "type": "array" + }, + "size": { + "maximum": 1000, + "minimum": 1, + "type": "number" + }, + "state": { + "nullable": true, + "type": "string" + }, + "takenAfter": { + "format": "date-time", + "type": "string" + }, + "takenBefore": { + "format": "date-time", + "type": "string" + }, + "trashedAfter": { + "format": "date-time", + "type": "string" + }, + "trashedBefore": { + "format": "date-time", + "type": "string" + }, + "type": { + "$ref": "#/components/schemas/AssetTypeEnum" + }, + "updatedAfter": { + "format": "date-time", + "type": "string" + }, + "updatedBefore": { + "format": "date-time", + "type": "string" + }, + "withArchived": { + "default": false, + "type": "boolean" + }, + "withDeleted": { + "type": "boolean" + }, + "withExif": { + "type": "boolean" + }, + "withPeople": { + "type": "boolean" + }, + "withStacked": { + "type": "boolean" + } + }, + "type": "object" + }, "RatingsResponse": { "properties": { "enabled": { diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index c2d73bda1acaf..8e607f7570856 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -837,6 +837,40 @@ export type PlacesResponseDto = { longitude: number; name: string; }; +export type RandomSearchDto = { + city?: string | null; + country?: string | null; + createdAfter?: string; + createdBefore?: string; + deviceId?: string; + isArchived?: boolean; + isEncoded?: boolean; + isFavorite?: boolean; + isMotion?: boolean; + isNotInAlbum?: boolean; + isOffline?: boolean; + isVisible?: boolean; + lensModel?: string | null; + libraryId?: string | null; + make?: string; + model?: string | null; + page?: number; + personIds?: string[]; + size?: number; + state?: string | null; + takenAfter?: string; + takenBefore?: string; + trashedAfter?: string; + trashedBefore?: string; + "type"?: AssetTypeEnum; + updatedAfter?: string; + updatedBefore?: string; + withArchived?: boolean; + withDeleted?: boolean; + withExif?: boolean; + withPeople?: boolean; + withStacked?: boolean; +}; export type SmartSearchDto = { city?: string | null; country?: string | null; @@ -1696,6 +1730,9 @@ export function getMemoryLane({ day, month }: { ...opts })); } +/** + * This property was deprecated in v1.116.0 + */ export function getRandom({ count }: { count?: number; }, opts?: Oazapfts.RequestOpts) { @@ -2500,6 +2537,18 @@ export function searchPlaces({ name }: { ...opts })); } +export function searchRandom({ randomSearchDto }: { + randomSearchDto: RandomSearchDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: SearchResponseDto; + }>("/search/random", oazapfts.json({ + ...opts, + method: "POST", + body: randomSearchDto + }))); +} export function searchSmart({ smartSearchDto }: { smartSearchDto: SmartSearchDto; }, opts?: Oazapfts.RequestOpts) { diff --git a/server/src/controllers/asset.controller.ts b/server/src/controllers/asset.controller.ts index c6fdac1710edc..9d3d23065724c 100644 --- a/server/src/controllers/asset.controller.ts +++ b/server/src/controllers/asset.controller.ts @@ -1,5 +1,6 @@ import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; +import { EndpointLifecycle } from 'src/decorators'; import { AssetResponseDto, MemoryLaneResponseDto } from 'src/dtos/asset-response.dto'; import { AssetBulkDeleteDto, @@ -31,6 +32,7 @@ export class AssetController { @Get('random') @Authenticated() + @EndpointLifecycle({ deprecatedAt: 'v1.116.0' }) getRandom(@Auth() auth: AuthDto, @Query() dto: RandomAssetsDto): Promise { return this.service.getRandom(auth, dto.count ?? 1); } diff --git a/server/src/controllers/search.controller.ts b/server/src/controllers/search.controller.ts index 5b8c1eeece026..5b6deb2981bc5 100644 --- a/server/src/controllers/search.controller.ts +++ b/server/src/controllers/search.controller.ts @@ -6,6 +6,7 @@ import { PersonResponseDto } from 'src/dtos/person.dto'; import { MetadataSearchDto, PlacesResponseDto, + RandomSearchDto, SearchExploreResponseDto, SearchPeopleDto, SearchPlacesDto, @@ -28,6 +29,13 @@ export class SearchController { return this.service.searchMetadata(auth, dto); } + @Post('random') + @HttpCode(HttpStatus.OK) + @Authenticated() + searchRandom(@Auth() auth: AuthDto, @Body() dto: RandomSearchDto): Promise { + return this.service.searchRandom(auth, dto); + } + @Post('smart') @HttpCode(HttpStatus.OK) @Authenticated() diff --git a/server/src/dtos/search.dto.ts b/server/src/dtos/search.dto.ts index 9e36cfee800b8..ddc6c192c5faa 100644 --- a/server/src/dtos/search.dto.ts +++ b/server/src/dtos/search.dto.ts @@ -119,7 +119,15 @@ class BaseSearchDto { personIds?: string[]; } -export class MetadataSearchDto extends BaseSearchDto { +export class RandomSearchDto extends BaseSearchDto { + @ValidateBoolean({ optional: true }) + withStacked?: boolean; + + @ValidateBoolean({ optional: true }) + withPeople?: boolean; +} + +export class MetadataSearchDto extends RandomSearchDto { @ValidateUUID({ optional: true }) id?: string; @@ -133,12 +141,6 @@ export class MetadataSearchDto extends BaseSearchDto { @Optional() checksum?: string; - @ValidateBoolean({ optional: true }) - withStacked?: boolean; - - @ValidateBoolean({ optional: true }) - withPeople?: boolean; - @IsString() @IsNotEmpty() @Optional() diff --git a/server/src/interfaces/search.interface.ts b/server/src/interfaces/search.interface.ts index 6578d0a4830eb..0ba524c00a272 100644 --- a/server/src/interfaces/search.interface.ts +++ b/server/src/interfaces/search.interface.ts @@ -116,6 +116,7 @@ export interface SearchPeopleOptions { export interface SearchOrderOptions { orderDirection?: 'ASC' | 'DESC'; + random?: boolean; } export interface SearchPaginationOptions { diff --git a/server/src/repositories/search.repository.ts b/server/src/repositories/search.repository.ts index 999e9063ef2a4..8115c72cf6ac1 100644 --- a/server/src/repositories/search.repository.ts +++ b/server/src/repositories/search.repository.ts @@ -73,8 +73,13 @@ export class SearchRepository implements ISearchRepository { async searchMetadata(pagination: SearchPaginationOptions, options: AssetSearchOptions): Paginated { let builder = this.assetRepository.createQueryBuilder('asset'); builder = searchAssetBuilder(builder, options); - builder.orderBy('asset.fileCreatedAt', options.orderDirection ?? 'DESC'); + + if (options.random) { + // TODO replace with complicated SQL magic after kysely migration + builder.addSelect('RANDOM() as r').orderBy('r'); + } + return paginatedBuilder(builder, { mode: PaginationMode.SKIP_TAKE, skip: (pagination.page - 1) * pagination.size, diff --git a/server/src/services/search.service.ts b/server/src/services/search.service.ts index 73ace233d08af..dc6e71f345943 100644 --- a/server/src/services/search.service.ts +++ b/server/src/services/search.service.ts @@ -6,6 +6,7 @@ import { PersonResponseDto } from 'src/dtos/person.dto'; import { MetadataSearchDto, PlacesResponseDto, + RandomSearchDto, SearchPeopleDto, SearchPlacesDto, SearchResponseDto, @@ -93,6 +94,22 @@ export class SearchService { return this.mapResponse(items, hasNextPage ? (page + 1).toString() : null, { auth }); } + async searchRandom(auth: AuthDto, dto: RandomSearchDto): Promise { + const userIds = await this.getUserIdsToSearch(auth); + const page = dto.page ?? 1; + const size = dto.size || 250; + const { hasNextPage, items } = await this.searchRepository.searchMetadata( + { page, size }, + { + ...dto, + userIds, + random: true, + }, + ); + + return this.mapResponse(items, hasNextPage ? (page + 1).toString() : null, { auth }); + } + async searchSmart(auth: AuthDto, dto: SmartSearchDto): Promise { const { machineLearning } = await this.configCore.getConfig({ withCache: false }); if (!isSmartSearchEnabled(machineLearning)) { From e748945b4f3ba06c5f615ad93d20b113e6ed5ee9 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Mon, 23 Sep 2024 13:22:36 -0400 Subject: [PATCH 072/123] fix(server): gracefully handle unknown jobs (#12870) --- server/src/services/job.service.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/server/src/services/job.service.ts b/server/src/services/job.service.ts index 03a6edf126e3a..5ed9f3202457b 100644 --- a/server/src/services/job.service.ts +++ b/server/src/services/job.service.ts @@ -186,11 +186,16 @@ export class JobService { this.jobRepository.addHandler(queueName, concurrency, async (item: JobItem): Promise => { const { name, data } = item; + const handler = jobHandlers[name]; + if (!handler) { + this.logger.warn(`Skipping unknown job: "${name}"`); + return; + } + const queueMetric = `immich.queues.${snakeCase(queueName)}.active`; this.metricRepository.jobs.addToGauge(queueMetric, 1); try { - const handler = jobHandlers[name]; const status = await handler(data); const jobMetric = `immich.jobs.${name.replaceAll('-', '_')}.${status}`; this.metricRepository.jobs.addToCounter(jobMetric, 1); From 87c54d6659a73916a2c3133966b00b5b78b4e408 Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Mon, 23 Sep 2024 19:37:08 +0200 Subject: [PATCH 073/123] fix: show asset count for unassigned faces (#12871) --- .../[[photos=photos]]/[[assetId=id]]/+page.svelte | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte index 83019d67cd869..037feaf35f6f1 100644 --- a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -486,17 +486,10 @@
- {#if person.name} -

{person.name}

-

- {$t('assets_count', { values: { count: numberOfAssets } })} -

- {:else} -

{$t('add_a_name')}

-

- {$t('find_them_fast')} -

- {/if} +

{person.name || $t('add_a_name')}

+

+ {$t('assets_count', { values: { count: numberOfAssets } })} +

From 3008050e4c71ea6ea2be9f0831ea19b24fd37500 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Mon, 23 Sep 2024 13:51:03 -0400 Subject: [PATCH 074/123] fix: remove no longer needed LD_LIBRARY_PATH (#12872) --- docker/hwaccel.transcoding.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/docker/hwaccel.transcoding.yml b/docker/hwaccel.transcoding.yml index bd4e2a46b8b39..33fb7b3c06273 100644 --- a/docker/hwaccel.transcoding.yml +++ b/docker/hwaccel.transcoding.yml @@ -51,5 +51,4 @@ services: volumes: - /usr/lib/wsl:/usr/lib/wsl environment: - - LD_LIBRARY_PATH=/usr/lib/wsl/lib - LIBVA_DRIVER_NAME=d3d12 From ad33ce5938c34edc7885b4244cef83edb09e39d5 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Mon, 23 Sep 2024 15:41:41 -0400 Subject: [PATCH 075/123] refactor(mobile): open api dto upgrade (#12793) --- mobile/openapi/lib/api_client.dart | 1 - .../lib/model/activity_create_dto.dart | 1 + .../lib/model/activity_response_dto.dart | 1 + .../activity_statistics_response_dto.dart | 1 + mobile/openapi/lib/model/add_users_dto.dart | 1 + .../model/admin_onboarding_update_dto.dart | 1 + .../openapi/lib/model/album_response_dto.dart | 1 + .../model/album_statistics_response_dto.dart | 1 + .../openapi/lib/model/album_user_add_dto.dart | 1 + .../lib/model/album_user_create_dto.dart | 1 + .../lib/model/album_user_response_dto.dart | 1 + .../model/all_job_status_response_dto.dart | 1 + .../openapi/lib/model/api_key_create_dto.dart | 1 + .../model/api_key_create_response_dto.dart | 1 + .../lib/model/api_key_response_dto.dart | 1 + .../openapi/lib/model/api_key_update_dto.dart | 1 + .../lib/model/asset_bulk_delete_dto.dart | 1 + .../lib/model/asset_bulk_update_dto.dart | 1 + .../model/asset_bulk_upload_check_dto.dart | 1 + .../model/asset_bulk_upload_check_item.dart | 1 + .../asset_bulk_upload_check_response_dto.dart | 1 + .../model/asset_bulk_upload_check_result.dart | 1 + .../lib/model/asset_delta_sync_dto.dart | 1 + .../model/asset_delta_sync_response_dto.dart | 1 + .../lib/model/asset_face_response_dto.dart | 1 + .../lib/model/asset_face_update_dto.dart | 1 + .../lib/model/asset_face_update_item.dart | 1 + ...sset_face_without_person_response_dto.dart | 1 + .../lib/model/asset_full_sync_dto.dart | 1 + mobile/openapi/lib/model/asset_ids_dto.dart | 1 + .../lib/model/asset_ids_response_dto.dart | 1 + mobile/openapi/lib/model/asset_jobs_dto.dart | 1 + .../lib/model/asset_media_response_dto.dart | 1 + .../openapi/lib/model/asset_response_dto.dart | 1 + .../lib/model/asset_stack_response_dto.dart | 1 + .../lib/model/asset_stats_response_dto.dart | 1 + .../lib/model/audit_deletes_response_dto.dart | 1 + mobile/openapi/lib/model/avatar_response.dart | 1 + mobile/openapi/lib/model/avatar_update.dart | 1 + .../lib/model/bulk_id_response_dto.dart | 1 + mobile/openapi/lib/model/bulk_ids_dto.dart | 1 + .../lib/model/change_password_dto.dart | 1 + .../lib/model/check_existing_assets_dto.dart | 1 + .../check_existing_assets_response_dto.dart | 1 + mobile/openapi/lib/model/clip_config.dart | 1 + .../openapi/lib/model/create_album_dto.dart | 1 + .../openapi/lib/model/create_library_dto.dart | 1 + .../create_profile_image_response_dto.dart | 1 + .../lib/model/download_archive_info.dart | 1 + .../openapi/lib/model/download_info_dto.dart | 1 + .../openapi/lib/model/download_response.dart | 1 + .../lib/model/download_response_dto.dart | 1 + mobile/openapi/lib/model/download_update.dart | 1 + .../lib/model/duplicate_detection_config.dart | 1 + .../lib/model/duplicate_response_dto.dart | 1 + .../model/email_notifications_response.dart | 1 + .../lib/model/email_notifications_update.dart | 1 + .../openapi/lib/model/exif_response_dto.dart | 1 + mobile/openapi/lib/model/face_dto.dart | 1 + .../lib/model/facial_recognition_config.dart | 1 + .../openapi/lib/model/file_checksum_dto.dart | 1 + .../lib/model/file_checksum_response_dto.dart | 1 + mobile/openapi/lib/model/file_report_dto.dart | 1 + .../lib/model/file_report_fix_dto.dart | 1 + .../lib/model/file_report_item_dto.dart | 1 + .../openapi/lib/model/folders_response.dart | 1 + mobile/openapi/lib/model/folders_update.dart | 1 + mobile/openapi/lib/model/job_command_dto.dart | 1 + mobile/openapi/lib/model/job_counts_dto.dart | 1 + mobile/openapi/lib/model/job_create_dto.dart | 1 + .../openapi/lib/model/job_settings_dto.dart | 1 + mobile/openapi/lib/model/job_status_dto.dart | 1 + .../lib/model/library_response_dto.dart | 1 + .../lib/model/library_stats_response_dto.dart | 1 + mobile/openapi/lib/model/license_key_dto.dart | 1 + .../lib/model/license_response_dto.dart | 1 + .../lib/model/login_credential_dto.dart | 1 + .../openapi/lib/model/login_response_dto.dart | 1 + .../lib/model/logout_response_dto.dart | 1 + .../lib/model/map_marker_response_dto.dart | 1 + .../map_reverse_geocode_response_dto.dart | 1 + .../openapi/lib/model/memories_response.dart | 1 + mobile/openapi/lib/model/memories_update.dart | 1 + .../openapi/lib/model/memory_create_dto.dart | 1 + .../lib/model/memory_lane_response_dto.dart | 1 + .../lib/model/memory_response_dto.dart | 1 + .../openapi/lib/model/memory_update_dto.dart | 1 + .../openapi/lib/model/merge_person_dto.dart | 1 + .../lib/model/metadata_search_dto.dart | 1 + .../model/o_auth_authorize_response_dto.dart | 1 + .../lib/model/o_auth_callback_dto.dart | 1 + .../openapi/lib/model/o_auth_config_dto.dart | 1 + mobile/openapi/lib/model/on_this_day_dto.dart | 1 + .../lib/model/partner_response_dto.dart | 1 + mobile/openapi/lib/model/people_response.dart | 1 + .../lib/model/people_response_dto.dart | 1 + mobile/openapi/lib/model/people_update.dart | 1 + .../openapi/lib/model/people_update_dto.dart | 1 + .../openapi/lib/model/people_update_item.dart | 1 + .../openapi/lib/model/person_create_dto.dart | 1 + .../lib/model/person_response_dto.dart | 1 + .../model/person_statistics_response_dto.dart | 1 + .../openapi/lib/model/person_update_dto.dart | 1 + .../model/person_with_faces_response_dto.dart | 1 + .../lib/model/places_response_dto.dart | 1 + .../openapi/lib/model/purchase_response.dart | 1 + mobile/openapi/lib/model/purchase_update.dart | 1 + .../openapi/lib/model/queue_status_dto.dart | 1 + .../openapi/lib/model/ratings_response.dart | 1 + mobile/openapi/lib/model/ratings_update.dart | 1 + .../reverse_geocoding_state_response_dto.dart | 1 + .../openapi/lib/model/scan_library_dto.dart | 1 + .../lib/model/search_album_response_dto.dart | 1 + .../lib/model/search_asset_response_dto.dart | 1 + .../lib/model/search_explore_item.dart | 1 + .../model/search_explore_response_dto.dart | 1 + .../search_facet_count_response_dto.dart | 1 + .../lib/model/search_facet_response_dto.dart | 1 + .../lib/model/search_response_dto.dart | 1 + .../lib/model/server_about_response_dto.dart | 1 + .../openapi/lib/model/server_config_dto.dart | 1 + .../lib/model/server_features_dto.dart | 1 + .../server_media_types_response_dto.dart | 1 + .../lib/model/server_ping_response.dart | 1 + .../lib/model/server_stats_response_dto.dart | 1 + .../model/server_storage_response_dto.dart | 1 + .../openapi/lib/model/server_theme_dto.dart | 1 + .../model/server_version_response_dto.dart | 1 + .../lib/model/session_response_dto.dart | 1 + .../lib/model/shared_link_create_dto.dart | 1 + .../lib/model/shared_link_edit_dto.dart | 1 + .../lib/model/shared_link_response_dto.dart | 1 + mobile/openapi/lib/model/sign_up_dto.dart | 1 + .../lib/model/smart_info_response_dto.dart | 1 + .../openapi/lib/model/smart_search_dto.dart | 1 + .../openapi/lib/model/stack_create_dto.dart | 1 + .../openapi/lib/model/stack_response_dto.dart | 1 + .../openapi/lib/model/stack_update_dto.dart | 1 + .../openapi/lib/model/system_config_dto.dart | 1 + .../lib/model/system_config_f_fmpeg_dto.dart | 1 + .../lib/model/system_config_faces_dto.dart | 1 + .../lib/model/system_config_image_dto.dart | 1 + .../lib/model/system_config_job_dto.dart | 1 + .../lib/model/system_config_library_dto.dart | 1 + .../model/system_config_library_scan_dto.dart | 1 + .../system_config_library_watch_dto.dart | 1 + .../lib/model/system_config_logging_dto.dart | 1 + .../system_config_machine_learning_dto.dart | 1 + .../lib/model/system_config_map_dto.dart | 1 + .../lib/model/system_config_metadata_dto.dart | 1 + .../system_config_new_version_check_dto.dart | 1 + .../system_config_notifications_dto.dart | 1 + .../lib/model/system_config_o_auth_dto.dart | 1 + .../system_config_password_login_dto.dart | 1 + .../system_config_reverse_geocoding_dto.dart | 1 + .../lib/model/system_config_server_dto.dart | 1 + .../lib/model/system_config_smtp_dto.dart | 1 + .../system_config_smtp_transport_dto.dart | 1 + .../system_config_storage_template_dto.dart | 1 + ...em_config_template_storage_option_dto.dart | 1 + .../lib/model/system_config_theme_dto.dart | 1 + .../lib/model/system_config_trash_dto.dart | 1 + .../lib/model/system_config_user_dto.dart | 1 + .../lib/model/tag_bulk_assets_dto.dart | 1 + .../model/tag_bulk_assets_response_dto.dart | 1 + mobile/openapi/lib/model/tag_create_dto.dart | 1 + .../openapi/lib/model/tag_response_dto.dart | 1 + mobile/openapi/lib/model/tag_update_dto.dart | 1 + mobile/openapi/lib/model/tag_upsert_dto.dart | 1 + mobile/openapi/lib/model/tags_response.dart | 1 + mobile/openapi/lib/model/tags_update.dart | 1 + .../lib/model/time_bucket_response_dto.dart | 1 + .../openapi/lib/model/trash_response_dto.dart | 1 + .../openapi/lib/model/update_album_dto.dart | 1 + .../lib/model/update_album_user_dto.dart | 1 + .../openapi/lib/model/update_asset_dto.dart | 1 + .../openapi/lib/model/update_library_dto.dart | 1 + .../openapi/lib/model/update_partner_dto.dart | 1 + .../openapi/lib/model/usage_by_user_dto.dart | 1 + .../lib/model/user_admin_create_dto.dart | 1 + .../lib/model/user_admin_delete_dto.dart | 1 + .../lib/model/user_admin_response_dto.dart | 1 + .../lib/model/user_admin_update_dto.dart | 1 + mobile/openapi/lib/model/user_license.dart | 1 + .../model/user_preferences_response_dto.dart | 1 + .../model/user_preferences_update_dto.dart | 1 + .../openapi/lib/model/user_response_dto.dart | 1 + .../openapi/lib/model/user_update_me_dto.dart | 1 + .../validate_access_token_response_dto.dart | 1 + .../lib/model/validate_library_dto.dart | 1 + ...date_library_import_path_response_dto.dart | 1 + .../model/validate_library_response_dto.dart | 1 + open-api/bin/generate-open-api.sh | 8 +- open-api/templates/mobile/api_client.mustache | 264 ------------------ .../mobile/api_client.mustache.patch | 10 - .../native/native_class.mustache | 1 + .../native/native_class.mustache.patch | 18 +- 197 files changed, 205 insertions(+), 288 deletions(-) delete mode 100644 open-api/templates/mobile/api_client.mustache delete mode 100644 open-api/templates/mobile/api_client.mustache.patch diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 597a15d5b0562..e857f51e3a875 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -166,7 +166,6 @@ class ApiClient { /// Returns a native instance of an OpenAPI class matching the [specified type][targetType]. static dynamic fromJson(dynamic value, String targetType, {bool growable = false,}) { - upgradeDto(value, targetType); try { switch (targetType) { case 'String': diff --git a/mobile/openapi/lib/model/activity_create_dto.dart b/mobile/openapi/lib/model/activity_create_dto.dart index b54fa2ca72bce..ce4b4a01766a4 100644 --- a/mobile/openapi/lib/model/activity_create_dto.dart +++ b/mobile/openapi/lib/model/activity_create_dto.dart @@ -78,6 +78,7 @@ class ActivityCreateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static ActivityCreateDto? fromJson(dynamic value) { + upgradeDto(value, "ActivityCreateDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/activity_response_dto.dart b/mobile/openapi/lib/model/activity_response_dto.dart index bfffd8485b0a9..25fb0f53f8707 100644 --- a/mobile/openapi/lib/model/activity_response_dto.dart +++ b/mobile/openapi/lib/model/activity_response_dto.dart @@ -78,6 +78,7 @@ class ActivityResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static ActivityResponseDto? fromJson(dynamic value) { + upgradeDto(value, "ActivityResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/activity_statistics_response_dto.dart b/mobile/openapi/lib/model/activity_statistics_response_dto.dart index 20d4696b1b60e..ad0b814a58774 100644 --- a/mobile/openapi/lib/model/activity_statistics_response_dto.dart +++ b/mobile/openapi/lib/model/activity_statistics_response_dto.dart @@ -40,6 +40,7 @@ class ActivityStatisticsResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static ActivityStatisticsResponseDto? fromJson(dynamic value) { + upgradeDto(value, "ActivityStatisticsResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/add_users_dto.dart b/mobile/openapi/lib/model/add_users_dto.dart index 2daa571265da6..531c1ec785b45 100644 --- a/mobile/openapi/lib/model/add_users_dto.dart +++ b/mobile/openapi/lib/model/add_users_dto.dart @@ -40,6 +40,7 @@ class AddUsersDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AddUsersDto? fromJson(dynamic value) { + upgradeDto(value, "AddUsersDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/admin_onboarding_update_dto.dart b/mobile/openapi/lib/model/admin_onboarding_update_dto.dart index 2277f0958c813..298bf318a292b 100644 --- a/mobile/openapi/lib/model/admin_onboarding_update_dto.dart +++ b/mobile/openapi/lib/model/admin_onboarding_update_dto.dart @@ -40,6 +40,7 @@ class AdminOnboardingUpdateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AdminOnboardingUpdateDto? fromJson(dynamic value) { + upgradeDto(value, "AdminOnboardingUpdateDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/album_response_dto.dart b/mobile/openapi/lib/model/album_response_dto.dart index c98a95775d2c8..547a6a70fd221 100644 --- a/mobile/openapi/lib/model/album_response_dto.dart +++ b/mobile/openapi/lib/model/album_response_dto.dart @@ -186,6 +186,7 @@ class AlbumResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AlbumResponseDto? fromJson(dynamic value) { + upgradeDto(value, "AlbumResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/album_statistics_response_dto.dart b/mobile/openapi/lib/model/album_statistics_response_dto.dart index 90dbe520163bb..9e19002cf18c2 100644 --- a/mobile/openapi/lib/model/album_statistics_response_dto.dart +++ b/mobile/openapi/lib/model/album_statistics_response_dto.dart @@ -52,6 +52,7 @@ class AlbumStatisticsResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AlbumStatisticsResponseDto? fromJson(dynamic value) { + upgradeDto(value, "AlbumStatisticsResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/album_user_add_dto.dart b/mobile/openapi/lib/model/album_user_add_dto.dart index e654a2ff5d7b0..3f72d5c893e18 100644 --- a/mobile/openapi/lib/model/album_user_add_dto.dart +++ b/mobile/openapi/lib/model/album_user_add_dto.dart @@ -56,6 +56,7 @@ class AlbumUserAddDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AlbumUserAddDto? fromJson(dynamic value) { + upgradeDto(value, "AlbumUserAddDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/album_user_create_dto.dart b/mobile/openapi/lib/model/album_user_create_dto.dart index 708acd472beed..93a0661b30251 100644 --- a/mobile/openapi/lib/model/album_user_create_dto.dart +++ b/mobile/openapi/lib/model/album_user_create_dto.dart @@ -46,6 +46,7 @@ class AlbumUserCreateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AlbumUserCreateDto? fromJson(dynamic value) { + upgradeDto(value, "AlbumUserCreateDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/album_user_response_dto.dart b/mobile/openapi/lib/model/album_user_response_dto.dart index 8f86cf254ea92..bbae03fba74c3 100644 --- a/mobile/openapi/lib/model/album_user_response_dto.dart +++ b/mobile/openapi/lib/model/album_user_response_dto.dart @@ -46,6 +46,7 @@ class AlbumUserResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AlbumUserResponseDto? fromJson(dynamic value) { + upgradeDto(value, "AlbumUserResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/all_job_status_response_dto.dart b/mobile/openapi/lib/model/all_job_status_response_dto.dart index 1ee5253c38bde..6ec248a638e80 100644 --- a/mobile/openapi/lib/model/all_job_status_response_dto.dart +++ b/mobile/openapi/lib/model/all_job_status_response_dto.dart @@ -118,6 +118,7 @@ class AllJobStatusResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AllJobStatusResponseDto? fromJson(dynamic value) { + upgradeDto(value, "AllJobStatusResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/api_key_create_dto.dart b/mobile/openapi/lib/model/api_key_create_dto.dart index 433855c4cfe17..848774e9c9cf7 100644 --- a/mobile/openapi/lib/model/api_key_create_dto.dart +++ b/mobile/openapi/lib/model/api_key_create_dto.dart @@ -56,6 +56,7 @@ class APIKeyCreateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static APIKeyCreateDto? fromJson(dynamic value) { + upgradeDto(value, "APIKeyCreateDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/api_key_create_response_dto.dart b/mobile/openapi/lib/model/api_key_create_response_dto.dart index 93065654ac331..cdaa70e37de71 100644 --- a/mobile/openapi/lib/model/api_key_create_response_dto.dart +++ b/mobile/openapi/lib/model/api_key_create_response_dto.dart @@ -46,6 +46,7 @@ class APIKeyCreateResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static APIKeyCreateResponseDto? fromJson(dynamic value) { + upgradeDto(value, "APIKeyCreateResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/api_key_response_dto.dart b/mobile/openapi/lib/model/api_key_response_dto.dart index b6ca86c050944..fd0d91f6737e3 100644 --- a/mobile/openapi/lib/model/api_key_response_dto.dart +++ b/mobile/openapi/lib/model/api_key_response_dto.dart @@ -64,6 +64,7 @@ class APIKeyResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static APIKeyResponseDto? fromJson(dynamic value) { + upgradeDto(value, "APIKeyResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/api_key_update_dto.dart b/mobile/openapi/lib/model/api_key_update_dto.dart index 318f4936e16b2..7295d1ea1f19b 100644 --- a/mobile/openapi/lib/model/api_key_update_dto.dart +++ b/mobile/openapi/lib/model/api_key_update_dto.dart @@ -40,6 +40,7 @@ class APIKeyUpdateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static APIKeyUpdateDto? fromJson(dynamic value) { + upgradeDto(value, "APIKeyUpdateDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/asset_bulk_delete_dto.dart b/mobile/openapi/lib/model/asset_bulk_delete_dto.dart index 0f6913a7f4ebc..c4453054b1b92 100644 --- a/mobile/openapi/lib/model/asset_bulk_delete_dto.dart +++ b/mobile/openapi/lib/model/asset_bulk_delete_dto.dart @@ -56,6 +56,7 @@ class AssetBulkDeleteDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AssetBulkDeleteDto? fromJson(dynamic value) { + upgradeDto(value, "AssetBulkDeleteDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/asset_bulk_update_dto.dart b/mobile/openapi/lib/model/asset_bulk_update_dto.dart index c9b21683fbcec..da23d2f09d2e0 100644 --- a/mobile/openapi/lib/model/asset_bulk_update_dto.dart +++ b/mobile/openapi/lib/model/asset_bulk_update_dto.dart @@ -148,6 +148,7 @@ class AssetBulkUpdateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AssetBulkUpdateDto? fromJson(dynamic value) { + upgradeDto(value, "AssetBulkUpdateDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/asset_bulk_upload_check_dto.dart b/mobile/openapi/lib/model/asset_bulk_upload_check_dto.dart index 55ea41b598e75..36c13bfdf6889 100644 --- a/mobile/openapi/lib/model/asset_bulk_upload_check_dto.dart +++ b/mobile/openapi/lib/model/asset_bulk_upload_check_dto.dart @@ -40,6 +40,7 @@ class AssetBulkUploadCheckDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AssetBulkUploadCheckDto? fromJson(dynamic value) { + upgradeDto(value, "AssetBulkUploadCheckDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/asset_bulk_upload_check_item.dart b/mobile/openapi/lib/model/asset_bulk_upload_check_item.dart index 16294cdae6d06..13dfa340fad0c 100644 --- a/mobile/openapi/lib/model/asset_bulk_upload_check_item.dart +++ b/mobile/openapi/lib/model/asset_bulk_upload_check_item.dart @@ -47,6 +47,7 @@ class AssetBulkUploadCheckItem { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AssetBulkUploadCheckItem? fromJson(dynamic value) { + upgradeDto(value, "AssetBulkUploadCheckItem"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/asset_bulk_upload_check_response_dto.dart b/mobile/openapi/lib/model/asset_bulk_upload_check_response_dto.dart index 5bfacbff570d2..8c3651e9fa189 100644 --- a/mobile/openapi/lib/model/asset_bulk_upload_check_response_dto.dart +++ b/mobile/openapi/lib/model/asset_bulk_upload_check_response_dto.dart @@ -40,6 +40,7 @@ class AssetBulkUploadCheckResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AssetBulkUploadCheckResponseDto? fromJson(dynamic value) { + upgradeDto(value, "AssetBulkUploadCheckResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/asset_bulk_upload_check_result.dart b/mobile/openapi/lib/model/asset_bulk_upload_check_result.dart index a016b357e7e6b..88e46dae7daa7 100644 --- a/mobile/openapi/lib/model/asset_bulk_upload_check_result.dart +++ b/mobile/openapi/lib/model/asset_bulk_upload_check_result.dart @@ -88,6 +88,7 @@ class AssetBulkUploadCheckResult { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AssetBulkUploadCheckResult? fromJson(dynamic value) { + upgradeDto(value, "AssetBulkUploadCheckResult"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/asset_delta_sync_dto.dart b/mobile/openapi/lib/model/asset_delta_sync_dto.dart index a5ee10f33e17e..845aadcdcd3b4 100644 --- a/mobile/openapi/lib/model/asset_delta_sync_dto.dart +++ b/mobile/openapi/lib/model/asset_delta_sync_dto.dart @@ -46,6 +46,7 @@ class AssetDeltaSyncDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AssetDeltaSyncDto? fromJson(dynamic value) { + upgradeDto(value, "AssetDeltaSyncDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/asset_delta_sync_response_dto.dart b/mobile/openapi/lib/model/asset_delta_sync_response_dto.dart index 3b14fa68cf8e7..a64e1a2fbee42 100644 --- a/mobile/openapi/lib/model/asset_delta_sync_response_dto.dart +++ b/mobile/openapi/lib/model/asset_delta_sync_response_dto.dart @@ -52,6 +52,7 @@ class AssetDeltaSyncResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AssetDeltaSyncResponseDto? fromJson(dynamic value) { + upgradeDto(value, "AssetDeltaSyncResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/asset_face_response_dto.dart b/mobile/openapi/lib/model/asset_face_response_dto.dart index 7a8588ce5c4af..c05b511649236 100644 --- a/mobile/openapi/lib/model/asset_face_response_dto.dart +++ b/mobile/openapi/lib/model/asset_face_response_dto.dart @@ -102,6 +102,7 @@ class AssetFaceResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AssetFaceResponseDto? fromJson(dynamic value) { + upgradeDto(value, "AssetFaceResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/asset_face_update_dto.dart b/mobile/openapi/lib/model/asset_face_update_dto.dart index 58def49ae1ae4..71bdde8e9a6a3 100644 --- a/mobile/openapi/lib/model/asset_face_update_dto.dart +++ b/mobile/openapi/lib/model/asset_face_update_dto.dart @@ -40,6 +40,7 @@ class AssetFaceUpdateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AssetFaceUpdateDto? fromJson(dynamic value) { + upgradeDto(value, "AssetFaceUpdateDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/asset_face_update_item.dart b/mobile/openapi/lib/model/asset_face_update_item.dart index 5ea37ea4db5f5..c2c48032595dc 100644 --- a/mobile/openapi/lib/model/asset_face_update_item.dart +++ b/mobile/openapi/lib/model/asset_face_update_item.dart @@ -46,6 +46,7 @@ class AssetFaceUpdateItem { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AssetFaceUpdateItem? fromJson(dynamic value) { + upgradeDto(value, "AssetFaceUpdateItem"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/asset_face_without_person_response_dto.dart b/mobile/openapi/lib/model/asset_face_without_person_response_dto.dart index ecfe06bd7d6ce..8bf07e15347ca 100644 --- a/mobile/openapi/lib/model/asset_face_without_person_response_dto.dart +++ b/mobile/openapi/lib/model/asset_face_without_person_response_dto.dart @@ -92,6 +92,7 @@ class AssetFaceWithoutPersonResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AssetFaceWithoutPersonResponseDto? fromJson(dynamic value) { + upgradeDto(value, "AssetFaceWithoutPersonResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/asset_full_sync_dto.dart b/mobile/openapi/lib/model/asset_full_sync_dto.dart index e80638f6b0869..7151094b9588c 100644 --- a/mobile/openapi/lib/model/asset_full_sync_dto.dart +++ b/mobile/openapi/lib/model/asset_full_sync_dto.dart @@ -79,6 +79,7 @@ class AssetFullSyncDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AssetFullSyncDto? fromJson(dynamic value) { + upgradeDto(value, "AssetFullSyncDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/asset_ids_dto.dart b/mobile/openapi/lib/model/asset_ids_dto.dart index c8c7a69b8907c..b44888f3962b3 100644 --- a/mobile/openapi/lib/model/asset_ids_dto.dart +++ b/mobile/openapi/lib/model/asset_ids_dto.dart @@ -40,6 +40,7 @@ class AssetIdsDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AssetIdsDto? fromJson(dynamic value) { + upgradeDto(value, "AssetIdsDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/asset_ids_response_dto.dart b/mobile/openapi/lib/model/asset_ids_response_dto.dart index a642c0924cba8..ff63091caa577 100644 --- a/mobile/openapi/lib/model/asset_ids_response_dto.dart +++ b/mobile/openapi/lib/model/asset_ids_response_dto.dart @@ -56,6 +56,7 @@ class AssetIdsResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AssetIdsResponseDto? fromJson(dynamic value) { + upgradeDto(value, "AssetIdsResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/asset_jobs_dto.dart b/mobile/openapi/lib/model/asset_jobs_dto.dart index 16ed2644fd6cd..0f8bfab009fa1 100644 --- a/mobile/openapi/lib/model/asset_jobs_dto.dart +++ b/mobile/openapi/lib/model/asset_jobs_dto.dart @@ -46,6 +46,7 @@ class AssetJobsDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AssetJobsDto? fromJson(dynamic value) { + upgradeDto(value, "AssetJobsDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/asset_media_response_dto.dart b/mobile/openapi/lib/model/asset_media_response_dto.dart index c2801c93cce55..75428ec5f61d8 100644 --- a/mobile/openapi/lib/model/asset_media_response_dto.dart +++ b/mobile/openapi/lib/model/asset_media_response_dto.dart @@ -46,6 +46,7 @@ class AssetMediaResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AssetMediaResponseDto? fromJson(dynamic value) { + upgradeDto(value, "AssetMediaResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/asset_response_dto.dart b/mobile/openapi/lib/model/asset_response_dto.dart index bfb461efdc4c5..c11dedcbfd230 100644 --- a/mobile/openapi/lib/model/asset_response_dto.dart +++ b/mobile/openapi/lib/model/asset_response_dto.dart @@ -293,6 +293,7 @@ class AssetResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AssetResponseDto? fromJson(dynamic value) { + upgradeDto(value, "AssetResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/asset_stack_response_dto.dart b/mobile/openapi/lib/model/asset_stack_response_dto.dart index 89d30f7810682..bb4becb129c17 100644 --- a/mobile/openapi/lib/model/asset_stack_response_dto.dart +++ b/mobile/openapi/lib/model/asset_stack_response_dto.dart @@ -52,6 +52,7 @@ class AssetStackResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AssetStackResponseDto? fromJson(dynamic value) { + upgradeDto(value, "AssetStackResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/asset_stats_response_dto.dart b/mobile/openapi/lib/model/asset_stats_response_dto.dart index c21d7fdbffa4c..d11ce55a5cc5c 100644 --- a/mobile/openapi/lib/model/asset_stats_response_dto.dart +++ b/mobile/openapi/lib/model/asset_stats_response_dto.dart @@ -52,6 +52,7 @@ class AssetStatsResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AssetStatsResponseDto? fromJson(dynamic value) { + upgradeDto(value, "AssetStatsResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/audit_deletes_response_dto.dart b/mobile/openapi/lib/model/audit_deletes_response_dto.dart index 690a52e811466..6b1df74eb4d79 100644 --- a/mobile/openapi/lib/model/audit_deletes_response_dto.dart +++ b/mobile/openapi/lib/model/audit_deletes_response_dto.dart @@ -46,6 +46,7 @@ class AuditDeletesResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AuditDeletesResponseDto? fromJson(dynamic value) { + upgradeDto(value, "AuditDeletesResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/avatar_response.dart b/mobile/openapi/lib/model/avatar_response.dart index edd242df4e3be..8ce0287565f2d 100644 --- a/mobile/openapi/lib/model/avatar_response.dart +++ b/mobile/openapi/lib/model/avatar_response.dart @@ -40,6 +40,7 @@ class AvatarResponse { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AvatarResponse? fromJson(dynamic value) { + upgradeDto(value, "AvatarResponse"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/avatar_update.dart b/mobile/openapi/lib/model/avatar_update.dart index b92eb8dcbdb2d..875eb138a8dbf 100644 --- a/mobile/openapi/lib/model/avatar_update.dart +++ b/mobile/openapi/lib/model/avatar_update.dart @@ -50,6 +50,7 @@ class AvatarUpdate { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AvatarUpdate? fromJson(dynamic value) { + upgradeDto(value, "AvatarUpdate"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/bulk_id_response_dto.dart b/mobile/openapi/lib/model/bulk_id_response_dto.dart index ef3cf2e0dbe00..67a587e8d001e 100644 --- a/mobile/openapi/lib/model/bulk_id_response_dto.dart +++ b/mobile/openapi/lib/model/bulk_id_response_dto.dart @@ -56,6 +56,7 @@ class BulkIdResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static BulkIdResponseDto? fromJson(dynamic value) { + upgradeDto(value, "BulkIdResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/bulk_ids_dto.dart b/mobile/openapi/lib/model/bulk_ids_dto.dart index 6942875f0a2e6..6a7f8ceeec6f6 100644 --- a/mobile/openapi/lib/model/bulk_ids_dto.dart +++ b/mobile/openapi/lib/model/bulk_ids_dto.dart @@ -40,6 +40,7 @@ class BulkIdsDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static BulkIdsDto? fromJson(dynamic value) { + upgradeDto(value, "BulkIdsDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/change_password_dto.dart b/mobile/openapi/lib/model/change_password_dto.dart index 1074aaf74d4f8..33b7f4a607390 100644 --- a/mobile/openapi/lib/model/change_password_dto.dart +++ b/mobile/openapi/lib/model/change_password_dto.dart @@ -46,6 +46,7 @@ class ChangePasswordDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static ChangePasswordDto? fromJson(dynamic value) { + upgradeDto(value, "ChangePasswordDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/check_existing_assets_dto.dart b/mobile/openapi/lib/model/check_existing_assets_dto.dart index 49ef36cc093e0..42ce6d5c3ea0c 100644 --- a/mobile/openapi/lib/model/check_existing_assets_dto.dart +++ b/mobile/openapi/lib/model/check_existing_assets_dto.dart @@ -46,6 +46,7 @@ class CheckExistingAssetsDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static CheckExistingAssetsDto? fromJson(dynamic value) { + upgradeDto(value, "CheckExistingAssetsDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/check_existing_assets_response_dto.dart b/mobile/openapi/lib/model/check_existing_assets_response_dto.dart index d8b0f43a6d0e1..ad93578ebc34c 100644 --- a/mobile/openapi/lib/model/check_existing_assets_response_dto.dart +++ b/mobile/openapi/lib/model/check_existing_assets_response_dto.dart @@ -40,6 +40,7 @@ class CheckExistingAssetsResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static CheckExistingAssetsResponseDto? fromJson(dynamic value) { + upgradeDto(value, "CheckExistingAssetsResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/clip_config.dart b/mobile/openapi/lib/model/clip_config.dart index 6e95c15fbfbe2..b500d20f2e6eb 100644 --- a/mobile/openapi/lib/model/clip_config.dart +++ b/mobile/openapi/lib/model/clip_config.dart @@ -46,6 +46,7 @@ class CLIPConfig { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static CLIPConfig? fromJson(dynamic value) { + upgradeDto(value, "CLIPConfig"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/create_album_dto.dart b/mobile/openapi/lib/model/create_album_dto.dart index fa28b782acee9..ff8c1df647fdc 100644 --- a/mobile/openapi/lib/model/create_album_dto.dart +++ b/mobile/openapi/lib/model/create_album_dto.dart @@ -68,6 +68,7 @@ class CreateAlbumDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static CreateAlbumDto? fromJson(dynamic value) { + upgradeDto(value, "CreateAlbumDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/create_library_dto.dart b/mobile/openapi/lib/model/create_library_dto.dart index 65ceec8e8a4f4..bffa5f427950d 100644 --- a/mobile/openapi/lib/model/create_library_dto.dart +++ b/mobile/openapi/lib/model/create_library_dto.dart @@ -68,6 +68,7 @@ class CreateLibraryDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static CreateLibraryDto? fromJson(dynamic value) { + upgradeDto(value, "CreateLibraryDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/create_profile_image_response_dto.dart b/mobile/openapi/lib/model/create_profile_image_response_dto.dart index 86624ed06bf46..ee98142e86097 100644 --- a/mobile/openapi/lib/model/create_profile_image_response_dto.dart +++ b/mobile/openapi/lib/model/create_profile_image_response_dto.dart @@ -52,6 +52,7 @@ class CreateProfileImageResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static CreateProfileImageResponseDto? fromJson(dynamic value) { + upgradeDto(value, "CreateProfileImageResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/download_archive_info.dart b/mobile/openapi/lib/model/download_archive_info.dart index e324850bdcf0b..5f3fd1a8c1f3d 100644 --- a/mobile/openapi/lib/model/download_archive_info.dart +++ b/mobile/openapi/lib/model/download_archive_info.dart @@ -46,6 +46,7 @@ class DownloadArchiveInfo { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static DownloadArchiveInfo? fromJson(dynamic value) { + upgradeDto(value, "DownloadArchiveInfo"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/download_info_dto.dart b/mobile/openapi/lib/model/download_info_dto.dart index 4c387690102a7..6f4777975c6b2 100644 --- a/mobile/openapi/lib/model/download_info_dto.dart +++ b/mobile/openapi/lib/model/download_info_dto.dart @@ -89,6 +89,7 @@ class DownloadInfoDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static DownloadInfoDto? fromJson(dynamic value) { + upgradeDto(value, "DownloadInfoDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/download_response.dart b/mobile/openapi/lib/model/download_response.dart index 25c5159a8b655..041da44b718bc 100644 --- a/mobile/openapi/lib/model/download_response.dart +++ b/mobile/openapi/lib/model/download_response.dart @@ -46,6 +46,7 @@ class DownloadResponse { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static DownloadResponse? fromJson(dynamic value) { + upgradeDto(value, "DownloadResponse"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/download_response_dto.dart b/mobile/openapi/lib/model/download_response_dto.dart index f32cba92537ac..5c6bd112661e8 100644 --- a/mobile/openapi/lib/model/download_response_dto.dart +++ b/mobile/openapi/lib/model/download_response_dto.dart @@ -46,6 +46,7 @@ class DownloadResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static DownloadResponseDto? fromJson(dynamic value) { + upgradeDto(value, "DownloadResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/download_update.dart b/mobile/openapi/lib/model/download_update.dart index 2c3839a6878dc..8df825a922315 100644 --- a/mobile/openapi/lib/model/download_update.dart +++ b/mobile/openapi/lib/model/download_update.dart @@ -67,6 +67,7 @@ class DownloadUpdate { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static DownloadUpdate? fromJson(dynamic value) { + upgradeDto(value, "DownloadUpdate"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/duplicate_detection_config.dart b/mobile/openapi/lib/model/duplicate_detection_config.dart index 0bc60917848c4..e4fc352028ec0 100644 --- a/mobile/openapi/lib/model/duplicate_detection_config.dart +++ b/mobile/openapi/lib/model/duplicate_detection_config.dart @@ -48,6 +48,7 @@ class DuplicateDetectionConfig { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static DuplicateDetectionConfig? fromJson(dynamic value) { + upgradeDto(value, "DuplicateDetectionConfig"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/duplicate_response_dto.dart b/mobile/openapi/lib/model/duplicate_response_dto.dart index b93ecfe5f5775..6ac7c468711e0 100644 --- a/mobile/openapi/lib/model/duplicate_response_dto.dart +++ b/mobile/openapi/lib/model/duplicate_response_dto.dart @@ -46,6 +46,7 @@ class DuplicateResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static DuplicateResponseDto? fromJson(dynamic value) { + upgradeDto(value, "DuplicateResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/email_notifications_response.dart b/mobile/openapi/lib/model/email_notifications_response.dart index cef92957c6d78..d6dcfb9273be6 100644 --- a/mobile/openapi/lib/model/email_notifications_response.dart +++ b/mobile/openapi/lib/model/email_notifications_response.dart @@ -52,6 +52,7 @@ class EmailNotificationsResponse { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static EmailNotificationsResponse? fromJson(dynamic value) { + upgradeDto(value, "EmailNotificationsResponse"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/email_notifications_update.dart b/mobile/openapi/lib/model/email_notifications_update.dart index dcd1ec432206d..dad0a52fdef28 100644 --- a/mobile/openapi/lib/model/email_notifications_update.dart +++ b/mobile/openapi/lib/model/email_notifications_update.dart @@ -82,6 +82,7 @@ class EmailNotificationsUpdate { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static EmailNotificationsUpdate? fromJson(dynamic value) { + upgradeDto(value, "EmailNotificationsUpdate"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/exif_response_dto.dart b/mobile/openapi/lib/model/exif_response_dto.dart index 0185f300fac5b..17397b20815e0 100644 --- a/mobile/openapi/lib/model/exif_response_dto.dart +++ b/mobile/openapi/lib/model/exif_response_dto.dart @@ -254,6 +254,7 @@ class ExifResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static ExifResponseDto? fromJson(dynamic value) { + upgradeDto(value, "ExifResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/face_dto.dart b/mobile/openapi/lib/model/face_dto.dart index 4fcc86debf22e..c84a518b8c784 100644 --- a/mobile/openapi/lib/model/face_dto.dart +++ b/mobile/openapi/lib/model/face_dto.dart @@ -40,6 +40,7 @@ class FaceDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static FaceDto? fromJson(dynamic value) { + upgradeDto(value, "FaceDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/facial_recognition_config.dart b/mobile/openapi/lib/model/facial_recognition_config.dart index 52400fd7e135d..4acfd4e20ff17 100644 --- a/mobile/openapi/lib/model/facial_recognition_config.dart +++ b/mobile/openapi/lib/model/facial_recognition_config.dart @@ -69,6 +69,7 @@ class FacialRecognitionConfig { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static FacialRecognitionConfig? fromJson(dynamic value) { + upgradeDto(value, "FacialRecognitionConfig"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/file_checksum_dto.dart b/mobile/openapi/lib/model/file_checksum_dto.dart index c7e8aa1da60b3..7dc9ccdf2f93e 100644 --- a/mobile/openapi/lib/model/file_checksum_dto.dart +++ b/mobile/openapi/lib/model/file_checksum_dto.dart @@ -40,6 +40,7 @@ class FileChecksumDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static FileChecksumDto? fromJson(dynamic value) { + upgradeDto(value, "FileChecksumDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/file_checksum_response_dto.dart b/mobile/openapi/lib/model/file_checksum_response_dto.dart index d4bae3c273ddc..7b963c8bd539e 100644 --- a/mobile/openapi/lib/model/file_checksum_response_dto.dart +++ b/mobile/openapi/lib/model/file_checksum_response_dto.dart @@ -46,6 +46,7 @@ class FileChecksumResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static FileChecksumResponseDto? fromJson(dynamic value) { + upgradeDto(value, "FileChecksumResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/file_report_dto.dart b/mobile/openapi/lib/model/file_report_dto.dart index 422215ff6c63e..3dc892e5e7f78 100644 --- a/mobile/openapi/lib/model/file_report_dto.dart +++ b/mobile/openapi/lib/model/file_report_dto.dart @@ -46,6 +46,7 @@ class FileReportDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static FileReportDto? fromJson(dynamic value) { + upgradeDto(value, "FileReportDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/file_report_fix_dto.dart b/mobile/openapi/lib/model/file_report_fix_dto.dart index cf09242b0fa15..d46cdeb4b784b 100644 --- a/mobile/openapi/lib/model/file_report_fix_dto.dart +++ b/mobile/openapi/lib/model/file_report_fix_dto.dart @@ -40,6 +40,7 @@ class FileReportFixDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static FileReportFixDto? fromJson(dynamic value) { + upgradeDto(value, "FileReportFixDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/file_report_item_dto.dart b/mobile/openapi/lib/model/file_report_item_dto.dart index 5255005daaaf1..1ef08c2b48511 100644 --- a/mobile/openapi/lib/model/file_report_item_dto.dart +++ b/mobile/openapi/lib/model/file_report_item_dto.dart @@ -74,6 +74,7 @@ class FileReportItemDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static FileReportItemDto? fromJson(dynamic value) { + upgradeDto(value, "FileReportItemDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/folders_response.dart b/mobile/openapi/lib/model/folders_response.dart index 5bfc4c793deed..248b64b054c83 100644 --- a/mobile/openapi/lib/model/folders_response.dart +++ b/mobile/openapi/lib/model/folders_response.dart @@ -46,6 +46,7 @@ class FoldersResponse { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static FoldersResponse? fromJson(dynamic value) { + upgradeDto(value, "FoldersResponse"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/folders_update.dart b/mobile/openapi/lib/model/folders_update.dart index 088c98a4d8fd2..02347177545d0 100644 --- a/mobile/openapi/lib/model/folders_update.dart +++ b/mobile/openapi/lib/model/folders_update.dart @@ -66,6 +66,7 @@ class FoldersUpdate { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static FoldersUpdate? fromJson(dynamic value) { + upgradeDto(value, "FoldersUpdate"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/job_command_dto.dart b/mobile/openapi/lib/model/job_command_dto.dart index 5c56715644f22..649e0128a7a97 100644 --- a/mobile/openapi/lib/model/job_command_dto.dart +++ b/mobile/openapi/lib/model/job_command_dto.dart @@ -46,6 +46,7 @@ class JobCommandDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static JobCommandDto? fromJson(dynamic value) { + upgradeDto(value, "JobCommandDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/job_counts_dto.dart b/mobile/openapi/lib/model/job_counts_dto.dart index cf1d0b457d821..afc90d108467d 100644 --- a/mobile/openapi/lib/model/job_counts_dto.dart +++ b/mobile/openapi/lib/model/job_counts_dto.dart @@ -70,6 +70,7 @@ class JobCountsDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static JobCountsDto? fromJson(dynamic value) { + upgradeDto(value, "JobCountsDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/job_create_dto.dart b/mobile/openapi/lib/model/job_create_dto.dart index a4734791bbced..fe6743cba09d9 100644 --- a/mobile/openapi/lib/model/job_create_dto.dart +++ b/mobile/openapi/lib/model/job_create_dto.dart @@ -40,6 +40,7 @@ class JobCreateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static JobCreateDto? fromJson(dynamic value) { + upgradeDto(value, "JobCreateDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/job_settings_dto.dart b/mobile/openapi/lib/model/job_settings_dto.dart index 9c59d503cac85..af354bef9e953 100644 --- a/mobile/openapi/lib/model/job_settings_dto.dart +++ b/mobile/openapi/lib/model/job_settings_dto.dart @@ -41,6 +41,7 @@ class JobSettingsDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static JobSettingsDto? fromJson(dynamic value) { + upgradeDto(value, "JobSettingsDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/job_status_dto.dart b/mobile/openapi/lib/model/job_status_dto.dart index fd925bd53a5fc..18fab8dfb3fe2 100644 --- a/mobile/openapi/lib/model/job_status_dto.dart +++ b/mobile/openapi/lib/model/job_status_dto.dart @@ -46,6 +46,7 @@ class JobStatusDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static JobStatusDto? fromJson(dynamic value) { + upgradeDto(value, "JobStatusDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/library_response_dto.dart b/mobile/openapi/lib/model/library_response_dto.dart index e27b48910439c..3cf12485080ac 100644 --- a/mobile/openapi/lib/model/library_response_dto.dart +++ b/mobile/openapi/lib/model/library_response_dto.dart @@ -92,6 +92,7 @@ class LibraryResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static LibraryResponseDto? fromJson(dynamic value) { + upgradeDto(value, "LibraryResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/library_stats_response_dto.dart b/mobile/openapi/lib/model/library_stats_response_dto.dart index 8cfb292855104..afe67da31a251 100644 --- a/mobile/openapi/lib/model/library_stats_response_dto.dart +++ b/mobile/openapi/lib/model/library_stats_response_dto.dart @@ -58,6 +58,7 @@ class LibraryStatsResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static LibraryStatsResponseDto? fromJson(dynamic value) { + upgradeDto(value, "LibraryStatsResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/license_key_dto.dart b/mobile/openapi/lib/model/license_key_dto.dart index aece85f81e9a0..d27d579bb4831 100644 --- a/mobile/openapi/lib/model/license_key_dto.dart +++ b/mobile/openapi/lib/model/license_key_dto.dart @@ -46,6 +46,7 @@ class LicenseKeyDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static LicenseKeyDto? fromJson(dynamic value) { + upgradeDto(value, "LicenseKeyDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/license_response_dto.dart b/mobile/openapi/lib/model/license_response_dto.dart index f83668af575c9..6d3009433fd80 100644 --- a/mobile/openapi/lib/model/license_response_dto.dart +++ b/mobile/openapi/lib/model/license_response_dto.dart @@ -52,6 +52,7 @@ class LicenseResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static LicenseResponseDto? fromJson(dynamic value) { + upgradeDto(value, "LicenseResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/login_credential_dto.dart b/mobile/openapi/lib/model/login_credential_dto.dart index ac2f5116916f2..7e892ab5fbcb6 100644 --- a/mobile/openapi/lib/model/login_credential_dto.dart +++ b/mobile/openapi/lib/model/login_credential_dto.dart @@ -46,6 +46,7 @@ class LoginCredentialDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static LoginCredentialDto? fromJson(dynamic value) { + upgradeDto(value, "LoginCredentialDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/login_response_dto.dart b/mobile/openapi/lib/model/login_response_dto.dart index 6a0eb2355ce55..dbc82d07ba1bb 100644 --- a/mobile/openapi/lib/model/login_response_dto.dart +++ b/mobile/openapi/lib/model/login_response_dto.dart @@ -76,6 +76,7 @@ class LoginResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static LoginResponseDto? fromJson(dynamic value) { + upgradeDto(value, "LoginResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/logout_response_dto.dart b/mobile/openapi/lib/model/logout_response_dto.dart index ca1e8d23bbf32..aa94904e2a7df 100644 --- a/mobile/openapi/lib/model/logout_response_dto.dart +++ b/mobile/openapi/lib/model/logout_response_dto.dart @@ -46,6 +46,7 @@ class LogoutResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static LogoutResponseDto? fromJson(dynamic value) { + upgradeDto(value, "LogoutResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/map_marker_response_dto.dart b/mobile/openapi/lib/model/map_marker_response_dto.dart index ca1ec3c8a1ced..74ac51a271478 100644 --- a/mobile/openapi/lib/model/map_marker_response_dto.dart +++ b/mobile/openapi/lib/model/map_marker_response_dto.dart @@ -82,6 +82,7 @@ class MapMarkerResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static MapMarkerResponseDto? fromJson(dynamic value) { + upgradeDto(value, "MapMarkerResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/map_reverse_geocode_response_dto.dart b/mobile/openapi/lib/model/map_reverse_geocode_response_dto.dart index ac99dd91a9915..6d8757d39ff61 100644 --- a/mobile/openapi/lib/model/map_reverse_geocode_response_dto.dart +++ b/mobile/openapi/lib/model/map_reverse_geocode_response_dto.dart @@ -64,6 +64,7 @@ class MapReverseGeocodeResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static MapReverseGeocodeResponseDto? fromJson(dynamic value) { + upgradeDto(value, "MapReverseGeocodeResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/memories_response.dart b/mobile/openapi/lib/model/memories_response.dart index e215a66a03f67..b9f8b5d8b1862 100644 --- a/mobile/openapi/lib/model/memories_response.dart +++ b/mobile/openapi/lib/model/memories_response.dart @@ -40,6 +40,7 @@ class MemoriesResponse { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static MemoriesResponse? fromJson(dynamic value) { + upgradeDto(value, "MemoriesResponse"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/memories_update.dart b/mobile/openapi/lib/model/memories_update.dart index d30949136197e..71efd71ae7866 100644 --- a/mobile/openapi/lib/model/memories_update.dart +++ b/mobile/openapi/lib/model/memories_update.dart @@ -50,6 +50,7 @@ class MemoriesUpdate { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static MemoriesUpdate? fromJson(dynamic value) { + upgradeDto(value, "MemoriesUpdate"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/memory_create_dto.dart b/mobile/openapi/lib/model/memory_create_dto.dart index 2efdf88936093..15985f2f1c175 100644 --- a/mobile/openapi/lib/model/memory_create_dto.dart +++ b/mobile/openapi/lib/model/memory_create_dto.dart @@ -90,6 +90,7 @@ class MemoryCreateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static MemoryCreateDto? fromJson(dynamic value) { + upgradeDto(value, "MemoryCreateDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/memory_lane_response_dto.dart b/mobile/openapi/lib/model/memory_lane_response_dto.dart index 4abe607381f35..27248d05c1f64 100644 --- a/mobile/openapi/lib/model/memory_lane_response_dto.dart +++ b/mobile/openapi/lib/model/memory_lane_response_dto.dart @@ -46,6 +46,7 @@ class MemoryLaneResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static MemoryLaneResponseDto? fromJson(dynamic value) { + upgradeDto(value, "MemoryLaneResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/memory_response_dto.dart b/mobile/openapi/lib/model/memory_response_dto.dart index f794be53cd5d3..652c993536aa4 100644 --- a/mobile/openapi/lib/model/memory_response_dto.dart +++ b/mobile/openapi/lib/model/memory_response_dto.dart @@ -120,6 +120,7 @@ class MemoryResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static MemoryResponseDto? fromJson(dynamic value) { + upgradeDto(value, "MemoryResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/memory_update_dto.dart b/mobile/openapi/lib/model/memory_update_dto.dart index 318f4b42add00..e750f9faad330 100644 --- a/mobile/openapi/lib/model/memory_update_dto.dart +++ b/mobile/openapi/lib/model/memory_update_dto.dart @@ -82,6 +82,7 @@ class MemoryUpdateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static MemoryUpdateDto? fromJson(dynamic value) { + upgradeDto(value, "MemoryUpdateDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/merge_person_dto.dart b/mobile/openapi/lib/model/merge_person_dto.dart index ea23042e2c5e0..fd225276b6f0a 100644 --- a/mobile/openapi/lib/model/merge_person_dto.dart +++ b/mobile/openapi/lib/model/merge_person_dto.dart @@ -40,6 +40,7 @@ class MergePersonDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static MergePersonDto? fromJson(dynamic value) { + upgradeDto(value, "MergePersonDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/metadata_search_dto.dart b/mobile/openapi/lib/model/metadata_search_dto.dart index fabf7a26107ec..0aef1f623efd0 100644 --- a/mobile/openapi/lib/model/metadata_search_dto.dart +++ b/mobile/openapi/lib/model/metadata_search_dto.dart @@ -637,6 +637,7 @@ class MetadataSearchDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static MetadataSearchDto? fromJson(dynamic value) { + upgradeDto(value, "MetadataSearchDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/o_auth_authorize_response_dto.dart b/mobile/openapi/lib/model/o_auth_authorize_response_dto.dart index ffd017f8168d6..869c3be753f70 100644 --- a/mobile/openapi/lib/model/o_auth_authorize_response_dto.dart +++ b/mobile/openapi/lib/model/o_auth_authorize_response_dto.dart @@ -40,6 +40,7 @@ class OAuthAuthorizeResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static OAuthAuthorizeResponseDto? fromJson(dynamic value) { + upgradeDto(value, "OAuthAuthorizeResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/o_auth_callback_dto.dart b/mobile/openapi/lib/model/o_auth_callback_dto.dart index 89ad0f60b0b09..d0b98d5c6f503 100644 --- a/mobile/openapi/lib/model/o_auth_callback_dto.dart +++ b/mobile/openapi/lib/model/o_auth_callback_dto.dart @@ -40,6 +40,7 @@ class OAuthCallbackDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static OAuthCallbackDto? fromJson(dynamic value) { + upgradeDto(value, "OAuthCallbackDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/o_auth_config_dto.dart b/mobile/openapi/lib/model/o_auth_config_dto.dart index 7d7675886497b..86c79b4e04ff6 100644 --- a/mobile/openapi/lib/model/o_auth_config_dto.dart +++ b/mobile/openapi/lib/model/o_auth_config_dto.dart @@ -40,6 +40,7 @@ class OAuthConfigDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static OAuthConfigDto? fromJson(dynamic value) { + upgradeDto(value, "OAuthConfigDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/on_this_day_dto.dart b/mobile/openapi/lib/model/on_this_day_dto.dart index be170caf853a9..bfcc4fd630589 100644 --- a/mobile/openapi/lib/model/on_this_day_dto.dart +++ b/mobile/openapi/lib/model/on_this_day_dto.dart @@ -41,6 +41,7 @@ class OnThisDayDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static OnThisDayDto? fromJson(dynamic value) { + upgradeDto(value, "OnThisDayDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/partner_response_dto.dart b/mobile/openapi/lib/model/partner_response_dto.dart index 375303c94a0ec..f61df86b42bf4 100644 --- a/mobile/openapi/lib/model/partner_response_dto.dart +++ b/mobile/openapi/lib/model/partner_response_dto.dart @@ -86,6 +86,7 @@ class PartnerResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static PartnerResponseDto? fromJson(dynamic value) { + upgradeDto(value, "PartnerResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/people_response.dart b/mobile/openapi/lib/model/people_response.dart index e12f86eeab5ba..1312c738744ef 100644 --- a/mobile/openapi/lib/model/people_response.dart +++ b/mobile/openapi/lib/model/people_response.dart @@ -46,6 +46,7 @@ class PeopleResponse { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static PeopleResponse? fromJson(dynamic value) { + upgradeDto(value, "PeopleResponse"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/people_response_dto.dart b/mobile/openapi/lib/model/people_response_dto.dart index 87e8c34fb0d7d..49f0e85aad421 100644 --- a/mobile/openapi/lib/model/people_response_dto.dart +++ b/mobile/openapi/lib/model/people_response_dto.dart @@ -69,6 +69,7 @@ class PeopleResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static PeopleResponseDto? fromJson(dynamic value) { + upgradeDto(value, "PeopleResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/people_update.dart b/mobile/openapi/lib/model/people_update.dart index 7803e6297036a..fb4eeeb434fda 100644 --- a/mobile/openapi/lib/model/people_update.dart +++ b/mobile/openapi/lib/model/people_update.dart @@ -66,6 +66,7 @@ class PeopleUpdate { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static PeopleUpdate? fromJson(dynamic value) { + upgradeDto(value, "PeopleUpdate"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/people_update_dto.dart b/mobile/openapi/lib/model/people_update_dto.dart index 9fcfdc8761018..f771084f75c63 100644 --- a/mobile/openapi/lib/model/people_update_dto.dart +++ b/mobile/openapi/lib/model/people_update_dto.dart @@ -40,6 +40,7 @@ class PeopleUpdateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static PeopleUpdateDto? fromJson(dynamic value) { + upgradeDto(value, "PeopleUpdateDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/people_update_item.dart b/mobile/openapi/lib/model/people_update_item.dart index 8af0a8b11ab8d..042e4fa36f969 100644 --- a/mobile/openapi/lib/model/people_update_item.dart +++ b/mobile/openapi/lib/model/people_update_item.dart @@ -103,6 +103,7 @@ class PeopleUpdateItem { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static PeopleUpdateItem? fromJson(dynamic value) { + upgradeDto(value, "PeopleUpdateItem"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/person_create_dto.dart b/mobile/openapi/lib/model/person_create_dto.dart index 9889328dee639..36bd6dfee9072 100644 --- a/mobile/openapi/lib/model/person_create_dto.dart +++ b/mobile/openapi/lib/model/person_create_dto.dart @@ -79,6 +79,7 @@ class PersonCreateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static PersonCreateDto? fromJson(dynamic value) { + upgradeDto(value, "PersonCreateDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/person_response_dto.dart b/mobile/openapi/lib/model/person_response_dto.dart index 50ee28f0af5fe..0b36fcde3b271 100644 --- a/mobile/openapi/lib/model/person_response_dto.dart +++ b/mobile/openapi/lib/model/person_response_dto.dart @@ -85,6 +85,7 @@ class PersonResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static PersonResponseDto? fromJson(dynamic value) { + upgradeDto(value, "PersonResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/person_statistics_response_dto.dart b/mobile/openapi/lib/model/person_statistics_response_dto.dart index 929fbc29d2585..d9f84e9f4c226 100644 --- a/mobile/openapi/lib/model/person_statistics_response_dto.dart +++ b/mobile/openapi/lib/model/person_statistics_response_dto.dart @@ -40,6 +40,7 @@ class PersonStatisticsResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static PersonStatisticsResponseDto? fromJson(dynamic value) { + upgradeDto(value, "PersonStatisticsResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/person_update_dto.dart b/mobile/openapi/lib/model/person_update_dto.dart index 1af03890a2aa3..51a7ea25d07b3 100644 --- a/mobile/openapi/lib/model/person_update_dto.dart +++ b/mobile/openapi/lib/model/person_update_dto.dart @@ -96,6 +96,7 @@ class PersonUpdateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static PersonUpdateDto? fromJson(dynamic value) { + upgradeDto(value, "PersonUpdateDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/person_with_faces_response_dto.dart b/mobile/openapi/lib/model/person_with_faces_response_dto.dart index af2e7101c3477..b14bad789505b 100644 --- a/mobile/openapi/lib/model/person_with_faces_response_dto.dart +++ b/mobile/openapi/lib/model/person_with_faces_response_dto.dart @@ -91,6 +91,7 @@ class PersonWithFacesResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static PersonWithFacesResponseDto? fromJson(dynamic value) { + upgradeDto(value, "PersonWithFacesResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/places_response_dto.dart b/mobile/openapi/lib/model/places_response_dto.dart index d3e1fc449bc5e..4f77788263450 100644 --- a/mobile/openapi/lib/model/places_response_dto.dart +++ b/mobile/openapi/lib/model/places_response_dto.dart @@ -84,6 +84,7 @@ class PlacesResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static PlacesResponseDto? fromJson(dynamic value) { + upgradeDto(value, "PlacesResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/purchase_response.dart b/mobile/openapi/lib/model/purchase_response.dart index 284d8995289ec..a1172069771ea 100644 --- a/mobile/openapi/lib/model/purchase_response.dart +++ b/mobile/openapi/lib/model/purchase_response.dart @@ -46,6 +46,7 @@ class PurchaseResponse { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static PurchaseResponse? fromJson(dynamic value) { + upgradeDto(value, "PurchaseResponse"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/purchase_update.dart b/mobile/openapi/lib/model/purchase_update.dart index ca0a27e3bc4ba..69057e6c55a46 100644 --- a/mobile/openapi/lib/model/purchase_update.dart +++ b/mobile/openapi/lib/model/purchase_update.dart @@ -66,6 +66,7 @@ class PurchaseUpdate { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static PurchaseUpdate? fromJson(dynamic value) { + upgradeDto(value, "PurchaseUpdate"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/queue_status_dto.dart b/mobile/openapi/lib/model/queue_status_dto.dart index 7f7d310f6ff07..77591affe2f3d 100644 --- a/mobile/openapi/lib/model/queue_status_dto.dart +++ b/mobile/openapi/lib/model/queue_status_dto.dart @@ -46,6 +46,7 @@ class QueueStatusDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static QueueStatusDto? fromJson(dynamic value) { + upgradeDto(value, "QueueStatusDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/ratings_response.dart b/mobile/openapi/lib/model/ratings_response.dart index c8791aa91a5ee..8e1951277ae80 100644 --- a/mobile/openapi/lib/model/ratings_response.dart +++ b/mobile/openapi/lib/model/ratings_response.dart @@ -40,6 +40,7 @@ class RatingsResponse { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static RatingsResponse? fromJson(dynamic value) { + upgradeDto(value, "RatingsResponse"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/ratings_update.dart b/mobile/openapi/lib/model/ratings_update.dart index bde51bad1b360..5d9f9a655f0ad 100644 --- a/mobile/openapi/lib/model/ratings_update.dart +++ b/mobile/openapi/lib/model/ratings_update.dart @@ -50,6 +50,7 @@ class RatingsUpdate { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static RatingsUpdate? fromJson(dynamic value) { + upgradeDto(value, "RatingsUpdate"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/reverse_geocoding_state_response_dto.dart b/mobile/openapi/lib/model/reverse_geocoding_state_response_dto.dart index eb414be984015..5b3648b46bb2a 100644 --- a/mobile/openapi/lib/model/reverse_geocoding_state_response_dto.dart +++ b/mobile/openapi/lib/model/reverse_geocoding_state_response_dto.dart @@ -54,6 +54,7 @@ class ReverseGeocodingStateResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static ReverseGeocodingStateResponseDto? fromJson(dynamic value) { + upgradeDto(value, "ReverseGeocodingStateResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/scan_library_dto.dart b/mobile/openapi/lib/model/scan_library_dto.dart index 1b31aaaf01702..8ff978be05321 100644 --- a/mobile/openapi/lib/model/scan_library_dto.dart +++ b/mobile/openapi/lib/model/scan_library_dto.dart @@ -66,6 +66,7 @@ class ScanLibraryDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static ScanLibraryDto? fromJson(dynamic value) { + upgradeDto(value, "ScanLibraryDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/search_album_response_dto.dart b/mobile/openapi/lib/model/search_album_response_dto.dart index 46ce5273ac947..e9b47e85ec9a3 100644 --- a/mobile/openapi/lib/model/search_album_response_dto.dart +++ b/mobile/openapi/lib/model/search_album_response_dto.dart @@ -58,6 +58,7 @@ class SearchAlbumResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SearchAlbumResponseDto? fromJson(dynamic value) { + upgradeDto(value, "SearchAlbumResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/search_asset_response_dto.dart b/mobile/openapi/lib/model/search_asset_response_dto.dart index 21ddbbb2132f2..3d214e61d9fef 100644 --- a/mobile/openapi/lib/model/search_asset_response_dto.dart +++ b/mobile/openapi/lib/model/search_asset_response_dto.dart @@ -68,6 +68,7 @@ class SearchAssetResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SearchAssetResponseDto? fromJson(dynamic value) { + upgradeDto(value, "SearchAssetResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/search_explore_item.dart b/mobile/openapi/lib/model/search_explore_item.dart index 951fdd1bc8ce2..d44b2cd704a9a 100644 --- a/mobile/openapi/lib/model/search_explore_item.dart +++ b/mobile/openapi/lib/model/search_explore_item.dart @@ -46,6 +46,7 @@ class SearchExploreItem { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SearchExploreItem? fromJson(dynamic value) { + upgradeDto(value, "SearchExploreItem"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/search_explore_response_dto.dart b/mobile/openapi/lib/model/search_explore_response_dto.dart index 5bc601de9e59b..3b5d4f984933a 100644 --- a/mobile/openapi/lib/model/search_explore_response_dto.dart +++ b/mobile/openapi/lib/model/search_explore_response_dto.dart @@ -46,6 +46,7 @@ class SearchExploreResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SearchExploreResponseDto? fromJson(dynamic value) { + upgradeDto(value, "SearchExploreResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/search_facet_count_response_dto.dart b/mobile/openapi/lib/model/search_facet_count_response_dto.dart index b40710e5251e4..f8eee844859e3 100644 --- a/mobile/openapi/lib/model/search_facet_count_response_dto.dart +++ b/mobile/openapi/lib/model/search_facet_count_response_dto.dart @@ -46,6 +46,7 @@ class SearchFacetCountResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SearchFacetCountResponseDto? fromJson(dynamic value) { + upgradeDto(value, "SearchFacetCountResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/search_facet_response_dto.dart b/mobile/openapi/lib/model/search_facet_response_dto.dart index 0784921c6b3e4..aeec873c8ddfb 100644 --- a/mobile/openapi/lib/model/search_facet_response_dto.dart +++ b/mobile/openapi/lib/model/search_facet_response_dto.dart @@ -46,6 +46,7 @@ class SearchFacetResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SearchFacetResponseDto? fromJson(dynamic value) { + upgradeDto(value, "SearchFacetResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/search_response_dto.dart b/mobile/openapi/lib/model/search_response_dto.dart index 9b2b7fd3cf75d..ca742ae35ccbc 100644 --- a/mobile/openapi/lib/model/search_response_dto.dart +++ b/mobile/openapi/lib/model/search_response_dto.dart @@ -46,6 +46,7 @@ class SearchResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SearchResponseDto? fromJson(dynamic value) { + upgradeDto(value, "SearchResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/server_about_response_dto.dart b/mobile/openapi/lib/model/server_about_response_dto.dart index 9c71d1fccdcb4..1ab51a80f1362 100644 --- a/mobile/openapi/lib/model/server_about_response_dto.dart +++ b/mobile/openapi/lib/model/server_about_response_dto.dart @@ -276,6 +276,7 @@ class ServerAboutResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static ServerAboutResponseDto? fromJson(dynamic value) { + upgradeDto(value, "ServerAboutResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/server_config_dto.dart b/mobile/openapi/lib/model/server_config_dto.dart index 47cc52fb2c3fe..c45ed32ac076b 100644 --- a/mobile/openapi/lib/model/server_config_dto.dart +++ b/mobile/openapi/lib/model/server_config_dto.dart @@ -76,6 +76,7 @@ class ServerConfigDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static ServerConfigDto? fromJson(dynamic value) { + upgradeDto(value, "ServerConfigDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/server_features_dto.dart b/mobile/openapi/lib/model/server_features_dto.dart index 0a7d8a4b4774a..5149c3796a9da 100644 --- a/mobile/openapi/lib/model/server_features_dto.dart +++ b/mobile/openapi/lib/model/server_features_dto.dart @@ -118,6 +118,7 @@ class ServerFeaturesDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static ServerFeaturesDto? fromJson(dynamic value) { + upgradeDto(value, "ServerFeaturesDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/server_media_types_response_dto.dart b/mobile/openapi/lib/model/server_media_types_response_dto.dart index 35ddef195601a..506cbb44b4d76 100644 --- a/mobile/openapi/lib/model/server_media_types_response_dto.dart +++ b/mobile/openapi/lib/model/server_media_types_response_dto.dart @@ -52,6 +52,7 @@ class ServerMediaTypesResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static ServerMediaTypesResponseDto? fromJson(dynamic value) { + upgradeDto(value, "ServerMediaTypesResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/server_ping_response.dart b/mobile/openapi/lib/model/server_ping_response.dart index e23dc15c61af1..621ebfa2945ad 100644 --- a/mobile/openapi/lib/model/server_ping_response.dart +++ b/mobile/openapi/lib/model/server_ping_response.dart @@ -40,6 +40,7 @@ class ServerPingResponse { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static ServerPingResponse? fromJson(dynamic value) { + upgradeDto(value, "ServerPingResponse"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/server_stats_response_dto.dart b/mobile/openapi/lib/model/server_stats_response_dto.dart index 6996e49aa5e24..654a34ee6b0e7 100644 --- a/mobile/openapi/lib/model/server_stats_response_dto.dart +++ b/mobile/openapi/lib/model/server_stats_response_dto.dart @@ -58,6 +58,7 @@ class ServerStatsResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static ServerStatsResponseDto? fromJson(dynamic value) { + upgradeDto(value, "ServerStatsResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/server_storage_response_dto.dart b/mobile/openapi/lib/model/server_storage_response_dto.dart index 89d97d32ead2d..8d12e77834bec 100644 --- a/mobile/openapi/lib/model/server_storage_response_dto.dart +++ b/mobile/openapi/lib/model/server_storage_response_dto.dart @@ -76,6 +76,7 @@ class ServerStorageResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static ServerStorageResponseDto? fromJson(dynamic value) { + upgradeDto(value, "ServerStorageResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/server_theme_dto.dart b/mobile/openapi/lib/model/server_theme_dto.dart index 65b9b9163e874..69e1b2d2c87a7 100644 --- a/mobile/openapi/lib/model/server_theme_dto.dart +++ b/mobile/openapi/lib/model/server_theme_dto.dart @@ -40,6 +40,7 @@ class ServerThemeDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static ServerThemeDto? fromJson(dynamic value) { + upgradeDto(value, "ServerThemeDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/server_version_response_dto.dart b/mobile/openapi/lib/model/server_version_response_dto.dart index e507f3372abfd..751347fabd2c1 100644 --- a/mobile/openapi/lib/model/server_version_response_dto.dart +++ b/mobile/openapi/lib/model/server_version_response_dto.dart @@ -52,6 +52,7 @@ class ServerVersionResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static ServerVersionResponseDto? fromJson(dynamic value) { + upgradeDto(value, "ServerVersionResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/session_response_dto.dart b/mobile/openapi/lib/model/session_response_dto.dart index 82673b3874b67..92e2dc60676af 100644 --- a/mobile/openapi/lib/model/session_response_dto.dart +++ b/mobile/openapi/lib/model/session_response_dto.dart @@ -70,6 +70,7 @@ class SessionResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SessionResponseDto? fromJson(dynamic value) { + upgradeDto(value, "SessionResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/shared_link_create_dto.dart b/mobile/openapi/lib/model/shared_link_create_dto.dart index 623bc3125fd48..bc96b31fd24f9 100644 --- a/mobile/openapi/lib/model/shared_link_create_dto.dart +++ b/mobile/openapi/lib/model/shared_link_create_dto.dart @@ -132,6 +132,7 @@ class SharedLinkCreateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SharedLinkCreateDto? fromJson(dynamic value) { + upgradeDto(value, "SharedLinkCreateDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/shared_link_edit_dto.dart b/mobile/openapi/lib/model/shared_link_edit_dto.dart index 2369c85db1a12..a394ba9b3b8fd 100644 --- a/mobile/openapi/lib/model/shared_link_edit_dto.dart +++ b/mobile/openapi/lib/model/shared_link_edit_dto.dart @@ -141,6 +141,7 @@ class SharedLinkEditDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SharedLinkEditDto? fromJson(dynamic value) { + upgradeDto(value, "SharedLinkEditDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/shared_link_response_dto.dart b/mobile/openapi/lib/model/shared_link_response_dto.dart index 018a1a51de2a4..9cc8b3ac80add 100644 --- a/mobile/openapi/lib/model/shared_link_response_dto.dart +++ b/mobile/openapi/lib/model/shared_link_response_dto.dart @@ -144,6 +144,7 @@ class SharedLinkResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SharedLinkResponseDto? fromJson(dynamic value) { + upgradeDto(value, "SharedLinkResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/sign_up_dto.dart b/mobile/openapi/lib/model/sign_up_dto.dart index 772749fdba48c..7e0ff4045c7b5 100644 --- a/mobile/openapi/lib/model/sign_up_dto.dart +++ b/mobile/openapi/lib/model/sign_up_dto.dart @@ -52,6 +52,7 @@ class SignUpDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SignUpDto? fromJson(dynamic value) { + upgradeDto(value, "SignUpDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/smart_info_response_dto.dart b/mobile/openapi/lib/model/smart_info_response_dto.dart index 52e7c108b8291..4631eccf2cb1c 100644 --- a/mobile/openapi/lib/model/smart_info_response_dto.dart +++ b/mobile/openapi/lib/model/smart_info_response_dto.dart @@ -54,6 +54,7 @@ class SmartInfoResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SmartInfoResponseDto? fromJson(dynamic value) { + upgradeDto(value, "SmartInfoResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/smart_search_dto.dart b/mobile/openapi/lib/model/smart_search_dto.dart index 2a42b75768420..4e1408cafa737 100644 --- a/mobile/openapi/lib/model/smart_search_dto.dart +++ b/mobile/openapi/lib/model/smart_search_dto.dart @@ -467,6 +467,7 @@ class SmartSearchDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SmartSearchDto? fromJson(dynamic value) { + upgradeDto(value, "SmartSearchDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/stack_create_dto.dart b/mobile/openapi/lib/model/stack_create_dto.dart index 9b37bc6e2e9aa..cb51081eb1ee4 100644 --- a/mobile/openapi/lib/model/stack_create_dto.dart +++ b/mobile/openapi/lib/model/stack_create_dto.dart @@ -41,6 +41,7 @@ class StackCreateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static StackCreateDto? fromJson(dynamic value) { + upgradeDto(value, "StackCreateDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/stack_response_dto.dart b/mobile/openapi/lib/model/stack_response_dto.dart index 3d0aaf91d17cc..b6cb747cafc5f 100644 --- a/mobile/openapi/lib/model/stack_response_dto.dart +++ b/mobile/openapi/lib/model/stack_response_dto.dart @@ -52,6 +52,7 @@ class StackResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static StackResponseDto? fromJson(dynamic value) { + upgradeDto(value, "StackResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/stack_update_dto.dart b/mobile/openapi/lib/model/stack_update_dto.dart index 0e9712721048a..0101499edfc51 100644 --- a/mobile/openapi/lib/model/stack_update_dto.dart +++ b/mobile/openapi/lib/model/stack_update_dto.dart @@ -50,6 +50,7 @@ class StackUpdateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static StackUpdateDto? fromJson(dynamic value) { + upgradeDto(value, "StackUpdateDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_dto.dart b/mobile/openapi/lib/model/system_config_dto.dart index aff8062c8a139..5306370d2d1f7 100644 --- a/mobile/openapi/lib/model/system_config_dto.dart +++ b/mobile/openapi/lib/model/system_config_dto.dart @@ -142,6 +142,7 @@ class SystemConfigDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_f_fmpeg_dto.dart b/mobile/openapi/lib/model/system_config_f_fmpeg_dto.dart index a75a77c669168..73f7d35aecc30 100644 --- a/mobile/openapi/lib/model/system_config_f_fmpeg_dto.dart +++ b/mobile/openapi/lib/model/system_config_f_fmpeg_dto.dart @@ -175,6 +175,7 @@ class SystemConfigFFmpegDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigFFmpegDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigFFmpegDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_faces_dto.dart b/mobile/openapi/lib/model/system_config_faces_dto.dart index 980e494fb70b0..4e18eb8de20e4 100644 --- a/mobile/openapi/lib/model/system_config_faces_dto.dart +++ b/mobile/openapi/lib/model/system_config_faces_dto.dart @@ -40,6 +40,7 @@ class SystemConfigFacesDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigFacesDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigFacesDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_image_dto.dart b/mobile/openapi/lib/model/system_config_image_dto.dart index 388949c759ce0..681a8c00c3bc0 100644 --- a/mobile/openapi/lib/model/system_config_image_dto.dart +++ b/mobile/openapi/lib/model/system_config_image_dto.dart @@ -80,6 +80,7 @@ class SystemConfigImageDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigImageDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigImageDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_job_dto.dart b/mobile/openapi/lib/model/system_config_job_dto.dart index 1bc0f6b29c1c0..c0fed5cccc06f 100644 --- a/mobile/openapi/lib/model/system_config_job_dto.dart +++ b/mobile/openapi/lib/model/system_config_job_dto.dart @@ -100,6 +100,7 @@ class SystemConfigJobDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigJobDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigJobDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_library_dto.dart b/mobile/openapi/lib/model/system_config_library_dto.dart index 4f55e33e8087b..e728b0bf20957 100644 --- a/mobile/openapi/lib/model/system_config_library_dto.dart +++ b/mobile/openapi/lib/model/system_config_library_dto.dart @@ -46,6 +46,7 @@ class SystemConfigLibraryDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigLibraryDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigLibraryDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_library_scan_dto.dart b/mobile/openapi/lib/model/system_config_library_scan_dto.dart index 31df272594577..6a6558b4b32ee 100644 --- a/mobile/openapi/lib/model/system_config_library_scan_dto.dart +++ b/mobile/openapi/lib/model/system_config_library_scan_dto.dart @@ -46,6 +46,7 @@ class SystemConfigLibraryScanDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigLibraryScanDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigLibraryScanDto"); if (value is Map) { final json = value.cast(); 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 9d152f366a898..1a1f5d7126b34 100644 --- a/mobile/openapi/lib/model/system_config_library_watch_dto.dart +++ b/mobile/openapi/lib/model/system_config_library_watch_dto.dart @@ -40,6 +40,7 @@ class SystemConfigLibraryWatchDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigLibraryWatchDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigLibraryWatchDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_logging_dto.dart b/mobile/openapi/lib/model/system_config_logging_dto.dart index 60c0be3d2c2ff..f025221eff996 100644 --- a/mobile/openapi/lib/model/system_config_logging_dto.dart +++ b/mobile/openapi/lib/model/system_config_logging_dto.dart @@ -46,6 +46,7 @@ class SystemConfigLoggingDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigLoggingDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigLoggingDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_machine_learning_dto.dart b/mobile/openapi/lib/model/system_config_machine_learning_dto.dart index 3923bacad4211..d665f0bfa56a7 100644 --- a/mobile/openapi/lib/model/system_config_machine_learning_dto.dart +++ b/mobile/openapi/lib/model/system_config_machine_learning_dto.dart @@ -64,6 +64,7 @@ class SystemConfigMachineLearningDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigMachineLearningDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigMachineLearningDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_map_dto.dart b/mobile/openapi/lib/model/system_config_map_dto.dart index 663188518275a..d53d5711db2af 100644 --- a/mobile/openapi/lib/model/system_config_map_dto.dart +++ b/mobile/openapi/lib/model/system_config_map_dto.dart @@ -52,6 +52,7 @@ class SystemConfigMapDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigMapDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigMapDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_metadata_dto.dart b/mobile/openapi/lib/model/system_config_metadata_dto.dart index 60ca35c835bdb..3c32fc551d4e2 100644 --- a/mobile/openapi/lib/model/system_config_metadata_dto.dart +++ b/mobile/openapi/lib/model/system_config_metadata_dto.dart @@ -40,6 +40,7 @@ class SystemConfigMetadataDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigMetadataDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigMetadataDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_new_version_check_dto.dart b/mobile/openapi/lib/model/system_config_new_version_check_dto.dart index c7b8c98695c32..c63d2abc1ba30 100644 --- a/mobile/openapi/lib/model/system_config_new_version_check_dto.dart +++ b/mobile/openapi/lib/model/system_config_new_version_check_dto.dart @@ -40,6 +40,7 @@ class SystemConfigNewVersionCheckDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigNewVersionCheckDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigNewVersionCheckDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_notifications_dto.dart b/mobile/openapi/lib/model/system_config_notifications_dto.dart index 22f08b3ab4365..35d3d318339e8 100644 --- a/mobile/openapi/lib/model/system_config_notifications_dto.dart +++ b/mobile/openapi/lib/model/system_config_notifications_dto.dart @@ -40,6 +40,7 @@ class SystemConfigNotificationsDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigNotificationsDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigNotificationsDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_o_auth_dto.dart b/mobile/openapi/lib/model/system_config_o_auth_dto.dart index 6ebbe8d25c05c..9125bb7bba65a 100644 --- a/mobile/openapi/lib/model/system_config_o_auth_dto.dart +++ b/mobile/openapi/lib/model/system_config_o_auth_dto.dart @@ -125,6 +125,7 @@ class SystemConfigOAuthDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigOAuthDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigOAuthDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_password_login_dto.dart b/mobile/openapi/lib/model/system_config_password_login_dto.dart index 61896a890c58e..69c8942bb6471 100644 --- a/mobile/openapi/lib/model/system_config_password_login_dto.dart +++ b/mobile/openapi/lib/model/system_config_password_login_dto.dart @@ -40,6 +40,7 @@ class SystemConfigPasswordLoginDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigPasswordLoginDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigPasswordLoginDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_reverse_geocoding_dto.dart b/mobile/openapi/lib/model/system_config_reverse_geocoding_dto.dart index 2eb586cac689c..6c1673d46c07d 100644 --- a/mobile/openapi/lib/model/system_config_reverse_geocoding_dto.dart +++ b/mobile/openapi/lib/model/system_config_reverse_geocoding_dto.dart @@ -40,6 +40,7 @@ class SystemConfigReverseGeocodingDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigReverseGeocodingDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigReverseGeocodingDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_server_dto.dart b/mobile/openapi/lib/model/system_config_server_dto.dart index ccb48ee61dedb..b1b92c9515d5b 100644 --- a/mobile/openapi/lib/model/system_config_server_dto.dart +++ b/mobile/openapi/lib/model/system_config_server_dto.dart @@ -46,6 +46,7 @@ class SystemConfigServerDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigServerDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigServerDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_smtp_dto.dart b/mobile/openapi/lib/model/system_config_smtp_dto.dart index 6588d244ee5d1..fcde49cf3564e 100644 --- a/mobile/openapi/lib/model/system_config_smtp_dto.dart +++ b/mobile/openapi/lib/model/system_config_smtp_dto.dart @@ -58,6 +58,7 @@ class SystemConfigSmtpDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigSmtpDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigSmtpDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_smtp_transport_dto.dart b/mobile/openapi/lib/model/system_config_smtp_transport_dto.dart index 63dfdca4cf07e..bdaaa426c5220 100644 --- a/mobile/openapi/lib/model/system_config_smtp_transport_dto.dart +++ b/mobile/openapi/lib/model/system_config_smtp_transport_dto.dart @@ -66,6 +66,7 @@ class SystemConfigSmtpTransportDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigSmtpTransportDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigSmtpTransportDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_storage_template_dto.dart b/mobile/openapi/lib/model/system_config_storage_template_dto.dart index 13323aebdaba3..596aafc1950a1 100644 --- a/mobile/openapi/lib/model/system_config_storage_template_dto.dart +++ b/mobile/openapi/lib/model/system_config_storage_template_dto.dart @@ -52,6 +52,7 @@ class SystemConfigStorageTemplateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigStorageTemplateDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigStorageTemplateDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_template_storage_option_dto.dart b/mobile/openapi/lib/model/system_config_template_storage_option_dto.dart index 82e0a6f74769b..f8586d344c5aa 100644 --- a/mobile/openapi/lib/model/system_config_template_storage_option_dto.dart +++ b/mobile/openapi/lib/model/system_config_template_storage_option_dto.dart @@ -82,6 +82,7 @@ class SystemConfigTemplateStorageOptionDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigTemplateStorageOptionDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigTemplateStorageOptionDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_theme_dto.dart b/mobile/openapi/lib/model/system_config_theme_dto.dart index 2f7f4d2f3b916..a97c2cf84c1f3 100644 --- a/mobile/openapi/lib/model/system_config_theme_dto.dart +++ b/mobile/openapi/lib/model/system_config_theme_dto.dart @@ -40,6 +40,7 @@ class SystemConfigThemeDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigThemeDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigThemeDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_trash_dto.dart b/mobile/openapi/lib/model/system_config_trash_dto.dart index 336019fde4203..51b39e9a55c1f 100644 --- a/mobile/openapi/lib/model/system_config_trash_dto.dart +++ b/mobile/openapi/lib/model/system_config_trash_dto.dart @@ -47,6 +47,7 @@ class SystemConfigTrashDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigTrashDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigTrashDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_user_dto.dart b/mobile/openapi/lib/model/system_config_user_dto.dart index c46637446098f..8e6bd3c9c306e 100644 --- a/mobile/openapi/lib/model/system_config_user_dto.dart +++ b/mobile/openapi/lib/model/system_config_user_dto.dart @@ -41,6 +41,7 @@ class SystemConfigUserDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigUserDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigUserDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/tag_bulk_assets_dto.dart b/mobile/openapi/lib/model/tag_bulk_assets_dto.dart index c11cb66ce081f..26a575e193dc6 100644 --- a/mobile/openapi/lib/model/tag_bulk_assets_dto.dart +++ b/mobile/openapi/lib/model/tag_bulk_assets_dto.dart @@ -46,6 +46,7 @@ class TagBulkAssetsDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static TagBulkAssetsDto? fromJson(dynamic value) { + upgradeDto(value, "TagBulkAssetsDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/tag_bulk_assets_response_dto.dart b/mobile/openapi/lib/model/tag_bulk_assets_response_dto.dart index d4dcb91d8c45d..009f26bfe4f49 100644 --- a/mobile/openapi/lib/model/tag_bulk_assets_response_dto.dart +++ b/mobile/openapi/lib/model/tag_bulk_assets_response_dto.dart @@ -40,6 +40,7 @@ class TagBulkAssetsResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static TagBulkAssetsResponseDto? fromJson(dynamic value) { + upgradeDto(value, "TagBulkAssetsResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/tag_create_dto.dart b/mobile/openapi/lib/model/tag_create_dto.dart index dd7e537a0a021..9a5171074d622 100644 --- a/mobile/openapi/lib/model/tag_create_dto.dart +++ b/mobile/openapi/lib/model/tag_create_dto.dart @@ -66,6 +66,7 @@ class TagCreateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static TagCreateDto? fromJson(dynamic value) { + upgradeDto(value, "TagCreateDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/tag_response_dto.dart b/mobile/openapi/lib/model/tag_response_dto.dart index 1d1a88c3cff29..cd684b163a27d 100644 --- a/mobile/openapi/lib/model/tag_response_dto.dart +++ b/mobile/openapi/lib/model/tag_response_dto.dart @@ -96,6 +96,7 @@ class TagResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static TagResponseDto? fromJson(dynamic value) { + upgradeDto(value, "TagResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/tag_update_dto.dart b/mobile/openapi/lib/model/tag_update_dto.dart index 661f65896e56f..ab1adb127bacb 100644 --- a/mobile/openapi/lib/model/tag_update_dto.dart +++ b/mobile/openapi/lib/model/tag_update_dto.dart @@ -44,6 +44,7 @@ class TagUpdateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static TagUpdateDto? fromJson(dynamic value) { + upgradeDto(value, "TagUpdateDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/tag_upsert_dto.dart b/mobile/openapi/lib/model/tag_upsert_dto.dart index 941d25b6aee6c..d60a00f466e1f 100644 --- a/mobile/openapi/lib/model/tag_upsert_dto.dart +++ b/mobile/openapi/lib/model/tag_upsert_dto.dart @@ -40,6 +40,7 @@ class TagUpsertDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static TagUpsertDto? fromJson(dynamic value) { + upgradeDto(value, "TagUpsertDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/tags_response.dart b/mobile/openapi/lib/model/tags_response.dart index 3a5ea3b20b3ec..2470edf979239 100644 --- a/mobile/openapi/lib/model/tags_response.dart +++ b/mobile/openapi/lib/model/tags_response.dart @@ -46,6 +46,7 @@ class TagsResponse { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static TagsResponse? fromJson(dynamic value) { + upgradeDto(value, "TagsResponse"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/tags_update.dart b/mobile/openapi/lib/model/tags_update.dart index 8355b00a00d49..d99236914055c 100644 --- a/mobile/openapi/lib/model/tags_update.dart +++ b/mobile/openapi/lib/model/tags_update.dart @@ -66,6 +66,7 @@ class TagsUpdate { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static TagsUpdate? fromJson(dynamic value) { + upgradeDto(value, "TagsUpdate"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/time_bucket_response_dto.dart b/mobile/openapi/lib/model/time_bucket_response_dto.dart index 2c86a56b3c9ad..56044b27a8a81 100644 --- a/mobile/openapi/lib/model/time_bucket_response_dto.dart +++ b/mobile/openapi/lib/model/time_bucket_response_dto.dart @@ -46,6 +46,7 @@ class TimeBucketResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static TimeBucketResponseDto? fromJson(dynamic value) { + upgradeDto(value, "TimeBucketResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/trash_response_dto.dart b/mobile/openapi/lib/model/trash_response_dto.dart index 52a05ff6d4db3..2df154d06c1a0 100644 --- a/mobile/openapi/lib/model/trash_response_dto.dart +++ b/mobile/openapi/lib/model/trash_response_dto.dart @@ -40,6 +40,7 @@ class TrashResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static TrashResponseDto? fromJson(dynamic value) { + upgradeDto(value, "TrashResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/update_album_dto.dart b/mobile/openapi/lib/model/update_album_dto.dart index f9c9762887265..8353dba14e627 100644 --- a/mobile/openapi/lib/model/update_album_dto.dart +++ b/mobile/openapi/lib/model/update_album_dto.dart @@ -114,6 +114,7 @@ class UpdateAlbumDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static UpdateAlbumDto? fromJson(dynamic value) { + upgradeDto(value, "UpdateAlbumDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/update_album_user_dto.dart b/mobile/openapi/lib/model/update_album_user_dto.dart index f77223acf5855..43218cae6e140 100644 --- a/mobile/openapi/lib/model/update_album_user_dto.dart +++ b/mobile/openapi/lib/model/update_album_user_dto.dart @@ -40,6 +40,7 @@ class UpdateAlbumUserDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static UpdateAlbumUserDto? fromJson(dynamic value) { + upgradeDto(value, "UpdateAlbumUserDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/update_asset_dto.dart b/mobile/openapi/lib/model/update_asset_dto.dart index 9aa413d24221e..9ebce5fd9232b 100644 --- a/mobile/openapi/lib/model/update_asset_dto.dart +++ b/mobile/openapi/lib/model/update_asset_dto.dart @@ -158,6 +158,7 @@ class UpdateAssetDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static UpdateAssetDto? fromJson(dynamic value) { + upgradeDto(value, "UpdateAssetDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/update_library_dto.dart b/mobile/openapi/lib/model/update_library_dto.dart index 85847c0ddfb6f..b85df40172e69 100644 --- a/mobile/openapi/lib/model/update_library_dto.dart +++ b/mobile/openapi/lib/model/update_library_dto.dart @@ -62,6 +62,7 @@ class UpdateLibraryDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static UpdateLibraryDto? fromJson(dynamic value) { + upgradeDto(value, "UpdateLibraryDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/update_partner_dto.dart b/mobile/openapi/lib/model/update_partner_dto.dart index f695f99535a38..3af3c83ad10bd 100644 --- a/mobile/openapi/lib/model/update_partner_dto.dart +++ b/mobile/openapi/lib/model/update_partner_dto.dart @@ -40,6 +40,7 @@ class UpdatePartnerDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static UpdatePartnerDto? fromJson(dynamic value) { + upgradeDto(value, "UpdatePartnerDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/usage_by_user_dto.dart b/mobile/openapi/lib/model/usage_by_user_dto.dart index 0bbbba00bbaab..e6f9216d74572 100644 --- a/mobile/openapi/lib/model/usage_by_user_dto.dart +++ b/mobile/openapi/lib/model/usage_by_user_dto.dart @@ -74,6 +74,7 @@ class UsageByUserDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static UsageByUserDto? fromJson(dynamic value) { + upgradeDto(value, "UsageByUserDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/user_admin_create_dto.dart b/mobile/openapi/lib/model/user_admin_create_dto.dart index db514a1d571b6..f2709be57b640 100644 --- a/mobile/openapi/lib/model/user_admin_create_dto.dart +++ b/mobile/openapi/lib/model/user_admin_create_dto.dart @@ -105,6 +105,7 @@ class UserAdminCreateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static UserAdminCreateDto? fromJson(dynamic value) { + upgradeDto(value, "UserAdminCreateDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/user_admin_delete_dto.dart b/mobile/openapi/lib/model/user_admin_delete_dto.dart index 7778b15775d0f..2cf68ad7b25ee 100644 --- a/mobile/openapi/lib/model/user_admin_delete_dto.dart +++ b/mobile/openapi/lib/model/user_admin_delete_dto.dart @@ -50,6 +50,7 @@ class UserAdminDeleteDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static UserAdminDeleteDto? fromJson(dynamic value) { + upgradeDto(value, "UserAdminDeleteDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/user_admin_response_dto.dart b/mobile/openapi/lib/model/user_admin_response_dto.dart index 461596b7bf026..e5ae8e1d4ef27 100644 --- a/mobile/openapi/lib/model/user_admin_response_dto.dart +++ b/mobile/openapi/lib/model/user_admin_response_dto.dart @@ -156,6 +156,7 @@ class UserAdminResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static UserAdminResponseDto? fromJson(dynamic value) { + upgradeDto(value, "UserAdminResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/user_admin_update_dto.dart b/mobile/openapi/lib/model/user_admin_update_dto.dart index dd0db767fe6b0..6c6f73ae8e9e1 100644 --- a/mobile/openapi/lib/model/user_admin_update_dto.dart +++ b/mobile/openapi/lib/model/user_admin_update_dto.dart @@ -119,6 +119,7 @@ class UserAdminUpdateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static UserAdminUpdateDto? fromJson(dynamic value) { + upgradeDto(value, "UserAdminUpdateDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/user_license.dart b/mobile/openapi/lib/model/user_license.dart index c7abb085f29c7..9bed8d5c43633 100644 --- a/mobile/openapi/lib/model/user_license.dart +++ b/mobile/openapi/lib/model/user_license.dart @@ -52,6 +52,7 @@ class UserLicense { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static UserLicense? fromJson(dynamic value) { + upgradeDto(value, "UserLicense"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/user_preferences_response_dto.dart b/mobile/openapi/lib/model/user_preferences_response_dto.dart index d3927df8d7ee3..23d9ea84ecd82 100644 --- a/mobile/openapi/lib/model/user_preferences_response_dto.dart +++ b/mobile/openapi/lib/model/user_preferences_response_dto.dart @@ -88,6 +88,7 @@ class UserPreferencesResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static UserPreferencesResponseDto? fromJson(dynamic value) { + upgradeDto(value, "UserPreferencesResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/user_preferences_update_dto.dart b/mobile/openapi/lib/model/user_preferences_update_dto.dart index 2841c2f572c11..208dbf686078a 100644 --- a/mobile/openapi/lib/model/user_preferences_update_dto.dart +++ b/mobile/openapi/lib/model/user_preferences_update_dto.dart @@ -178,6 +178,7 @@ class UserPreferencesUpdateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static UserPreferencesUpdateDto? fromJson(dynamic value) { + upgradeDto(value, "UserPreferencesUpdateDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/user_response_dto.dart b/mobile/openapi/lib/model/user_response_dto.dart index 282a5a40dce8b..a02da299481b8 100644 --- a/mobile/openapi/lib/model/user_response_dto.dart +++ b/mobile/openapi/lib/model/user_response_dto.dart @@ -70,6 +70,7 @@ class UserResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static UserResponseDto? fromJson(dynamic value) { + upgradeDto(value, "UserResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/user_update_me_dto.dart b/mobile/openapi/lib/model/user_update_me_dto.dart index 2d665fc7847b8..8f3f4df37ad81 100644 --- a/mobile/openapi/lib/model/user_update_me_dto.dart +++ b/mobile/openapi/lib/model/user_update_me_dto.dart @@ -82,6 +82,7 @@ class UserUpdateMeDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static UserUpdateMeDto? fromJson(dynamic value) { + upgradeDto(value, "UserUpdateMeDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/validate_access_token_response_dto.dart b/mobile/openapi/lib/model/validate_access_token_response_dto.dart index e970f7e840a80..5e36efcfedf5b 100644 --- a/mobile/openapi/lib/model/validate_access_token_response_dto.dart +++ b/mobile/openapi/lib/model/validate_access_token_response_dto.dart @@ -40,6 +40,7 @@ class ValidateAccessTokenResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static ValidateAccessTokenResponseDto? fromJson(dynamic value) { + upgradeDto(value, "ValidateAccessTokenResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/validate_library_dto.dart b/mobile/openapi/lib/model/validate_library_dto.dart index 05e122b1a1170..08199e3aa66c8 100644 --- a/mobile/openapi/lib/model/validate_library_dto.dart +++ b/mobile/openapi/lib/model/validate_library_dto.dart @@ -46,6 +46,7 @@ class ValidateLibraryDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static ValidateLibraryDto? fromJson(dynamic value) { + upgradeDto(value, "ValidateLibraryDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/validate_library_import_path_response_dto.dart b/mobile/openapi/lib/model/validate_library_import_path_response_dto.dart index 23aac0b74255a..11fbbd74c2aaa 100644 --- a/mobile/openapi/lib/model/validate_library_import_path_response_dto.dart +++ b/mobile/openapi/lib/model/validate_library_import_path_response_dto.dart @@ -62,6 +62,7 @@ class ValidateLibraryImportPathResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static ValidateLibraryImportPathResponseDto? fromJson(dynamic value) { + upgradeDto(value, "ValidateLibraryImportPathResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/validate_library_response_dto.dart b/mobile/openapi/lib/model/validate_library_response_dto.dart index b213f9ba98943..e0dc2a2d14233 100644 --- a/mobile/openapi/lib/model/validate_library_response_dto.dart +++ b/mobile/openapi/lib/model/validate_library_response_dto.dart @@ -40,6 +40,7 @@ class ValidateLibraryResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static ValidateLibraryResponseDto? fromJson(dynamic value) { + upgradeDto(value, "ValidateLibraryResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/open-api/bin/generate-open-api.sh b/open-api/bin/generate-open-api.sh index 2ca04630468f9..bf8b24b55703b 100755 --- a/open-api/bin/generate-open-api.sh +++ b/open-api/bin/generate-open-api.sh @@ -9,11 +9,7 @@ function dart { wget -O native_class.mustache https://raw.githubusercontent.com/OpenAPITools/openapi-generator/$OPENAPI_GENERATOR_VERSION/modules/openapi-generator/src/main/resources/dart2/serialization/native/native_class.mustache patch --no-backup-if-mismatch -u native_class.mustache header}} -{{>part_of}} -class ApiClient { - ApiClient({this.basePath = '{{{basePath}}}', this.authentication,}); - - final String basePath; - final Authentication? authentication; - - var _client = Client(); - final _defaultHeaderMap = {}; - - /// Returns the current HTTP [Client] instance to use in this class. - /// - /// The return value is guaranteed to never be null. - Client get client => _client; - - /// Requests to use a new HTTP [Client] in this class. - set client(Client newClient) { - _client = newClient; - } - - Map get defaultHeaderMap => _defaultHeaderMap; - - void addDefaultHeader(String key, String value) { - _defaultHeaderMap[key] = value; - } - - // We don't use a Map for queryParams. - // If collectionFormat is 'multi', a key might appear multiple times. - Future invokeAPI( - String path, - String method, - List queryParams, - Object? body, - Map headerParams, - Map formParams, - String? contentType, - ) async { - await authentication?.applyToParams(queryParams, headerParams); - - headerParams.addAll(_defaultHeaderMap); - if (contentType != null) { - headerParams['Content-Type'] = contentType; - } - - final urlEncodedQueryParams = queryParams.map((param) => '$param'); - final queryString = urlEncodedQueryParams.isNotEmpty ? '?${urlEncodedQueryParams.join('&')}' : ''; - final uri = Uri.parse('$basePath$path$queryString'); - - try { - // Special case for uploading a single file which isn't a 'multipart/form-data'. - if ( - body is MultipartFile && (contentType == null || - !contentType.toLowerCase().startsWith('multipart/form-data')) - ) { - final request = StreamedRequest(method, uri); - request.headers.addAll(headerParams); - request.contentLength = body.length; - body.finalize().listen( - request.sink.add, - onDone: request.sink.close, - // ignore: avoid_types_on_closure_parameters - onError: (Object error, StackTrace trace) => request.sink.close(), - cancelOnError: true, - ); - final response = await _client.send(request); - return Response.fromStream(response); - } - - if (body is MultipartRequest) { - final request = MultipartRequest(method, uri); - request.fields.addAll(body.fields); - request.files.addAll(body.files); - request.headers.addAll(body.headers); - request.headers.addAll(headerParams); - final response = await _client.send(request); - return Response.fromStream(response); - } - - final msgBody = contentType == 'application/x-www-form-urlencoded' - ? formParams - : await serializeAsync(body); - final nullableHeaderParams = headerParams.isEmpty ? null : headerParams; - - switch(method) { - case 'POST': return await _client.post(uri, headers: nullableHeaderParams, body: msgBody,); - case 'PUT': return await _client.put(uri, headers: nullableHeaderParams, body: msgBody,); - case 'DELETE': return await _client.delete(uri, headers: nullableHeaderParams, body: msgBody,); - case 'PATCH': return await _client.patch(uri, headers: nullableHeaderParams, body: msgBody,); - case 'HEAD': return await _client.head(uri, headers: nullableHeaderParams,); - case 'GET': return await _client.get(uri, headers: nullableHeaderParams,); - } - } on SocketException catch (error, trace) { - throw ApiException.withInner( - HttpStatus.badRequest, - 'Socket operation failed: $method $path', - error, - trace, - ); - } on TlsException catch (error, trace) { - throw ApiException.withInner( - HttpStatus.badRequest, - 'TLS/SSL communication failed: $method $path', - error, - trace, - ); - } on IOException catch (error, trace) { - throw ApiException.withInner( - HttpStatus.badRequest, - 'I/O operation failed: $method $path', - error, - trace, - ); - } on ClientException catch (error, trace) { - throw ApiException.withInner( - HttpStatus.badRequest, - 'HTTP connection failed: $method $path', - error, - trace, - ); - } on Exception catch (error, trace) { - throw ApiException.withInner( - HttpStatus.badRequest, - 'Exception occurred: $method $path', - error, - trace, - ); - } - - throw ApiException( - HttpStatus.badRequest, - 'Invalid HTTP operation: $method $path', - ); - } -{{#native_serialization}} - - Future deserializeAsync(String value, String targetType, {bool growable = false,}) async => - // ignore: deprecated_member_use_from_same_package - deserialize(value, targetType, growable: growable); - - @Deprecated('Scheduled for removal in OpenAPI Generator 6.x. Use deserializeAsync() instead.') - dynamic deserialize(String value, String targetType, {bool growable = false,}) { - // Remove all spaces. Necessary for regular expressions as well. - targetType = targetType.replaceAll(' ', ''); // ignore: parameter_assignments - - // If the expected target type is String, nothing to do... - return targetType == 'String' - ? value - : fromJson(json.decode(value), targetType, growable: growable); - } -{{/native_serialization}} - - // ignore: deprecated_member_use_from_same_package - Future serializeAsync(Object? value) async => serialize(value); - - @Deprecated('Scheduled for removal in OpenAPI Generator 6.x. Use serializeAsync() instead.') - String serialize(Object? value) => value == null ? '' : json.encode(value); - -{{#native_serialization}} - /// Returns a native instance of an OpenAPI class matching the [specified type][targetType]. - static dynamic fromJson(dynamic value, String targetType, {bool growable = false,}) { - upgradeDto(value, targetType); - try { - switch (targetType) { - case 'String': - return value is String ? value : value.toString(); - case 'int': - return value is int ? value : int.parse('$value'); - case 'double': - return value is double ? value : double.parse('$value'); - case 'bool': - if (value is bool) { - return value; - } - final valueString = '$value'.toLowerCase(); - return valueString == 'true' || valueString == '1'; - case 'DateTime': - return value is DateTime ? value : DateTime.tryParse(value); - {{#models}} - {{#model}} - case '{{{classname}}}': - {{#isEnum}} - {{#native_serialization}}return {{{classname}}}TypeTransformer().decode(value);{{/native_serialization}} - {{/isEnum}} - {{^isEnum}} - return {{{classname}}}.fromJson(value); - {{/isEnum}} - {{/model}} - {{/models}} - default: - dynamic match; - if (value is List && (match = _regList.firstMatch(targetType)?.group(1)) != null) { - return value - .map((dynamic v) => fromJson(v, match, growable: growable,)) - .toList(growable: growable); - } - if (value is Set && (match = _regSet.firstMatch(targetType)?.group(1)) != null) { - return value - .map((dynamic v) => fromJson(v, match, growable: growable,)) - .toSet(); - } - if (value is Map && (match = _regMap.firstMatch(targetType)?.group(1)) != null) { - return Map.fromIterables( - value.keys.cast(), - value.values.map((dynamic v) => fromJson(v, match, growable: growable,)), - ); - } - } - } on Exception catch (error, trace) { - throw ApiException.withInner(HttpStatus.internalServerError, 'Exception during deserialization.', error, trace,); - } - throw ApiException(HttpStatus.internalServerError, 'Could not find a suitable class for deserialization',); - } -{{/native_serialization}} -} -{{#native_serialization}} - -/// Primarily intended for use in an isolate. -class DeserializationMessage { - const DeserializationMessage({ - required this.json, - required this.targetType, - this.growable = false, - }); - - /// The JSON value to deserialize. - final String json; - - /// Target type to deserialize to. - final String targetType; - - /// Whether to make deserialized lists or maps growable. - final bool growable; -} - -/// Primarily intended for use in an isolate. -Future decodeAsync(DeserializationMessage message) async { - // Remove all spaces. Necessary for regular expressions as well. - final targetType = message.targetType.replaceAll(' ', ''); - - // If the expected target type is String, nothing to do... - return targetType == 'String' - ? message.json - : json.decode(message.json); -} - -/// Primarily intended for use in an isolate. -Future deserializeAsync(DeserializationMessage message) async { - // Remove all spaces. Necessary for regular expressions as well. - final targetType = message.targetType.replaceAll(' ', ''); - - // If the expected target type is String, nothing to do... - return targetType == 'String' - ? message.json - : ApiClient.fromJson( - json.decode(message.json), - targetType, - growable: message.growable, - ); -} -{{/native_serialization}} - -/// Primarily intended for use in an isolate. -Future serializeAsync(Object? value) async => value == null ? '' : json.encode(value); diff --git a/open-api/templates/mobile/api_client.mustache.patch b/open-api/templates/mobile/api_client.mustache.patch deleted file mode 100644 index 3805cd8f7934a..0000000000000 --- a/open-api/templates/mobile/api_client.mustache.patch +++ /dev/null @@ -1,10 +0,0 @@ ---- api_client.mustache 2024-08-13 14:29:04.056364916 -0500 -+++ api_client_new.mustache 2024-08-13 14:29:36.224410735 -0500 -@@ -159,6 +159,7 @@ - {{#native_serialization}} - /// Returns a native instance of an OpenAPI class matching the [specified type][targetType]. - static dynamic fromJson(dynamic value, String targetType, {bool growable = false,}) { -+ upgradeDto(value, targetType); - try { - switch (targetType) { - case 'String': diff --git a/open-api/templates/mobile/serialization/native/native_class.mustache b/open-api/templates/mobile/serialization/native/native_class.mustache index 254843e00eefa..9a7b1439b1fdf 100644 --- a/open-api/templates/mobile/serialization/native/native_class.mustache +++ b/open-api/templates/mobile/serialization/native/native_class.mustache @@ -111,6 +111,7 @@ class {{{classname}}} { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static {{{classname}}}? fromJson(dynamic value) { + upgradeDto(value, "{{{classname}}}"); if (value is Map) { final json = value.cast(); diff --git a/open-api/templates/mobile/serialization/native/native_class.mustache.patch b/open-api/templates/mobile/serialization/native/native_class.mustache.patch index 02e07f933a30d..4ba65949665f8 100644 --- a/open-api/templates/mobile/serialization/native/native_class.mustache.patch +++ b/open-api/templates/mobile/serialization/native/native_class.mustache.patch @@ -1,5 +1,5 @@ ---- native_class.mustache 2023-08-31 23:09:59.584269162 +0200 -+++ native_class1.mustache 2023-08-31 22:59:53.633083270 +0200 +--- native_class.mustache 2024-09-19 11:41:07.855683995 -0400 ++++ native_class_temp.mustache 2024-09-19 11:41:57.113249395 -0400 @@ -91,14 +91,14 @@ {{/isDateTime}} {{#isNullable}} @@ -17,10 +17,14 @@ } {{/defaultValue}} {{/required}} -@@ -114,17 +114,6 @@ +@@ -111,20 +111,10 @@ + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static {{{classname}}}? fromJson(dynamic value) { ++ upgradeDto(value, "{{{classname}}}"); if (value is Map) { final json = value.cast(); - + - // Ensure that the map contains the required keys. - // Note 1: the values aren't checked for validity beyond being non-null. - // Note 2: this code is stripped in release mode! @@ -35,9 +39,9 @@ return {{{classname}}}( {{#vars}} {{#isDateTime}} -@@ -215,6 +204,10 @@ +@@ -215,6 +205,10 @@ ? {{#defaultValue}}{{{.}}}{{/defaultValue}}{{^defaultValue}}null{{/defaultValue}} - : {{{datatypeWithEnum}}}.parse(json[r'{{{baseName}}}'].toString()), + : {{/isNullable}}{{{datatypeWithEnum}}}.parse('${json[r'{{{baseName}}}']}'), {{/isNumber}} + {{#isDouble}} + {{{name}}}: (mapValueOfType(json, r'{{{baseName}}}'){{#required}}{{^isNullable}}!{{/isNullable}}{{/required}}{{^required}}{{#defaultValue}} ?? {{{.}}}{{/defaultValue}}{{/required}}).toDouble(), @@ -46,7 +50,7 @@ {{^isNumber}} {{^isEnum}} {{{name}}}: mapValueOfType<{{{datatypeWithEnum}}}>(json, r'{{{baseName}}}'){{#required}}{{^isNullable}}!{{/isNullable}}{{/required}}{{^required}}{{#defaultValue}} ?? {{{.}}}{{/defaultValue}}{{/required}}, -@@ -223,6 +216,7 @@ +@@ -223,6 +217,7 @@ {{{name}}}: {{{enumName}}}.fromJson(json[r'{{{baseName}}}']){{#required}}{{^isNullable}}!{{/isNullable}}{{/required}}{{^required}}{{#defaultValue}} ?? {{{.}}}{{/defaultValue}}{{/required}}, {{/isEnum}} {{/isNumber}} From e41785b1a1e6591c7b385f97d45d8417f8c451ef Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Mon, 23 Sep 2024 22:08:01 +0200 Subject: [PATCH 076/123] fix: open api (#12878) --- mobile/openapi/lib/model/random_search_dto.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/mobile/openapi/lib/model/random_search_dto.dart b/mobile/openapi/lib/model/random_search_dto.dart index 8dbbeb538714d..419cb451e2a02 100644 --- a/mobile/openapi/lib/model/random_search_dto.dart +++ b/mobile/openapi/lib/model/random_search_dto.dart @@ -493,6 +493,7 @@ class RandomSearchDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static RandomSearchDto? fromJson(dynamic value) { + upgradeDto(value, "RandomSearchDto"); if (value is Map) { final json = value.cast(); From bcd416477b0d9dd76f3a2f11547f220354c0f9a0 Mon Sep 17 00:00:00 2001 From: Zack Pollard Date: Mon, 23 Sep 2024 21:30:23 +0100 Subject: [PATCH 077/123] feat: serve map tile styles from tiles.immich.cloud (#12858) Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- e2e/package-lock.json | 6 +- e2e/src/api/specs/map.e2e-spec.ts | 65 +- e2e/src/api/specs/server-info.e2e-spec.ts | 2 + e2e/src/api/specs/server.e2e-spec.ts | 2 + mobile/.vscode/settings.json | 2 +- .../server_info/server_config.model.dart | 10 +- mobile/lib/pages/search/map/map.page.dart | 15 +- .../lib/providers/map/map_state.provider.dart | 75 +- .../lib/providers/server_info.provider.dart | 3 + mobile/lib/utils/openapi_patching.dart | 13 + mobile/openapi/README.md | 2 - mobile/openapi/lib/api.dart | 1 - mobile/openapi/lib/api/map_api.dart | 56 - mobile/openapi/lib/api_client.dart | 2 - mobile/openapi/lib/api_helper.dart | 3 - mobile/openapi/lib/model/map_theme.dart | 85 -- .../openapi/lib/model/server_config_dto.dart | 18 +- open-api/immich-openapi-specs.json | 66 +- open-api/typescript-sdk/src/fetch-client.ts | 20 +- server/package-lock.json | 1122 +++++++++-------- server/src/config.ts | 4 +- server/src/controllers/map.controller.ts | 7 - server/src/dtos/server.dto.ts | 2 + server/src/dtos/system-config.dto.ts | 6 +- server/src/services/map.service.ts | 11 - server/src/services/server.service.spec.ts | 2 + server/src/services/server.service.ts | 2 + .../services/system-config.service.spec.ts | 4 +- .../shared-components/map/map.svelte | 16 +- web/src/lib/stores/server-config.store.ts | 2 + 30 files changed, 676 insertions(+), 948 deletions(-) delete mode 100644 mobile/openapi/lib/model/map_theme.dart diff --git a/e2e/package-lock.json b/e2e/package-lock.json index 865f154d6b065..ab4fd53fbf2ef 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -5016,9 +5016,9 @@ } }, "node_modules/path-to-regexp": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.2.2.tgz", - "integrity": "sha512-GQX3SSMokngb36+whdpRXE+3f9V8UzyAorlYvOGx87ufGHehNTn5lCxrKtLyZ4Yl/wEKnNnr98ZzOwwDZV5ogw==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", "dev": true }, "node_modules/pathe": { diff --git a/e2e/src/api/specs/map.e2e-spec.ts b/e2e/src/api/specs/map.e2e-spec.ts index 343a7c91d03e6..da5f779cffaad 100644 --- a/e2e/src/api/specs/map.e2e-spec.ts +++ b/e2e/src/api/specs/map.e2e-spec.ts @@ -1,8 +1,7 @@ -import { AssetMediaResponseDto, LoginResponseDto, SharedLinkType } from '@immich/sdk'; +import { LoginResponseDto } from '@immich/sdk'; import { readFile } from 'node:fs/promises'; import { basename, join } from 'node:path'; import { Socket } from 'socket.io-client'; -import { createUserDto } from 'src/fixtures'; import { errorDto } from 'src/responses'; import { app, testAssetDir, utils } from 'src/utils'; import request from 'supertest'; @@ -11,18 +10,13 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; describe('/map', () => { let websocket: Socket; let admin: LoginResponseDto; - let nonAdmin: LoginResponseDto; - let asset: AssetMediaResponseDto; beforeAll(async () => { await utils.resetDatabase(); admin = await utils.adminSetup({ onboarding: false }); - nonAdmin = await utils.userSetup(admin.accessToken, createUserDto.user1); websocket = await utils.connectWebsocket(admin.accessToken); - asset = await utils.createAsset(admin.accessToken); - const files = ['formats/heic/IMG_2682.heic', 'metadata/gps-position/thompson-springs.jpg']; utils.resetEvents(); const uploadFile = async (input: string) => { @@ -103,63 +97,6 @@ describe('/map', () => { }); }); - describe('GET /map/style.json', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).get('/map/style.json'); - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - - it('should allow shared link access', async () => { - const sharedLink = await utils.createSharedLink(admin.accessToken, { - type: SharedLinkType.Individual, - assetIds: [asset.id], - }); - const { status, body } = await request(app).get(`/map/style.json?key=${sharedLink.key}`).query({ theme: 'dark' }); - - expect(status).toBe(200); - expect(body).toEqual(expect.objectContaining({ id: 'immich-map-dark' })); - }); - - it('should throw an error if a theme is not light or dark', async () => { - for (const theme of ['dark1', true, 123, '', null, undefined]) { - const { status, body } = await request(app) - .get('/map/style.json') - .query({ theme }) - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['theme must be one of the following values: light, dark'])); - } - }); - - it('should return the light style.json', async () => { - const { status, body } = await request(app) - .get('/map/style.json') - .query({ theme: 'light' }) - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toBe(200); - expect(body).toEqual(expect.objectContaining({ id: 'immich-map-light' })); - }); - - it('should return the dark style.json', async () => { - const { status, body } = await request(app) - .get('/map/style.json') - .query({ theme: 'dark' }) - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toBe(200); - expect(body).toEqual(expect.objectContaining({ id: 'immich-map-dark' })); - }); - - it('should not require admin authentication', async () => { - const { status, body } = await request(app) - .get('/map/style.json') - .query({ theme: 'dark' }) - .set('Authorization', `Bearer ${nonAdmin.accessToken}`); - expect(status).toBe(200); - expect(body).toEqual(expect.objectContaining({ id: 'immich-map-dark' })); - }); - }); - describe('GET /map/reverse-geocode', () => { it('should require authentication', async () => { const { status, body } = await request(app).get('/map/reverse-geocode'); diff --git a/e2e/src/api/specs/server-info.e2e-spec.ts b/e2e/src/api/specs/server-info.e2e-spec.ts index 571d98cda744e..1ef8d8602ad24 100644 --- a/e2e/src/api/specs/server-info.e2e-spec.ts +++ b/e2e/src/api/specs/server-info.e2e-spec.ts @@ -128,6 +128,8 @@ describe('/server-info', () => { isInitialized: true, externalDomain: '', isOnboarded: false, + mapDarkStyleUrl: 'https://tiles.immich.cloud/v1/style/dark.json', + mapLightStyleUrl: 'https://tiles.immich.cloud/v1/style/light.json', }); }); }); diff --git a/e2e/src/api/specs/server.e2e-spec.ts b/e2e/src/api/specs/server.e2e-spec.ts index b19e6d85c4ad0..3133460adaf2a 100644 --- a/e2e/src/api/specs/server.e2e-spec.ts +++ b/e2e/src/api/specs/server.e2e-spec.ts @@ -134,6 +134,8 @@ describe('/server', () => { isInitialized: true, externalDomain: '', isOnboarded: false, + mapDarkStyleUrl: 'https://tiles.immich.cloud/v1/style/dark.json', + mapLightStyleUrl: 'https://tiles.immich.cloud/v1/style/light.json', }); }); }); diff --git a/mobile/.vscode/settings.json b/mobile/.vscode/settings.json index aa43dab3fb008..ceaf9a6ab88dc 100644 --- a/mobile/.vscode/settings.json +++ b/mobile/.vscode/settings.json @@ -1,5 +1,5 @@ { - "dart.flutterSdkPath": ".fvm/versions/3.24.0", + "dart.flutterSdkPath": ".fvm/versions/3.24.3", "search.exclude": { "**/.fvm": true }, diff --git a/mobile/lib/models/server_info/server_config.model.dart b/mobile/lib/models/server_info/server_config.model.dart index 8936939135d26..f07ffde522f14 100644 --- a/mobile/lib/models/server_info/server_config.model.dart +++ b/mobile/lib/models/server_info/server_config.model.dart @@ -4,11 +4,15 @@ class ServerConfig { final int trashDays; final String oauthButtonText; final String externalDomain; + final String mapDarkStyleUrl; + final String mapLightStyleUrl; const ServerConfig({ required this.trashDays, required this.oauthButtonText, required this.externalDomain, + required this.mapDarkStyleUrl, + required this.mapLightStyleUrl, }); ServerConfig copyWith({ @@ -20,6 +24,8 @@ class ServerConfig { trashDays: trashDays ?? this.trashDays, oauthButtonText: oauthButtonText ?? this.oauthButtonText, externalDomain: externalDomain ?? this.externalDomain, + mapDarkStyleUrl: mapDarkStyleUrl, + mapLightStyleUrl: mapLightStyleUrl, ); } @@ -30,7 +36,9 @@ class ServerConfig { ServerConfig.fromDto(ServerConfigDto dto) : trashDays = dto.trashDays, oauthButtonText = dto.oauthButtonText, - externalDomain = dto.externalDomain; + externalDomain = dto.externalDomain, + mapDarkStyleUrl = dto.mapDarkStyleUrl, + mapLightStyleUrl = dto.mapLightStyleUrl; @override bool operator ==(covariant ServerConfig other) { diff --git a/mobile/lib/pages/search/map/map.page.dart b/mobile/lib/pages/search/map/map.page.dart index d226ea55a36da..3be7e9b3e5374 100644 --- a/mobile/lib/pages/search/map/map.page.dart +++ b/mobile/lib/pages/search/map/map.page.dart @@ -1,4 +1,5 @@ import 'dart:math'; + import 'package:auto_route/auto_route.dart'; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; @@ -7,27 +8,27 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:geolocator/geolocator.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/latlngbounds_extension.dart'; import 'package:immich_mobile/extensions/maplibrecontroller_extensions.dart'; import 'package:immich_mobile/models/map/map_event.model.dart'; import 'package:immich_mobile/models/map/map_marker.model.dart'; +import 'package:immich_mobile/providers/db.provider.dart'; import 'package:immich_mobile/providers/map/map_marker.provider.dart'; import 'package:immich_mobile/providers/map/map_state.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/utils/debounce.dart'; +import 'package:immich_mobile/utils/immich_loading_overlay.dart'; import 'package:immich_mobile/utils/map_utils.dart'; import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart'; +import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:immich_mobile/widgets/map/map_app_bar.dart'; import 'package:immich_mobile/widgets/map/map_asset_grid.dart'; import 'package:immich_mobile/widgets/map/map_bottom_sheet.dart'; import 'package:immich_mobile/widgets/map/map_theme_override.dart'; import 'package:immich_mobile/widgets/map/positioned_asset_marker_icon.dart'; -import 'package:immich_mobile/routing/router.dart'; -import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:immich_mobile/providers/db.provider.dart'; -import 'package:immich_mobile/widgets/common/immich_toast.dart'; -import 'package:immich_mobile/utils/immich_loading_overlay.dart'; -import 'package:immich_mobile/utils/debounce.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; @RoutePage() @@ -304,7 +305,7 @@ class MapPage extends HookConsumerWidget { ), Positioned( right: 0, - bottom: MediaQuery.of(context).padding.bottom + 16, + bottom: MediaQuery.paddingOf(context).bottom + 16, child: ElevatedButton( onPressed: onZoomToLocation, style: ElevatedButton.styleFrom( diff --git a/mobile/lib/providers/map/map_state.provider.dart b/mobile/lib/providers/map/map_state.provider.dart index 6d1630bba2e18..189a23cd0aad1 100644 --- a/mobile/lib/providers/map/map_state.provider.dart +++ b/mobile/lib/providers/map/map_state.provider.dart @@ -1,28 +1,23 @@ -import 'dart:io'; - import 'package:flutter/material.dart'; -import 'package:immich_mobile/extensions/response_extensions.dart'; import 'package:immich_mobile/models/map/map_state.model.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart'; +import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; -import 'package:immich_mobile/providers/api.provider.dart'; -import 'package:logging/logging.dart'; -import 'package:openapi/api.dart'; -import 'package:path_provider/path_provider.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'map_state.provider.g.dart'; @Riverpod(keepAlive: true) class MapStateNotifier extends _$MapStateNotifier { - final _log = Logger("MapStateNotifier"); - @override MapState build() { final appSettingsProvider = ref.read(appSettingsServiceProvider); - // Fetch and save the Style JSONs - loadStyles(); + final lightStyleUrl = + ref.read(serverInfoProvider).serverConfig.mapLightStyleUrl; + final darkStyleUrl = + ref.read(serverInfoProvider).serverConfig.mapDarkStyleUrl; + return MapState( themeMode: ThemeMode.values[ appSettingsProvider.getSetting(AppSettingsEnum.mapThemeMode)], @@ -34,65 +29,11 @@ class MapStateNotifier extends _$MapStateNotifier { appSettingsProvider.getSetting(AppSettingsEnum.mapwithPartners), relativeTime: appSettingsProvider.getSetting(AppSettingsEnum.mapRelativeDate), + lightStyleFetched: AsyncData(lightStyleUrl), + darkStyleFetched: AsyncData(darkStyleUrl), ); } - void loadStyles() async { - final documents = (await getApplicationDocumentsDirectory()).path; - - // Set to loading - state = state.copyWith(lightStyleFetched: const AsyncLoading()); - - // Fetch and save light theme - final lightResponse = await ref - .read(apiServiceProvider) - .mapApi - .getMapStyleWithHttpInfo(MapTheme.light); - - if (lightResponse.statusCode >= HttpStatus.badRequest) { - state = state.copyWith( - lightStyleFetched: AsyncError(lightResponse.body, StackTrace.current), - ); - _log.severe( - "Cannot fetch map light style", - lightResponse.toLoggerString(), - ); - return; - } - - final lightJSON = lightResponse.body; - final lightFile = await File("$documents/map-style-light.json") - .writeAsString(lightJSON, flush: true); - - // Update state with path - state = - state.copyWith(lightStyleFetched: AsyncData(lightFile.absolute.path)); - - // Set to loading - state = state.copyWith(darkStyleFetched: const AsyncLoading()); - - // Fetch and save dark theme - final darkResponse = await ref - .read(apiServiceProvider) - .mapApi - .getMapStyleWithHttpInfo(MapTheme.dark); - - if (darkResponse.statusCode >= HttpStatus.badRequest) { - state = state.copyWith( - darkStyleFetched: AsyncError(darkResponse.body, StackTrace.current), - ); - _log.severe("Cannot fetch map dark style", darkResponse.toLoggerString()); - return; - } - - final darkJSON = darkResponse.body; - final darkFile = await File("$documents/map-style-dark.json") - .writeAsString(darkJSON, flush: true); - - // Update state with path - state = state.copyWith(darkStyleFetched: AsyncData(darkFile.absolute.path)); - } - void switchTheme(ThemeMode mode) { ref.read(appSettingsServiceProvider).setSetting( AppSettingsEnum.mapThemeMode, diff --git a/mobile/lib/providers/server_info.provider.dart b/mobile/lib/providers/server_info.provider.dart index 6327f992f5cd0..14521b06f64ca 100644 --- a/mobile/lib/providers/server_info.provider.dart +++ b/mobile/lib/providers/server_info.provider.dart @@ -34,6 +34,9 @@ class ServerInfoNotifier extends StateNotifier { trashDays: 30, oauthButtonText: '', externalDomain: '', + mapLightStyleUrl: + 'https://tiles.immich.cloud/v1/style/light.json', + mapDarkStyleUrl: 'https://tiles.immich.cloud/v1/style/dark.json', ), serverDiskInfo: const ServerDiskInfo( diskAvailable: "0", diff --git a/mobile/lib/utils/openapi_patching.dart b/mobile/lib/utils/openapi_patching.dart index c473fbb8333c1..255ad01247aa0 100644 --- a/mobile/lib/utils/openapi_patching.dart +++ b/mobile/lib/utils/openapi_patching.dart @@ -12,6 +12,19 @@ dynamic upgradeDto(dynamic value, String targetType) { addDefault(value, 'tags', TagsResponse().toJson()); } break; + case 'ServerConfigDto': + if (value is Map) { + addDefault( + value, + 'mapLightStyleUrl', + 'https://tiles.immich.cloud/v1/style/light.json', + ); + addDefault( + value, + 'mapDarkStyleUrl', + 'https://tiles.immich.cloud/v1/style/dark.json', + ); + } case 'UserResponseDto': if (value is Map) { addDefault(value, 'profileChangedAt', DateTime.now().toIso8601String()); diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index c8135519def56..285514e11cd54 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -138,7 +138,6 @@ Class | Method | HTTP request | Description *LibrariesApi* | [**updateLibrary**](doc//LibrariesApi.md#updatelibrary) | **PUT** /libraries/{id} | *LibrariesApi* | [**validate**](doc//LibrariesApi.md#validate) | **POST** /libraries/{id}/validate | *MapApi* | [**getMapMarkers**](doc//MapApi.md#getmapmarkers) | **GET** /map/markers | -*MapApi* | [**getMapStyle**](doc//MapApi.md#getmapstyle) | **GET** /map/style.json | *MapApi* | [**reverseGeocode**](doc//MapApi.md#reversegeocode) | **GET** /map/reverse-geocode | *MemoriesApi* | [**addMemoryAssets**](doc//MemoriesApi.md#addmemoryassets) | **PUT** /memories/{id}/assets | *MemoriesApi* | [**createMemory**](doc//MemoriesApi.md#creatememory) | **POST** /memories | @@ -348,7 +347,6 @@ Class | Method | HTTP request | Description - [ManualJobName](doc//ManualJobName.md) - [MapMarkerResponseDto](doc//MapMarkerResponseDto.md) - [MapReverseGeocodeResponseDto](doc//MapReverseGeocodeResponseDto.md) - - [MapTheme](doc//MapTheme.md) - [MemoriesResponse](doc//MemoriesResponse.md) - [MemoriesUpdate](doc//MemoriesUpdate.md) - [MemoryCreateDto](doc//MemoryCreateDto.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 7fa06b04875ee..fc0224a8c2072 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -159,7 +159,6 @@ part 'model/logout_response_dto.dart'; part 'model/manual_job_name.dart'; part 'model/map_marker_response_dto.dart'; part 'model/map_reverse_geocode_response_dto.dart'; -part 'model/map_theme.dart'; part 'model/memories_response.dart'; part 'model/memories_update.dart'; part 'model/memory_create_dto.dart'; diff --git a/mobile/openapi/lib/api/map_api.dart b/mobile/openapi/lib/api/map_api.dart index 2846dae6c3582..9644fbfc5c08b 100644 --- a/mobile/openapi/lib/api/map_api.dart +++ b/mobile/openapi/lib/api/map_api.dart @@ -105,62 +105,6 @@ class MapApi { return null; } - /// Performs an HTTP 'GET /map/style.json' operation and returns the [Response]. - /// Parameters: - /// - /// * [MapTheme] theme (required): - /// - /// * [String] key: - Future getMapStyleWithHttpInfo(MapTheme theme, { String? key, }) async { - // ignore: prefer_const_declarations - final path = r'/map/style.json'; - - // ignore: prefer_final_locals - Object? postBody; - - final queryParams = []; - final headerParams = {}; - final formParams = {}; - - if (key != null) { - queryParams.addAll(_queryParams('', 'key', key)); - } - queryParams.addAll(_queryParams('', 'theme', theme)); - - const contentTypes = []; - - - return apiClient.invokeAPI( - path, - 'GET', - queryParams, - postBody, - headerParams, - formParams, - contentTypes.isEmpty ? null : contentTypes.first, - ); - } - - /// Parameters: - /// - /// * [MapTheme] theme (required): - /// - /// * [String] key: - Future getMapStyle(MapTheme theme, { String? key, }) async { - final response = await getMapStyleWithHttpInfo(theme, key: key, ); - if (response.statusCode >= HttpStatus.badRequest) { - throw ApiException(response.statusCode, await _decodeBodyBytes(response)); - } - // When a remote server returns no body with a status of 204, we shall not decode it. - // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" - // FormatException when trying to decode an empty string. - if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { - return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'Object',) as Object; - - } - return null; - } - /// Performs an HTTP 'GET /map/reverse-geocode' operation and returns the [Response]. /// Parameters: /// diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index e857f51e3a875..828c0b9ed925c 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -372,8 +372,6 @@ class ApiClient { return MapMarkerResponseDto.fromJson(value); case 'MapReverseGeocodeResponseDto': return MapReverseGeocodeResponseDto.fromJson(value); - case 'MapTheme': - return MapThemeTypeTransformer().decode(value); case 'MemoriesResponse': return MemoriesResponse.fromJson(value); case 'MemoriesUpdate': diff --git a/mobile/openapi/lib/api_helper.dart b/mobile/openapi/lib/api_helper.dart index 0f3cc41097276..b7c6ad5e010d3 100644 --- a/mobile/openapi/lib/api_helper.dart +++ b/mobile/openapi/lib/api_helper.dart @@ -100,9 +100,6 @@ String parameterToString(dynamic value) { if (value is ManualJobName) { return ManualJobNameTypeTransformer().encode(value).toString(); } - if (value is MapTheme) { - return MapThemeTypeTransformer().encode(value).toString(); - } if (value is MemoryType) { return MemoryTypeTypeTransformer().encode(value).toString(); } diff --git a/mobile/openapi/lib/model/map_theme.dart b/mobile/openapi/lib/model/map_theme.dart deleted file mode 100644 index e2553790c6cc1..0000000000000 --- a/mobile/openapi/lib/model/map_theme.dart +++ /dev/null @@ -1,85 +0,0 @@ -// -// AUTO-GENERATED FILE, DO NOT MODIFY! -// -// @dart=2.18 - -// ignore_for_file: unused_element, unused_import -// ignore_for_file: always_put_required_named_parameters_first -// ignore_for_file: constant_identifier_names -// ignore_for_file: lines_longer_than_80_chars - -part of openapi.api; - - -class MapTheme { - /// Instantiate a new enum with the provided [value]. - const MapTheme._(this.value); - - /// The underlying value of this enum member. - final String value; - - @override - String toString() => value; - - String toJson() => value; - - static const light = MapTheme._(r'light'); - static const dark = MapTheme._(r'dark'); - - /// List of all possible values in this [enum][MapTheme]. - static const values = [ - light, - dark, - ]; - - static MapTheme? fromJson(dynamic value) => MapThemeTypeTransformer().decode(value); - - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; - if (json is List && json.isNotEmpty) { - for (final row in json) { - final value = MapTheme.fromJson(row); - if (value != null) { - result.add(value); - } - } - } - return result.toList(growable: growable); - } -} - -/// Transformation class that can [encode] an instance of [MapTheme] to String, -/// and [decode] dynamic data back to [MapTheme]. -class MapThemeTypeTransformer { - factory MapThemeTypeTransformer() => _instance ??= const MapThemeTypeTransformer._(); - - const MapThemeTypeTransformer._(); - - String encode(MapTheme data) => data.value; - - /// Decodes a [dynamic value][data] to a MapTheme. - /// - /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, - /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] - /// cannot be decoded successfully, then an [UnimplementedError] is thrown. - /// - /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, - /// and users are still using an old app with the old code. - MapTheme? decode(dynamic data, {bool allowNull = true}) { - if (data != null) { - switch (data) { - case r'light': return MapTheme.light; - case r'dark': return MapTheme.dark; - default: - if (!allowNull) { - throw ArgumentError('Unknown enum value to decode: $data'); - } - } - } - return null; - } - - /// Singleton [MapThemeTypeTransformer] instance. - static MapThemeTypeTransformer? _instance; -} - diff --git a/mobile/openapi/lib/model/server_config_dto.dart b/mobile/openapi/lib/model/server_config_dto.dart index c45ed32ac076b..bd5c2405e29d3 100644 --- a/mobile/openapi/lib/model/server_config_dto.dart +++ b/mobile/openapi/lib/model/server_config_dto.dart @@ -17,6 +17,8 @@ class ServerConfigDto { required this.isInitialized, required this.isOnboarded, required this.loginPageMessage, + required this.mapDarkStyleUrl, + required this.mapLightStyleUrl, required this.oauthButtonText, required this.trashDays, required this.userDeleteDelay, @@ -30,6 +32,10 @@ class ServerConfigDto { String loginPageMessage; + String mapDarkStyleUrl; + + String mapLightStyleUrl; + String oauthButtonText; int trashDays; @@ -42,6 +48,8 @@ class ServerConfigDto { other.isInitialized == isInitialized && other.isOnboarded == isOnboarded && other.loginPageMessage == loginPageMessage && + other.mapDarkStyleUrl == mapDarkStyleUrl && + other.mapLightStyleUrl == mapLightStyleUrl && other.oauthButtonText == oauthButtonText && other.trashDays == trashDays && other.userDeleteDelay == userDeleteDelay; @@ -53,12 +61,14 @@ class ServerConfigDto { (isInitialized.hashCode) + (isOnboarded.hashCode) + (loginPageMessage.hashCode) + + (mapDarkStyleUrl.hashCode) + + (mapLightStyleUrl.hashCode) + (oauthButtonText.hashCode) + (trashDays.hashCode) + (userDeleteDelay.hashCode); @override - String toString() => 'ServerConfigDto[externalDomain=$externalDomain, isInitialized=$isInitialized, isOnboarded=$isOnboarded, loginPageMessage=$loginPageMessage, oauthButtonText=$oauthButtonText, trashDays=$trashDays, userDeleteDelay=$userDeleteDelay]'; + String toString() => 'ServerConfigDto[externalDomain=$externalDomain, isInitialized=$isInitialized, isOnboarded=$isOnboarded, loginPageMessage=$loginPageMessage, mapDarkStyleUrl=$mapDarkStyleUrl, mapLightStyleUrl=$mapLightStyleUrl, oauthButtonText=$oauthButtonText, trashDays=$trashDays, userDeleteDelay=$userDeleteDelay]'; Map toJson() { final json = {}; @@ -66,6 +76,8 @@ class ServerConfigDto { json[r'isInitialized'] = this.isInitialized; json[r'isOnboarded'] = this.isOnboarded; json[r'loginPageMessage'] = this.loginPageMessage; + json[r'mapDarkStyleUrl'] = this.mapDarkStyleUrl; + json[r'mapLightStyleUrl'] = this.mapLightStyleUrl; json[r'oauthButtonText'] = this.oauthButtonText; json[r'trashDays'] = this.trashDays; json[r'userDeleteDelay'] = this.userDeleteDelay; @@ -85,6 +97,8 @@ class ServerConfigDto { isInitialized: mapValueOfType(json, r'isInitialized')!, isOnboarded: mapValueOfType(json, r'isOnboarded')!, loginPageMessage: mapValueOfType(json, r'loginPageMessage')!, + mapDarkStyleUrl: mapValueOfType(json, r'mapDarkStyleUrl')!, + mapLightStyleUrl: mapValueOfType(json, r'mapLightStyleUrl')!, oauthButtonText: mapValueOfType(json, r'oauthButtonText')!, trashDays: mapValueOfType(json, r'trashDays')!, userDeleteDelay: mapValueOfType(json, r'userDeleteDelay')!, @@ -139,6 +153,8 @@ class ServerConfigDto { 'isInitialized', 'isOnboarded', 'loginPageMessage', + 'mapDarkStyleUrl', + 'mapLightStyleUrl', 'oauthButtonText', 'trashDays', 'userDeleteDelay', diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 706ff5b8fb654..4e7c7119781fe 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -3167,55 +3167,6 @@ ] } }, - "/map/style.json": { - "get": { - "operationId": "getMapStyle", - "parameters": [ - { - "name": "key", - "required": false, - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "theme", - "required": true, - "in": "query", - "schema": { - "$ref": "#/components/schemas/MapTheme" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "type": "object" - } - } - }, - "description": "" - } - }, - "security": [ - { - "bearer": [] - }, - { - "cookie": [] - }, - { - "api_key": [] - } - ], - "tags": [ - "Map" - ] - } - }, "/memories": { "get": { "operationId": "searchMemories", @@ -5356,8 +5307,8 @@ "name": "password", "required": false, "in": "query", - "example": "password", "schema": { + "example": "password", "type": "string" } }, @@ -9695,13 +9646,6 @@ ], "type": "object" }, - "MapTheme": { - "enum": [ - "light", - "dark" - ], - "type": "string" - }, "MemoriesResponse": { "properties": { "enabled": { @@ -10917,6 +10861,12 @@ "loginPageMessage": { "type": "string" }, + "mapDarkStyleUrl": { + "type": "string" + }, + "mapLightStyleUrl": { + "type": "string" + }, "oauthButtonText": { "type": "string" }, @@ -10932,6 +10882,8 @@ "isInitialized", "isOnboarded", "loginPageMessage", + "mapDarkStyleUrl", + "mapLightStyleUrl", "oauthButtonText", "trashDays", "userDeleteDelay" diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 8e607f7570856..d1b88afabb043 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -928,6 +928,8 @@ export type ServerConfigDto = { isInitialized: boolean; isOnboarded: boolean; loginPageMessage: string; + mapDarkStyleUrl: string; + mapLightStyleUrl: string; oauthButtonText: string; trashDays: number; userDeleteDelay: number; @@ -2138,20 +2140,6 @@ export function reverseGeocode({ lat, lon }: { ...opts })); } -export function getMapStyle({ key, theme }: { - key?: string; - theme: MapTheme; -}, opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchJson<{ - status: 200; - data: object; - }>(`/map/style.json${QS.query(QS.explode({ - key, - theme - }))}`, { - ...opts - })); -} export function searchMemories(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; @@ -3469,10 +3457,6 @@ export enum JobCommand { Empty = "empty", ClearFailed = "clear-failed" } -export enum MapTheme { - Light = "light", - Dark = "dark" -} export enum MemoryType { OnThisDay = "on_this_day" } diff --git a/server/package-lock.json b/server/package-lock.json index ee432b9e065ff..9abfc6b5ce70f 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -733,9 +733,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz", - "integrity": "sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", "cpu": [ "ppc64" ], @@ -749,9 +749,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.20.2.tgz", - "integrity": "sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", "cpu": [ "arm" ], @@ -765,9 +765,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.20.2.tgz", - "integrity": "sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", "cpu": [ "arm64" ], @@ -781,9 +781,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.20.2.tgz", - "integrity": "sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", "cpu": [ "x64" ], @@ -797,9 +797,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.20.2.tgz", - "integrity": "sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", "cpu": [ "arm64" ], @@ -813,9 +813,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.20.2.tgz", - "integrity": "sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", "cpu": [ "x64" ], @@ -829,9 +829,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.2.tgz", - "integrity": "sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", "cpu": [ "arm64" ], @@ -845,9 +845,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.20.2.tgz", - "integrity": "sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", "cpu": [ "x64" ], @@ -861,9 +861,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.20.2.tgz", - "integrity": "sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", "cpu": [ "arm" ], @@ -877,9 +877,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.20.2.tgz", - "integrity": "sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", "cpu": [ "arm64" ], @@ -893,9 +893,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz", - "integrity": "sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", "cpu": [ "ia32" ], @@ -909,9 +909,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.20.2.tgz", - "integrity": "sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", "cpu": [ "loong64" ], @@ -925,9 +925,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.20.2.tgz", - "integrity": "sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", "cpu": [ "mips64el" ], @@ -941,9 +941,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.20.2.tgz", - "integrity": "sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", "cpu": [ "ppc64" ], @@ -957,9 +957,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.20.2.tgz", - "integrity": "sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", "cpu": [ "riscv64" ], @@ -973,9 +973,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.20.2.tgz", - "integrity": "sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", "cpu": [ "s390x" ], @@ -989,9 +989,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz", - "integrity": "sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", "cpu": [ "x64" ], @@ -1005,9 +1005,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.20.2.tgz", - "integrity": "sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", "cpu": [ "x64" ], @@ -1021,9 +1021,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.20.2.tgz", - "integrity": "sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", "cpu": [ "x64" ], @@ -1037,9 +1037,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.20.2.tgz", - "integrity": "sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", "cpu": [ "x64" ], @@ -1053,9 +1053,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.20.2.tgz", - "integrity": "sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", "cpu": [ "arm64" ], @@ -1069,9 +1069,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.20.2.tgz", - "integrity": "sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", "cpu": [ "ia32" ], @@ -1085,9 +1085,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.20.2.tgz", - "integrity": "sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", "cpu": [ "x64" ], @@ -2085,16 +2085,16 @@ } }, "node_modules/@nestjs/core": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-10.4.1.tgz", - "integrity": "sha512-9I1WdfOBCCHdUm+ClBJupOuZQS6UxzIWHIq6Vp1brAA5ZKl/Wq6BVwSsbnUJGBy3J3PM2XHmR0EQ4fwX3nR7lA==", + "version": "10.4.4", + "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-10.4.4.tgz", + "integrity": "sha512-y9tjmAzU6LTh1cC/lWrRsCcOd80khSR0qAHAqwY2svbW+AhsR/XCzgpZrAAKJrm/dDfjLCZKyxJSayeirGcW5Q==", "hasInstallScript": true, "dependencies": { "@nuxtjs/opencollective": "0.3.2", "fast-safe-stringify": "2.1.1", "iterare": "1.2.1", - "path-to-regexp": "3.2.0", - "tslib": "2.6.3", + "path-to-regexp": "3.3.0", + "tslib": "2.7.0", "uid": "2.0.2" }, "funding": { @@ -2121,6 +2121,11 @@ } } }, + "node_modules/@nestjs/core/node_modules/tslib": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==" + }, "node_modules/@nestjs/event-emitter": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/@nestjs/event-emitter/-/event-emitter-2.0.4.tgz", @@ -2153,15 +2158,15 @@ } }, "node_modules/@nestjs/platform-express": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-10.4.1.tgz", - "integrity": "sha512-ccfqIDAq/bg1ShLI5KGtaLaYGykuAdvCi57ohewH7eKJSIpWY1DQjbgKlFfXokALYUq1YOMGqjeZ244OWHfDQg==", + "version": "10.4.4", + "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-10.4.4.tgz", + "integrity": "sha512-y52q1MxhbHaT3vAgWd08RgiYon0lJgtTa8U6g6gV0KI0IygwZhDQFJVxnrRDUdxQGIP5CKHmfQu3sk9gTNFoEA==", "dependencies": { - "body-parser": "1.20.2", + "body-parser": "1.20.3", "cors": "2.8.5", - "express": "4.19.2", + "express": "4.21.0", "multer": "1.4.4-lts.1", - "tslib": "2.6.3" + "tslib": "2.7.0" }, "funding": { "type": "opencollective", @@ -2172,6 +2177,11 @@ "@nestjs/core": "^10.0.0" } }, + "node_modules/@nestjs/platform-express/node_modules/tslib": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==" + }, "node_modules/@nestjs/platform-socket.io": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-10.4.1.tgz", @@ -2238,15 +2248,15 @@ "dev": true }, "node_modules/@nestjs/swagger": { - "version": "7.4.0", - "resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-7.4.0.tgz", - "integrity": "sha512-dCiwKkRxcR7dZs5jtrGspBAe/nqJd1AYzOBTzw9iCdbq3BGrLpwokelk6lFZPe4twpTsPQqzNKBwKzVbI6AR/g==", + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-7.4.2.tgz", + "integrity": "sha512-Mu6TEn1M/owIvAx2B4DUQObQXqo2028R2s9rSZ/hJEgBK95+doTwS0DjmVA2wTeZTyVtXOoN7CsoM5pONBzvKQ==", "dependencies": { "@microsoft/tsdoc": "^0.15.0", "@nestjs/mapped-types": "2.0.5", "js-yaml": "4.1.0", "lodash": "4.17.21", - "path-to-regexp": "3.2.0", + "path-to-regexp": "3.3.0", "swagger-ui-dist": "5.17.14" }, "peerDependencies": { @@ -4551,9 +4561,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.14.3.tgz", - "integrity": "sha512-X9alQ3XM6I9IlSlmC8ddAvMSyG1WuHk5oUnXGw+yUBs3BFoTizmG1La/Gr8fVJvDWAq+zlYTZ9DBgrlKRVY06g==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.22.4.tgz", + "integrity": "sha512-Fxamp4aEZnfPOcGA8KSNEohV8hX7zVHOemC8jVBoBUHu5zpJK/Eu3uJwt6BMgy9fkvzxDaurgj96F/NiLukF2w==", "cpu": [ "arm" ], @@ -4564,9 +4574,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.14.3.tgz", - "integrity": "sha512-eQK5JIi+POhFpzk+LnjKIy4Ks+pwJ+NXmPxOCSvOKSNRPONzKuUvWE+P9JxGZVxrtzm6BAYMaL50FFuPe0oWMQ==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.22.4.tgz", + "integrity": "sha512-VXoK5UMrgECLYaMuGuVTOx5kcuap1Jm8g/M83RnCHBKOqvPPmROFJGQaZhGccnsFtfXQ3XYa4/jMCJvZnbJBdA==", "cpu": [ "arm64" ], @@ -4577,9 +4587,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.14.3.tgz", - "integrity": "sha512-Od4vE6f6CTT53yM1jgcLqNfItTsLt5zE46fdPaEmeFHvPs5SjZYlLpHrSiHEKR1+HdRfxuzXHjDOIxQyC3ptBA==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.22.4.tgz", + "integrity": "sha512-xMM9ORBqu81jyMKCDP+SZDhnX2QEVQzTcC6G18KlTQEzWK8r/oNZtKuZaCcHhnsa6fEeOBionoyl5JsAbE/36Q==", "cpu": [ "arm64" ], @@ -4590,9 +4600,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.14.3.tgz", - "integrity": "sha512-0IMAO21axJeNIrvS9lSe/PGthc8ZUS+zC53O0VhF5gMxfmcKAP4ESkKOCwEi6u2asUrt4mQv2rjY8QseIEb1aw==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.22.4.tgz", + "integrity": "sha512-aJJyYKQwbHuhTUrjWjxEvGnNNBCnmpHDvrb8JFDbeSH3m2XdHcxDd3jthAzvmoI8w/kSjd2y0udT+4okADsZIw==", "cpu": [ "x64" ], @@ -4603,9 +4613,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.14.3.tgz", - "integrity": "sha512-ge2DC7tHRHa3caVEoSbPRJpq7azhG+xYsd6u2MEnJ6XzPSzQsTKyXvh6iWjXRf7Rt9ykIUWHtl0Uz3T6yXPpKw==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.22.4.tgz", + "integrity": "sha512-j63YtCIRAzbO+gC2L9dWXRh5BFetsv0j0va0Wi9epXDgU/XUi5dJKo4USTttVyK7fGw2nPWK0PbAvyliz50SCQ==", "cpu": [ "arm" ], @@ -4616,9 +4626,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.14.3.tgz", - "integrity": "sha512-ljcuiDI4V3ySuc7eSk4lQ9wU8J8r8KrOUvB2U+TtK0TiW6OFDmJ+DdIjjwZHIw9CNxzbmXY39wwpzYuFDwNXuw==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.22.4.tgz", + "integrity": "sha512-dJnWUgwWBX1YBRsuKKMOlXCzh2Wu1mlHzv20TpqEsfdZLb3WoJW2kIEsGwLkroYf24IrPAvOT/ZQ2OYMV6vlrg==", "cpu": [ "arm" ], @@ -4629,9 +4639,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.14.3.tgz", - "integrity": "sha512-Eci2us9VTHm1eSyn5/eEpaC7eP/mp5n46gTRB3Aar3BgSvDQGJZuicyq6TsH4HngNBgVqC5sDYxOzTExSU+NjA==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.22.4.tgz", + "integrity": "sha512-AdPRoNi3NKVLolCN/Sp4F4N1d98c4SBnHMKoLuiG6RXgoZ4sllseuGioszumnPGmPM2O7qaAX/IJdeDU8f26Aw==", "cpu": [ "arm64" ], @@ -4642,9 +4652,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.14.3.tgz", - "integrity": "sha512-UrBoMLCq4E92/LCqlh+blpqMz5h1tJttPIniwUgOFJyjWI1qrtrDhhpHPuFxULlUmjFHfloWdixtDhSxJt5iKw==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.22.4.tgz", + "integrity": "sha512-Gl0AxBtDg8uoAn5CCqQDMqAx22Wx22pjDOjBdmG0VIWX3qUBHzYmOKh8KXHL4UpogfJ14G4wk16EQogF+v8hmA==", "cpu": [ "arm64" ], @@ -4655,9 +4665,9 @@ ] }, "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.14.3.tgz", - "integrity": "sha512-5aRjvsS8q1nWN8AoRfrq5+9IflC3P1leMoy4r2WjXyFqf3qcqsxRCfxtZIV58tCxd+Yv7WELPcO9mY9aeQyAmw==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.22.4.tgz", + "integrity": "sha512-3aVCK9xfWW1oGQpTsYJJPF6bfpWfhbRnhdlyhak2ZiyFLDaayz0EP5j9V1RVLAAxlmWKTDfS9wyRyY3hvhPoOg==", "cpu": [ "ppc64" ], @@ -4668,9 +4678,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.14.3.tgz", - "integrity": "sha512-sk/Qh1j2/RJSX7FhEpJn8n0ndxy/uf0kI/9Zc4b1ELhqULVdTfN6HL31CDaTChiBAOgLcsJ1sgVZjWv8XNEsAQ==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.22.4.tgz", + "integrity": "sha512-ePYIir6VYnhgv2C5Xe9u+ico4t8sZWXschR6fMgoPUK31yQu7hTEJb7bCqivHECwIClJfKgE7zYsh1qTP3WHUA==", "cpu": [ "riscv64" ], @@ -4681,9 +4691,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.14.3.tgz", - "integrity": "sha512-jOO/PEaDitOmY9TgkxF/TQIjXySQe5KVYB57H/8LRP/ux0ZoO8cSHCX17asMSv3ruwslXW/TLBcxyaUzGRHcqg==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.22.4.tgz", + "integrity": "sha512-GqFJ9wLlbB9daxhVlrTe61vJtEY99/xB3C8e4ULVsVfflcpmR6c8UZXjtkMA6FhNONhj2eA5Tk9uAVw5orEs4Q==", "cpu": [ "s390x" ], @@ -4694,9 +4704,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.14.3.tgz", - "integrity": "sha512-8ybV4Xjy59xLMyWo3GCfEGqtKV5M5gCSrZlxkPGvEPCGDLNla7v48S662HSGwRd6/2cSneMQWiv+QzcttLrrOA==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.22.4.tgz", + "integrity": "sha512-87v0ol2sH9GE3cLQLNEy0K/R0pz1nvg76o8M5nhMR0+Q+BBGLnb35P0fVz4CQxHYXaAOhE8HhlkaZfsdUOlHwg==", "cpu": [ "x64" ], @@ -4707,9 +4717,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.14.3.tgz", - "integrity": "sha512-s+xf1I46trOY10OqAtZ5Rm6lzHre/UiLA1J2uOhCFXWkbZrJRkYBPO6FhvGfHmdtQ3Bx793MNa7LvoWFAm93bg==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.22.4.tgz", + "integrity": "sha512-UV6FZMUgePDZrFjrNGIWzDo/vABebuXBhJEqrHxrGiU6HikPy0Z3LfdtciIttEUQfuDdCn8fqh7wiFJjCNwO+g==", "cpu": [ "x64" ], @@ -4720,9 +4730,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.14.3.tgz", - "integrity": "sha512-+4h2WrGOYsOumDQ5S2sYNyhVfrue+9tc9XcLWLh+Kw3UOxAvrfOrSMFon60KspcDdytkNDh7K2Vs6eMaYImAZg==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.22.4.tgz", + "integrity": "sha512-BjI+NVVEGAXjGWYHz/vv0pBqfGoUH0IGZ0cICTn7kB9PyjrATSkX+8WkguNjWoj2qSr1im/+tTGRaY+4/PdcQw==", "cpu": [ "arm64" ], @@ -4733,9 +4743,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.14.3.tgz", - "integrity": "sha512-T1l7y/bCeL/kUwh9OD4PQT4aM7Bq43vX05htPJJ46RTI4r5KNt6qJRzAfNfM+OYMNEVBWQzR2Gyk+FXLZfogGw==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.22.4.tgz", + "integrity": "sha512-SiWG/1TuUdPvYmzmYnmd3IEifzR61Tragkbx9D3+R8mzQqDBz8v+BvZNDlkiTtI9T15KYZhP0ehn3Dld4n9J5g==", "cpu": [ "ia32" ], @@ -4746,9 +4756,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.14.3.tgz", - "integrity": "sha512-/BypzV0H1y1HzgYpxqRaXGBRqfodgoBBCcsrujT6QRcakDQdfU+Lq9PENPh5jB4I44YWq+0C2eHsHya+nZY1sA==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.22.4.tgz", + "integrity": "sha512-j8pPKp53/lq9lMXN57S8cFz0MynJk8OWNuUnXct/9KCpKU7DgU3bYMJhwWmcqC0UU29p8Lr0/7KEVcaM6bf47Q==", "cpu": [ "x64" ], @@ -6689,9 +6699,9 @@ } }, "node_modules/body-parser": { - "version": "1.20.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz", - "integrity": "sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==", + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", + "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", "dependencies": { "bytes": "3.1.2", "content-type": "~1.0.5", @@ -6701,7 +6711,7 @@ "http-errors": "2.0.0", "iconv-lite": "0.4.24", "on-finished": "2.4.1", - "qs": "6.11.0", + "qs": "6.13.0", "raw-body": "2.5.2", "type-is": "~1.6.18", "unpipe": "1.0.0" @@ -7995,9 +8005,9 @@ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" }, "node_modules/encodeurl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", "engines": { "node": ">= 0.8" } @@ -8012,9 +8022,9 @@ } }, "node_modules/engine.io": { - "version": "6.5.2", - "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.5.2.tgz", - "integrity": "sha512-IXsMcGpw/xRfjra46sVZVHiSWo/nJ/3g1337q9KNXtS6YRzbW5yIzTCb9DjhrBe7r3GZQR0I4+nq+4ODk5g/cA==", + "version": "6.5.5", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.5.5.tgz", + "integrity": "sha512-C5Pn8Wk+1vKBoHghJODM63yk8MvrO9EWZUfkAt5HAqIgPE4/8FF0PEGHXtEd40l223+cE5ABWuPzm38PHFXfMA==", "dependencies": { "@types/cookie": "^0.4.1", "@types/cors": "^2.8.12", @@ -8025,7 +8035,7 @@ "cors": "~2.8.5", "debug": "~4.3.1", "engine.io-parser": "~5.2.1", - "ws": "~8.11.0" + "ws": "~8.17.1" }, "engines": { "node": ">=10.2.0" @@ -8097,9 +8107,9 @@ "dev": true }, "node_modules/esbuild": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz", - "integrity": "sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", "dev": true, "hasInstallScript": true, "bin": { @@ -8109,29 +8119,29 @@ "node": ">=12" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.20.2", - "@esbuild/android-arm": "0.20.2", - "@esbuild/android-arm64": "0.20.2", - "@esbuild/android-x64": "0.20.2", - "@esbuild/darwin-arm64": "0.20.2", - "@esbuild/darwin-x64": "0.20.2", - "@esbuild/freebsd-arm64": "0.20.2", - "@esbuild/freebsd-x64": "0.20.2", - "@esbuild/linux-arm": "0.20.2", - "@esbuild/linux-arm64": "0.20.2", - "@esbuild/linux-ia32": "0.20.2", - "@esbuild/linux-loong64": "0.20.2", - "@esbuild/linux-mips64el": "0.20.2", - "@esbuild/linux-ppc64": "0.20.2", - "@esbuild/linux-riscv64": "0.20.2", - "@esbuild/linux-s390x": "0.20.2", - "@esbuild/linux-x64": "0.20.2", - "@esbuild/netbsd-x64": "0.20.2", - "@esbuild/openbsd-x64": "0.20.2", - "@esbuild/sunos-x64": "0.20.2", - "@esbuild/win32-arm64": "0.20.2", - "@esbuild/win32-ia32": "0.20.2", - "@esbuild/win32-x64": "0.20.2" + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" } }, "node_modules/escalade": { @@ -8524,36 +8534,36 @@ ] }, "node_modules/express": { - "version": "4.19.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.19.2.tgz", - "integrity": "sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q==", + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.0.tgz", + "integrity": "sha512-VqcNGcj/Id5ZT1LZ/cfihi3ttTn+NJmkli2eZADigjq29qTlWi/hAQ43t/VLPq8+UX06FCEx3ByOYet6ZFblng==", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "1.20.2", + "body-parser": "1.20.3", "content-disposition": "0.5.4", "content-type": "~1.0.4", "cookie": "0.6.0", "cookie-signature": "1.0.6", "debug": "2.6.9", "depd": "2.0.0", - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", - "finalhandler": "1.2.0", + "finalhandler": "1.3.1", "fresh": "0.5.2", "http-errors": "2.0.0", - "merge-descriptors": "1.0.1", + "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "2.4.1", "parseurl": "~1.3.3", - "path-to-regexp": "0.1.7", + "path-to-regexp": "0.1.10", "proxy-addr": "~2.0.7", - "qs": "6.11.0", + "qs": "6.13.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", - "send": "0.18.0", - "serve-static": "1.15.0", + "send": "0.19.0", + "serve-static": "1.16.2", "setprototypeof": "1.2.0", "statuses": "2.0.1", "type-is": "~1.6.18", @@ -8586,9 +8596,9 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, "node_modules/express/node_modules/path-to-regexp": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", - "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==" + "version": "0.1.10", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.10.tgz", + "integrity": "sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w==" }, "node_modules/extend": { "version": "3.0.2", @@ -8719,12 +8729,12 @@ } }, "node_modules/finalhandler": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz", - "integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", + "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", "dependencies": { "debug": "2.6.9", - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "on-finished": "2.4.1", "parseurl": "~1.3.3", @@ -10281,9 +10291,12 @@ } }, "node_modules/merge-descriptors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", - "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==" + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, "node_modules/merge-stream": { "version": "2.0.0", @@ -10308,11 +10321,11 @@ } }, "node_modules/micromatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", - "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "dependencies": { - "braces": "^3.0.2", + "braces": "^3.0.3", "picomatch": "^2.3.1" }, "engines": { @@ -10928,9 +10941,12 @@ } }, "node_modules/object-inspect": { - "version": "1.12.3", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.3.tgz", - "integrity": "sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==", + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.2.tgz", + "integrity": "sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g==", + "engines": { + "node": ">= 0.4" + }, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -11247,9 +11263,9 @@ } }, "node_modules/path-to-regexp": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-3.2.0.tgz", - "integrity": "sha512-jczvQbCUS7XmS7o+y1aEO9OBVFeZBQ1MDSEqmO7xSoPgOPoowY/SxLpZ6Vh97/8qHZOteiCKb7gkG9gA2ZUxJA==" + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-3.3.0.tgz", + "integrity": "sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw==" }, "node_modules/path-type": { "version": "4.0.0", @@ -11384,9 +11400,9 @@ } }, "node_modules/picocolors": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", - "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==" + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.0.tgz", + "integrity": "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==" }, "node_modules/picomatch": { "version": "4.0.2", @@ -11433,9 +11449,9 @@ "integrity": "sha512-3hTIM2j/v9Lio+wOyur3kckD4NxruZhpowUbEgmyikW+a2Kppjtu1eN+AhnMQtoHW46zld88JiYWv6fxpsDrTQ==" }, "node_modules/postcss": { - "version": "8.4.38", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.38.tgz", - "integrity": "sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==", + "version": "8.4.47", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz", + "integrity": "sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==", "funding": [ { "type": "opencollective", @@ -11452,8 +11468,8 @@ ], "dependencies": { "nanoid": "^3.3.7", - "picocolors": "^1.0.0", - "source-map-js": "^1.2.0" + "picocolors": "^1.1.0", + "source-map-js": "^1.2.1" }, "engines": { "node": "^10 || ^12 || >=14" @@ -11806,11 +11822,11 @@ } }, "node_modules/qs": { - "version": "6.11.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", - "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", "dependencies": { - "side-channel": "^1.0.4" + "side-channel": "^1.0.6" }, "engines": { "node": ">=0.6" @@ -12862,9 +12878,9 @@ } }, "node_modules/rollup": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.14.3.tgz", - "integrity": "sha512-ag5tTQKYsj1bhrFC9+OEWqb5O6VYgtQDO9hPDBMmIbePwhfSr+ExlcU741t8Dhw5DkPCQf6noz0jb36D6W9/hw==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.22.4.tgz", + "integrity": "sha512-vD8HJ5raRcWOyymsR6Z3o6+RzfEPCnVLMFJ6vRslO1jt4LO6dUo5Qnpg7y4RkZFM2DMe3WUirkI5c16onjrc6A==", "dev": true, "dependencies": { "@types/estree": "1.0.5" @@ -12877,22 +12893,22 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.14.3", - "@rollup/rollup-android-arm64": "4.14.3", - "@rollup/rollup-darwin-arm64": "4.14.3", - "@rollup/rollup-darwin-x64": "4.14.3", - "@rollup/rollup-linux-arm-gnueabihf": "4.14.3", - "@rollup/rollup-linux-arm-musleabihf": "4.14.3", - "@rollup/rollup-linux-arm64-gnu": "4.14.3", - "@rollup/rollup-linux-arm64-musl": "4.14.3", - "@rollup/rollup-linux-powerpc64le-gnu": "4.14.3", - "@rollup/rollup-linux-riscv64-gnu": "4.14.3", - "@rollup/rollup-linux-s390x-gnu": "4.14.3", - "@rollup/rollup-linux-x64-gnu": "4.14.3", - "@rollup/rollup-linux-x64-musl": "4.14.3", - "@rollup/rollup-win32-arm64-msvc": "4.14.3", - "@rollup/rollup-win32-ia32-msvc": "4.14.3", - "@rollup/rollup-win32-x64-msvc": "4.14.3", + "@rollup/rollup-android-arm-eabi": "4.22.4", + "@rollup/rollup-android-arm64": "4.22.4", + "@rollup/rollup-darwin-arm64": "4.22.4", + "@rollup/rollup-darwin-x64": "4.22.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.22.4", + "@rollup/rollup-linux-arm-musleabihf": "4.22.4", + "@rollup/rollup-linux-arm64-gnu": "4.22.4", + "@rollup/rollup-linux-arm64-musl": "4.22.4", + "@rollup/rollup-linux-powerpc64le-gnu": "4.22.4", + "@rollup/rollup-linux-riscv64-gnu": "4.22.4", + "@rollup/rollup-linux-s390x-gnu": "4.22.4", + "@rollup/rollup-linux-x64-gnu": "4.22.4", + "@rollup/rollup-linux-x64-musl": "4.22.4", + "@rollup/rollup-win32-arm64-msvc": "4.22.4", + "@rollup/rollup-win32-ia32-msvc": "4.22.4", + "@rollup/rollup-win32-x64-msvc": "4.22.4", "fsevents": "~2.3.2" } }, @@ -13047,9 +13063,9 @@ } }, "node_modules/send": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz", - "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==", + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", + "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", "dependencies": { "debug": "2.6.9", "depd": "2.0.0", @@ -13082,6 +13098,14 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, + "node_modules/send/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/serialize-javascript": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", @@ -13092,14 +13116,14 @@ } }, "node_modules/serve-static": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz", - "integrity": "sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==", + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", + "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", "dependencies": { - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", - "send": "0.18.0" + "send": "0.19.0" }, "engines": { "node": ">= 0.8.0" @@ -13228,13 +13252,17 @@ "integrity": "sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw==" }, "node_modules/side-channel": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", - "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", + "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", "dependencies": { - "call-bind": "^1.0.0", - "get-intrinsic": "^1.0.2", - "object-inspect": "^1.9.0" + "call-bind": "^1.0.7", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.4", + "object-inspect": "^1.13.1" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -13314,26 +13342,6 @@ "ws": "~8.17.1" } }, - "node_modules/socket.io-adapter/node_modules/ws": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", - "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, "node_modules/socket.io-parser": { "version": "4.2.4", "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz", @@ -13356,9 +13364,9 @@ } }, "node_modules/source-map-js": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz", - "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "engines": { "node": ">=0.10.0" } @@ -13820,9 +13828,9 @@ } }, "node_modules/tar": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.0.tgz", - "integrity": "sha512-/Wo7DcT0u5HUV486xg675HtjNd3BXZ6xDbzsCUZPt5iw8bTQ63bP0Raut3mvro9u+CUyq7YQd8Cx55fsZXxqLQ==", + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", "dependencies": { "chownr": "^2.0.0", "fs-minipass": "^2.0.0", @@ -14794,14 +14802,14 @@ } }, "node_modules/vite": { - "version": "5.2.11", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.2.11.tgz", - "integrity": "sha512-HndV31LWW05i1BLPMUCE1B9E9GFbOu1MbenhS58FuK6owSO5qHm7GiCotrNY1YE5rMeQSFBGmT5ZaLEjFizgiQ==", + "version": "5.4.7", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.7.tgz", + "integrity": "sha512-5l2zxqMEPVENgvzTuBpHer2awaetimj2BGkhBPdnwKbPNOlHsODU+oiazEZzLK7KhAnOrO+XGYJYn4ZlUhDtDQ==", "dev": true, "dependencies": { - "esbuild": "^0.20.1", - "postcss": "^8.4.38", - "rollup": "^4.13.0" + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" }, "bin": { "vite": "bin/vite.js" @@ -14820,6 +14828,7 @@ "less": "*", "lightningcss": "^1.21.0", "sass": "*", + "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" @@ -14837,6 +14846,9 @@ "sass": { "optional": true }, + "sass-embedded": { + "optional": true + }, "stylus": { "optional": true }, @@ -15167,15 +15179,15 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, "node_modules/ws": { - "version": "8.11.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.11.0.tgz", - "integrity": "sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==", + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", "engines": { "node": ">=10.0.0" }, "peerDependencies": { "bufferutil": "^4.0.1", - "utf-8-validate": "^5.0.2" + "utf-8-validate": ">=5.0.2" }, "peerDependenciesMeta": { "bufferutil": { @@ -15779,163 +15791,163 @@ } }, "@esbuild/aix-ppc64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz", - "integrity": "sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", "dev": true, "optional": true }, "@esbuild/android-arm": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.20.2.tgz", - "integrity": "sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", "dev": true, "optional": true }, "@esbuild/android-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.20.2.tgz", - "integrity": "sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", "dev": true, "optional": true }, "@esbuild/android-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.20.2.tgz", - "integrity": "sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", "dev": true, "optional": true }, "@esbuild/darwin-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.20.2.tgz", - "integrity": "sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", "dev": true, "optional": true }, "@esbuild/darwin-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.20.2.tgz", - "integrity": "sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", "dev": true, "optional": true }, "@esbuild/freebsd-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.2.tgz", - "integrity": "sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", "dev": true, "optional": true }, "@esbuild/freebsd-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.20.2.tgz", - "integrity": "sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", "dev": true, "optional": true }, "@esbuild/linux-arm": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.20.2.tgz", - "integrity": "sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", "dev": true, "optional": true }, "@esbuild/linux-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.20.2.tgz", - "integrity": "sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", "dev": true, "optional": true }, "@esbuild/linux-ia32": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz", - "integrity": "sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", "dev": true, "optional": true }, "@esbuild/linux-loong64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.20.2.tgz", - "integrity": "sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", "dev": true, "optional": true }, "@esbuild/linux-mips64el": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.20.2.tgz", - "integrity": "sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", "dev": true, "optional": true }, "@esbuild/linux-ppc64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.20.2.tgz", - "integrity": "sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", "dev": true, "optional": true }, "@esbuild/linux-riscv64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.20.2.tgz", - "integrity": "sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", "dev": true, "optional": true }, "@esbuild/linux-s390x": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.20.2.tgz", - "integrity": "sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", "dev": true, "optional": true }, "@esbuild/linux-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz", - "integrity": "sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", "dev": true, "optional": true }, "@esbuild/netbsd-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.20.2.tgz", - "integrity": "sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", "dev": true, "optional": true }, "@esbuild/openbsd-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.20.2.tgz", - "integrity": "sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", "dev": true, "optional": true }, "@esbuild/sunos-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.20.2.tgz", - "integrity": "sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", "dev": true, "optional": true }, "@esbuild/win32-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.20.2.tgz", - "integrity": "sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", "dev": true, "optional": true }, "@esbuild/win32-ia32": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.20.2.tgz", - "integrity": "sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", "dev": true, "optional": true }, "@esbuild/win32-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.20.2.tgz", - "integrity": "sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", "dev": true, "optional": true }, @@ -16520,16 +16532,23 @@ } }, "@nestjs/core": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-10.4.1.tgz", - "integrity": "sha512-9I1WdfOBCCHdUm+ClBJupOuZQS6UxzIWHIq6Vp1brAA5ZKl/Wq6BVwSsbnUJGBy3J3PM2XHmR0EQ4fwX3nR7lA==", + "version": "10.4.4", + "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-10.4.4.tgz", + "integrity": "sha512-y9tjmAzU6LTh1cC/lWrRsCcOd80khSR0qAHAqwY2svbW+AhsR/XCzgpZrAAKJrm/dDfjLCZKyxJSayeirGcW5Q==", "requires": { "@nuxtjs/opencollective": "0.3.2", "fast-safe-stringify": "2.1.1", "iterare": "1.2.1", - "path-to-regexp": "3.2.0", - "tslib": "2.6.3", + "path-to-regexp": "3.3.0", + "tslib": "2.7.0", "uid": "2.0.2" + }, + "dependencies": { + "tslib": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==" + } } }, "@nestjs/event-emitter": { @@ -16547,15 +16566,22 @@ "requires": {} }, "@nestjs/platform-express": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-10.4.1.tgz", - "integrity": "sha512-ccfqIDAq/bg1ShLI5KGtaLaYGykuAdvCi57ohewH7eKJSIpWY1DQjbgKlFfXokALYUq1YOMGqjeZ244OWHfDQg==", + "version": "10.4.4", + "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-10.4.4.tgz", + "integrity": "sha512-y52q1MxhbHaT3vAgWd08RgiYon0lJgtTa8U6g6gV0KI0IygwZhDQFJVxnrRDUdxQGIP5CKHmfQu3sk9gTNFoEA==", "requires": { - "body-parser": "1.20.2", + "body-parser": "1.20.3", "cors": "2.8.5", - "express": "4.19.2", + "express": "4.21.0", "multer": "1.4.4-lts.1", - "tslib": "2.6.3" + "tslib": "2.7.0" + }, + "dependencies": { + "tslib": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==" + } } }, "@nestjs/platform-socket.io": { @@ -16605,15 +16631,15 @@ } }, "@nestjs/swagger": { - "version": "7.4.0", - "resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-7.4.0.tgz", - "integrity": "sha512-dCiwKkRxcR7dZs5jtrGspBAe/nqJd1AYzOBTzw9iCdbq3BGrLpwokelk6lFZPe4twpTsPQqzNKBwKzVbI6AR/g==", + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-7.4.2.tgz", + "integrity": "sha512-Mu6TEn1M/owIvAx2B4DUQObQXqo2028R2s9rSZ/hJEgBK95+doTwS0DjmVA2wTeZTyVtXOoN7CsoM5pONBzvKQ==", "requires": { "@microsoft/tsdoc": "^0.15.0", "@nestjs/mapped-types": "2.0.5", "js-yaml": "4.1.0", "lodash": "4.17.21", - "path-to-regexp": "3.2.0", + "path-to-regexp": "3.3.0", "swagger-ui-dist": "5.17.14" } }, @@ -18061,114 +18087,114 @@ } }, "@rollup/rollup-android-arm-eabi": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.14.3.tgz", - "integrity": "sha512-X9alQ3XM6I9IlSlmC8ddAvMSyG1WuHk5oUnXGw+yUBs3BFoTizmG1La/Gr8fVJvDWAq+zlYTZ9DBgrlKRVY06g==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.22.4.tgz", + "integrity": "sha512-Fxamp4aEZnfPOcGA8KSNEohV8hX7zVHOemC8jVBoBUHu5zpJK/Eu3uJwt6BMgy9fkvzxDaurgj96F/NiLukF2w==", "dev": true, "optional": true }, "@rollup/rollup-android-arm64": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.14.3.tgz", - "integrity": "sha512-eQK5JIi+POhFpzk+LnjKIy4Ks+pwJ+NXmPxOCSvOKSNRPONzKuUvWE+P9JxGZVxrtzm6BAYMaL50FFuPe0oWMQ==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.22.4.tgz", + "integrity": "sha512-VXoK5UMrgECLYaMuGuVTOx5kcuap1Jm8g/M83RnCHBKOqvPPmROFJGQaZhGccnsFtfXQ3XYa4/jMCJvZnbJBdA==", "dev": true, "optional": true }, "@rollup/rollup-darwin-arm64": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.14.3.tgz", - "integrity": "sha512-Od4vE6f6CTT53yM1jgcLqNfItTsLt5zE46fdPaEmeFHvPs5SjZYlLpHrSiHEKR1+HdRfxuzXHjDOIxQyC3ptBA==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.22.4.tgz", + "integrity": "sha512-xMM9ORBqu81jyMKCDP+SZDhnX2QEVQzTcC6G18KlTQEzWK8r/oNZtKuZaCcHhnsa6fEeOBionoyl5JsAbE/36Q==", "dev": true, "optional": true }, "@rollup/rollup-darwin-x64": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.14.3.tgz", - "integrity": "sha512-0IMAO21axJeNIrvS9lSe/PGthc8ZUS+zC53O0VhF5gMxfmcKAP4ESkKOCwEi6u2asUrt4mQv2rjY8QseIEb1aw==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.22.4.tgz", + "integrity": "sha512-aJJyYKQwbHuhTUrjWjxEvGnNNBCnmpHDvrb8JFDbeSH3m2XdHcxDd3jthAzvmoI8w/kSjd2y0udT+4okADsZIw==", "dev": true, "optional": true }, "@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.14.3.tgz", - "integrity": "sha512-ge2DC7tHRHa3caVEoSbPRJpq7azhG+xYsd6u2MEnJ6XzPSzQsTKyXvh6iWjXRf7Rt9ykIUWHtl0Uz3T6yXPpKw==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.22.4.tgz", + "integrity": "sha512-j63YtCIRAzbO+gC2L9dWXRh5BFetsv0j0va0Wi9epXDgU/XUi5dJKo4USTttVyK7fGw2nPWK0PbAvyliz50SCQ==", "dev": true, "optional": true }, "@rollup/rollup-linux-arm-musleabihf": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.14.3.tgz", - "integrity": "sha512-ljcuiDI4V3ySuc7eSk4lQ9wU8J8r8KrOUvB2U+TtK0TiW6OFDmJ+DdIjjwZHIw9CNxzbmXY39wwpzYuFDwNXuw==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.22.4.tgz", + "integrity": "sha512-dJnWUgwWBX1YBRsuKKMOlXCzh2Wu1mlHzv20TpqEsfdZLb3WoJW2kIEsGwLkroYf24IrPAvOT/ZQ2OYMV6vlrg==", "dev": true, "optional": true }, "@rollup/rollup-linux-arm64-gnu": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.14.3.tgz", - "integrity": "sha512-Eci2us9VTHm1eSyn5/eEpaC7eP/mp5n46gTRB3Aar3BgSvDQGJZuicyq6TsH4HngNBgVqC5sDYxOzTExSU+NjA==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.22.4.tgz", + "integrity": "sha512-AdPRoNi3NKVLolCN/Sp4F4N1d98c4SBnHMKoLuiG6RXgoZ4sllseuGioszumnPGmPM2O7qaAX/IJdeDU8f26Aw==", "dev": true, "optional": true }, "@rollup/rollup-linux-arm64-musl": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.14.3.tgz", - "integrity": "sha512-UrBoMLCq4E92/LCqlh+blpqMz5h1tJttPIniwUgOFJyjWI1qrtrDhhpHPuFxULlUmjFHfloWdixtDhSxJt5iKw==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.22.4.tgz", + "integrity": "sha512-Gl0AxBtDg8uoAn5CCqQDMqAx22Wx22pjDOjBdmG0VIWX3qUBHzYmOKh8KXHL4UpogfJ14G4wk16EQogF+v8hmA==", "dev": true, "optional": true }, "@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.14.3.tgz", - "integrity": "sha512-5aRjvsS8q1nWN8AoRfrq5+9IflC3P1leMoy4r2WjXyFqf3qcqsxRCfxtZIV58tCxd+Yv7WELPcO9mY9aeQyAmw==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.22.4.tgz", + "integrity": "sha512-3aVCK9xfWW1oGQpTsYJJPF6bfpWfhbRnhdlyhak2ZiyFLDaayz0EP5j9V1RVLAAxlmWKTDfS9wyRyY3hvhPoOg==", "dev": true, "optional": true }, "@rollup/rollup-linux-riscv64-gnu": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.14.3.tgz", - "integrity": "sha512-sk/Qh1j2/RJSX7FhEpJn8n0ndxy/uf0kI/9Zc4b1ELhqULVdTfN6HL31CDaTChiBAOgLcsJ1sgVZjWv8XNEsAQ==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.22.4.tgz", + "integrity": "sha512-ePYIir6VYnhgv2C5Xe9u+ico4t8sZWXschR6fMgoPUK31yQu7hTEJb7bCqivHECwIClJfKgE7zYsh1qTP3WHUA==", "dev": true, "optional": true }, "@rollup/rollup-linux-s390x-gnu": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.14.3.tgz", - "integrity": "sha512-jOO/PEaDitOmY9TgkxF/TQIjXySQe5KVYB57H/8LRP/ux0ZoO8cSHCX17asMSv3ruwslXW/TLBcxyaUzGRHcqg==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.22.4.tgz", + "integrity": "sha512-GqFJ9wLlbB9daxhVlrTe61vJtEY99/xB3C8e4ULVsVfflcpmR6c8UZXjtkMA6FhNONhj2eA5Tk9uAVw5orEs4Q==", "dev": true, "optional": true }, "@rollup/rollup-linux-x64-gnu": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.14.3.tgz", - "integrity": "sha512-8ybV4Xjy59xLMyWo3GCfEGqtKV5M5gCSrZlxkPGvEPCGDLNla7v48S662HSGwRd6/2cSneMQWiv+QzcttLrrOA==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.22.4.tgz", + "integrity": "sha512-87v0ol2sH9GE3cLQLNEy0K/R0pz1nvg76o8M5nhMR0+Q+BBGLnb35P0fVz4CQxHYXaAOhE8HhlkaZfsdUOlHwg==", "dev": true, "optional": true }, "@rollup/rollup-linux-x64-musl": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.14.3.tgz", - "integrity": "sha512-s+xf1I46trOY10OqAtZ5Rm6lzHre/UiLA1J2uOhCFXWkbZrJRkYBPO6FhvGfHmdtQ3Bx793MNa7LvoWFAm93bg==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.22.4.tgz", + "integrity": "sha512-UV6FZMUgePDZrFjrNGIWzDo/vABebuXBhJEqrHxrGiU6HikPy0Z3LfdtciIttEUQfuDdCn8fqh7wiFJjCNwO+g==", "dev": true, "optional": true }, "@rollup/rollup-win32-arm64-msvc": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.14.3.tgz", - "integrity": "sha512-+4h2WrGOYsOumDQ5S2sYNyhVfrue+9tc9XcLWLh+Kw3UOxAvrfOrSMFon60KspcDdytkNDh7K2Vs6eMaYImAZg==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.22.4.tgz", + "integrity": "sha512-BjI+NVVEGAXjGWYHz/vv0pBqfGoUH0IGZ0cICTn7kB9PyjrATSkX+8WkguNjWoj2qSr1im/+tTGRaY+4/PdcQw==", "dev": true, "optional": true }, "@rollup/rollup-win32-ia32-msvc": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.14.3.tgz", - "integrity": "sha512-T1l7y/bCeL/kUwh9OD4PQT4aM7Bq43vX05htPJJ46RTI4r5KNt6qJRzAfNfM+OYMNEVBWQzR2Gyk+FXLZfogGw==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.22.4.tgz", + "integrity": "sha512-SiWG/1TuUdPvYmzmYnmd3IEifzR61Tragkbx9D3+R8mzQqDBz8v+BvZNDlkiTtI9T15KYZhP0ehn3Dld4n9J5g==", "dev": true, "optional": true }, "@rollup/rollup-win32-x64-msvc": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.14.3.tgz", - "integrity": "sha512-/BypzV0H1y1HzgYpxqRaXGBRqfodgoBBCcsrujT6QRcakDQdfU+Lq9PENPh5jB4I44YWq+0C2eHsHya+nZY1sA==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.22.4.tgz", + "integrity": "sha512-j8pPKp53/lq9lMXN57S8cFz0MynJk8OWNuUnXct/9KCpKU7DgU3bYMJhwWmcqC0UU29p8Lr0/7KEVcaM6bf47Q==", "dev": true, "optional": true }, @@ -19691,9 +19717,9 @@ } }, "body-parser": { - "version": "1.20.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz", - "integrity": "sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==", + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", + "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", "requires": { "bytes": "3.1.2", "content-type": "~1.0.5", @@ -19703,7 +19729,7 @@ "http-errors": "2.0.0", "iconv-lite": "0.4.24", "on-finished": "2.4.1", - "qs": "6.11.0", + "qs": "6.13.0", "raw-body": "2.5.2", "type-is": "~1.6.18", "unpipe": "1.0.0" @@ -20626,9 +20652,9 @@ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" }, "encodeurl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==" + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==" }, "end-of-stream": { "version": "1.4.4", @@ -20640,9 +20666,9 @@ } }, "engine.io": { - "version": "6.5.2", - "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.5.2.tgz", - "integrity": "sha512-IXsMcGpw/xRfjra46sVZVHiSWo/nJ/3g1337q9KNXtS6YRzbW5yIzTCb9DjhrBe7r3GZQR0I4+nq+4ODk5g/cA==", + "version": "6.5.5", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.5.5.tgz", + "integrity": "sha512-C5Pn8Wk+1vKBoHghJODM63yk8MvrO9EWZUfkAt5HAqIgPE4/8FF0PEGHXtEd40l223+cE5ABWuPzm38PHFXfMA==", "requires": { "@types/cookie": "^0.4.1", "@types/cors": "^2.8.12", @@ -20653,7 +20679,7 @@ "cors": "~2.8.5", "debug": "~4.3.1", "engine.io-parser": "~5.2.1", - "ws": "~8.11.0" + "ws": "~8.17.1" } }, "engine.io-parser": { @@ -20704,34 +20730,34 @@ "dev": true }, "esbuild": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz", - "integrity": "sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", "dev": true, "requires": { - "@esbuild/aix-ppc64": "0.20.2", - "@esbuild/android-arm": "0.20.2", - "@esbuild/android-arm64": "0.20.2", - "@esbuild/android-x64": "0.20.2", - "@esbuild/darwin-arm64": "0.20.2", - "@esbuild/darwin-x64": "0.20.2", - "@esbuild/freebsd-arm64": "0.20.2", - "@esbuild/freebsd-x64": "0.20.2", - "@esbuild/linux-arm": "0.20.2", - "@esbuild/linux-arm64": "0.20.2", - "@esbuild/linux-ia32": "0.20.2", - "@esbuild/linux-loong64": "0.20.2", - "@esbuild/linux-mips64el": "0.20.2", - "@esbuild/linux-ppc64": "0.20.2", - "@esbuild/linux-riscv64": "0.20.2", - "@esbuild/linux-s390x": "0.20.2", - "@esbuild/linux-x64": "0.20.2", - "@esbuild/netbsd-x64": "0.20.2", - "@esbuild/openbsd-x64": "0.20.2", - "@esbuild/sunos-x64": "0.20.2", - "@esbuild/win32-arm64": "0.20.2", - "@esbuild/win32-ia32": "0.20.2", - "@esbuild/win32-x64": "0.20.2" + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" } }, "escalade": { @@ -20995,36 +21021,36 @@ "optional": true }, "express": { - "version": "4.19.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.19.2.tgz", - "integrity": "sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q==", + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.0.tgz", + "integrity": "sha512-VqcNGcj/Id5ZT1LZ/cfihi3ttTn+NJmkli2eZADigjq29qTlWi/hAQ43t/VLPq8+UX06FCEx3ByOYet6ZFblng==", "requires": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "1.20.2", + "body-parser": "1.20.3", "content-disposition": "0.5.4", "content-type": "~1.0.4", "cookie": "0.6.0", "cookie-signature": "1.0.6", "debug": "2.6.9", "depd": "2.0.0", - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", - "finalhandler": "1.2.0", + "finalhandler": "1.3.1", "fresh": "0.5.2", "http-errors": "2.0.0", - "merge-descriptors": "1.0.1", + "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "2.4.1", "parseurl": "~1.3.3", - "path-to-regexp": "0.1.7", + "path-to-regexp": "0.1.10", "proxy-addr": "~2.0.7", - "qs": "6.11.0", + "qs": "6.13.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", - "send": "0.18.0", - "serve-static": "1.15.0", + "send": "0.19.0", + "serve-static": "1.16.2", "setprototypeof": "1.2.0", "statuses": "2.0.1", "type-is": "~1.6.18", @@ -21051,9 +21077,9 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, "path-to-regexp": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", - "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==" + "version": "0.1.10", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.10.tgz", + "integrity": "sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w==" } } }, @@ -21167,12 +21193,12 @@ } }, "finalhandler": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz", - "integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", + "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", "requires": { "debug": "2.6.9", - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "on-finished": "2.4.1", "parseurl": "~1.3.3", @@ -22320,9 +22346,9 @@ } }, "merge-descriptors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", - "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==" + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==" }, "merge-stream": { "version": "2.0.0", @@ -22341,11 +22367,11 @@ "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==" }, "micromatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", - "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "requires": { - "braces": "^3.0.2", + "braces": "^3.0.3", "picomatch": "^2.3.1" }, "dependencies": { @@ -22784,9 +22810,9 @@ "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==" }, "object-inspect": { - "version": "1.12.3", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.3.tgz", - "integrity": "sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==" + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.2.tgz", + "integrity": "sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g==" }, "obuf": { "version": "1.1.2", @@ -23022,9 +23048,9 @@ } }, "path-to-regexp": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-3.2.0.tgz", - "integrity": "sha512-jczvQbCUS7XmS7o+y1aEO9OBVFeZBQ1MDSEqmO7xSoPgOPoowY/SxLpZ6Vh97/8qHZOteiCKb7gkG9gA2ZUxJA==" + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-3.3.0.tgz", + "integrity": "sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw==" }, "path-type": { "version": "4.0.0", @@ -23123,9 +23149,9 @@ } }, "picocolors": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", - "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==" + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.0.tgz", + "integrity": "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==" }, "picomatch": { "version": "4.0.2", @@ -23156,13 +23182,13 @@ "integrity": "sha512-3hTIM2j/v9Lio+wOyur3kckD4NxruZhpowUbEgmyikW+a2Kppjtu1eN+AhnMQtoHW46zld88JiYWv6fxpsDrTQ==" }, "postcss": { - "version": "8.4.38", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.38.tgz", - "integrity": "sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==", + "version": "8.4.47", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz", + "integrity": "sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==", "requires": { "nanoid": "^3.3.7", - "picocolors": "^1.0.0", - "source-map-js": "^1.2.0" + "picocolors": "^1.1.0", + "source-map-js": "^1.2.1" } }, "postcss-import": { @@ -23389,11 +23415,11 @@ "dev": true }, "qs": { - "version": "6.11.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", - "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", "requires": { - "side-channel": "^1.0.4" + "side-channel": "^1.0.6" } }, "queue-microtask": { @@ -24051,27 +24077,27 @@ } }, "rollup": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.14.3.tgz", - "integrity": "sha512-ag5tTQKYsj1bhrFC9+OEWqb5O6VYgtQDO9hPDBMmIbePwhfSr+ExlcU741t8Dhw5DkPCQf6noz0jb36D6W9/hw==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.22.4.tgz", + "integrity": "sha512-vD8HJ5raRcWOyymsR6Z3o6+RzfEPCnVLMFJ6vRslO1jt4LO6dUo5Qnpg7y4RkZFM2DMe3WUirkI5c16onjrc6A==", "dev": true, "requires": { - "@rollup/rollup-android-arm-eabi": "4.14.3", - "@rollup/rollup-android-arm64": "4.14.3", - "@rollup/rollup-darwin-arm64": "4.14.3", - "@rollup/rollup-darwin-x64": "4.14.3", - "@rollup/rollup-linux-arm-gnueabihf": "4.14.3", - "@rollup/rollup-linux-arm-musleabihf": "4.14.3", - "@rollup/rollup-linux-arm64-gnu": "4.14.3", - "@rollup/rollup-linux-arm64-musl": "4.14.3", - "@rollup/rollup-linux-powerpc64le-gnu": "4.14.3", - "@rollup/rollup-linux-riscv64-gnu": "4.14.3", - "@rollup/rollup-linux-s390x-gnu": "4.14.3", - "@rollup/rollup-linux-x64-gnu": "4.14.3", - "@rollup/rollup-linux-x64-musl": "4.14.3", - "@rollup/rollup-win32-arm64-msvc": "4.14.3", - "@rollup/rollup-win32-ia32-msvc": "4.14.3", - "@rollup/rollup-win32-x64-msvc": "4.14.3", + "@rollup/rollup-android-arm-eabi": "4.22.4", + "@rollup/rollup-android-arm64": "4.22.4", + "@rollup/rollup-darwin-arm64": "4.22.4", + "@rollup/rollup-darwin-x64": "4.22.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.22.4", + "@rollup/rollup-linux-arm-musleabihf": "4.22.4", + "@rollup/rollup-linux-arm64-gnu": "4.22.4", + "@rollup/rollup-linux-arm64-musl": "4.22.4", + "@rollup/rollup-linux-powerpc64le-gnu": "4.22.4", + "@rollup/rollup-linux-riscv64-gnu": "4.22.4", + "@rollup/rollup-linux-s390x-gnu": "4.22.4", + "@rollup/rollup-linux-x64-gnu": "4.22.4", + "@rollup/rollup-linux-x64-musl": "4.22.4", + "@rollup/rollup-win32-arm64-msvc": "4.22.4", + "@rollup/rollup-win32-ia32-msvc": "4.22.4", + "@rollup/rollup-win32-x64-msvc": "4.22.4", "@types/estree": "1.0.5", "fsevents": "~2.3.2" } @@ -24176,9 +24202,9 @@ "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==" }, "send": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz", - "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==", + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", + "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", "requires": { "debug": "2.6.9", "depd": "2.0.0", @@ -24209,6 +24235,11 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" } } + }, + "encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==" } } }, @@ -24222,14 +24253,14 @@ } }, "serve-static": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz", - "integrity": "sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==", + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", + "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", "requires": { - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", - "send": "0.18.0" + "send": "0.19.0" } }, "set-blocking": { @@ -24332,13 +24363,14 @@ "integrity": "sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw==" }, "side-channel": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", - "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", + "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", "requires": { - "call-bind": "^1.0.0", - "get-intrinsic": "^1.0.2", - "object-inspect": "^1.9.0" + "call-bind": "^1.0.7", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.4", + "object-inspect": "^1.13.1" } }, "siginfo": { @@ -24403,14 +24435,6 @@ "requires": { "debug": "~4.3.4", "ws": "~8.17.1" - }, - "dependencies": { - "ws": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", - "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", - "requires": {} - } } }, "socket.io-parser": { @@ -24429,9 +24453,9 @@ "dev": true }, "source-map-js": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz", - "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==" + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==" }, "source-map-support": { "version": "0.5.21", @@ -24766,9 +24790,9 @@ "dev": true }, "tar": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.0.tgz", - "integrity": "sha512-/Wo7DcT0u5HUV486xg675HtjNd3BXZ6xDbzsCUZPt5iw8bTQ63bP0Raut3mvro9u+CUyq7YQd8Cx55fsZXxqLQ==", + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", "requires": { "chownr": "^2.0.0", "fs-minipass": "^2.0.0", @@ -25399,15 +25423,15 @@ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==" }, "vite": { - "version": "5.2.11", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.2.11.tgz", - "integrity": "sha512-HndV31LWW05i1BLPMUCE1B9E9GFbOu1MbenhS58FuK6owSO5qHm7GiCotrNY1YE5rMeQSFBGmT5ZaLEjFizgiQ==", + "version": "5.4.7", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.7.tgz", + "integrity": "sha512-5l2zxqMEPVENgvzTuBpHer2awaetimj2BGkhBPdnwKbPNOlHsODU+oiazEZzLK7KhAnOrO+XGYJYn4ZlUhDtDQ==", "dev": true, "requires": { - "esbuild": "^0.20.1", + "esbuild": "^0.21.3", "fsevents": "~2.3.3", - "postcss": "^8.4.38", - "rollup": "^4.13.0" + "postcss": "^8.4.43", + "rollup": "^4.20.0" } }, "vite-node": { @@ -25627,9 +25651,9 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, "ws": { - "version": "8.11.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.11.0.tgz", - "integrity": "sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==", + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", "requires": {} }, "xtend": { diff --git a/server/src/config.ts b/server/src/config.ts index 057c9a69e213c..03ea3f111b9ac 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -285,8 +285,8 @@ export const defaults = Object.freeze({ }, map: { enabled: true, - lightStyle: '', - darkStyle: '', + lightStyle: 'https://tiles.immich.cloud/v1/style/light.json', + darkStyle: 'https://tiles.immich.cloud/v1/style/dark.json', }, reverseGeocoding: { enabled: true, diff --git a/server/src/controllers/map.controller.ts b/server/src/controllers/map.controller.ts index d6c26c58a073c..88104e6b588d8 100644 --- a/server/src/controllers/map.controller.ts +++ b/server/src/controllers/map.controller.ts @@ -7,7 +7,6 @@ import { MapReverseGeocodeDto, MapReverseGeocodeResponseDto, } from 'src/dtos/map.dto'; -import { MapThemeDto } from 'src/dtos/system-config.dto'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { MapService } from 'src/services/map.service'; @@ -22,12 +21,6 @@ export class MapController { return this.service.getMapMarkers(auth, options); } - @Authenticated({ sharedLink: true }) - @Get('style.json') - getMapStyle(@Query() dto: MapThemeDto) { - return this.service.getMapStyle(dto.theme); - } - @Authenticated() @Get('reverse-geocode') @HttpCode(HttpStatus.OK) diff --git a/server/src/dtos/server.dto.ts b/server/src/dtos/server.dto.ts index 78e59e4d1a695..aafadff47888f 100644 --- a/server/src/dtos/server.dto.ts +++ b/server/src/dtos/server.dto.ts @@ -121,6 +121,8 @@ export class ServerConfigDto { isInitialized!: boolean; isOnboarded!: boolean; externalDomain!: string; + mapDarkStyleUrl!: string; + mapLightStyleUrl!: string; } export class ServerFeaturesDto { diff --git a/server/src/dtos/system-config.dto.ts b/server/src/dtos/system-config.dto.ts index 14027aa16ad32..336f50f39bc8c 100644 --- a/server/src/dtos/system-config.dto.ts +++ b/server/src/dtos/system-config.dto.ts @@ -296,10 +296,12 @@ class SystemConfigMapDto { @ValidateBoolean() enabled!: boolean; - @IsString() + @IsNotEmpty() + @IsUrl() lightStyle!: string; - @IsString() + @IsNotEmpty() + @IsUrl() darkStyle!: string; } diff --git a/server/src/services/map.service.ts b/server/src/services/map.service.ts index ffd84a3e02bf6..5836505e54893 100644 --- a/server/src/services/map.service.ts +++ b/server/src/services/map.service.ts @@ -43,17 +43,6 @@ export class MapService { return this.mapRepository.getMapMarkers(userIds, albumIds, options); } - async getMapStyle(theme: 'light' | 'dark') { - const { map } = await this.configCore.getConfig({ withCache: false }); - const styleUrl = theme === 'dark' ? map.darkStyle : map.lightStyle; - - if (styleUrl) { - return this.mapRepository.fetchStyle(styleUrl); - } - - return JSON.parse(await this.systemMetadataRepository.readFile(`./resources/style-${theme}.json`)); - } - async reverseGeocode(dto: MapReverseGeocodeDto) { const { lat: latitude, lon: longitude } = dto; // eventually this should probably return an array of results diff --git a/server/src/services/server.service.spec.ts b/server/src/services/server.service.spec.ts index ac899f7b13ba8..4e6a8972b008c 100644 --- a/server/src/services/server.service.spec.ts +++ b/server/src/services/server.service.spec.ts @@ -186,6 +186,8 @@ describe(ServerService.name, () => { isInitialized: undefined, isOnboarded: false, externalDomain: '', + mapDarkStyleUrl: 'https://tiles.immich.cloud/v1/style/dark.json', + mapLightStyleUrl: 'https://tiles.immich.cloud/v1/style/light.json', }); expect(systemMock.get).toHaveBeenCalled(); }); diff --git a/server/src/services/server.service.ts b/server/src/services/server.service.ts index e57a206765f96..9db90e41b3c58 100644 --- a/server/src/services/server.service.ts +++ b/server/src/services/server.service.ts @@ -129,6 +129,8 @@ export class ServerService { isInitialized, isOnboarded: onboarding?.isOnboarded || false, externalDomain: config.server.externalDomain, + mapDarkStyleUrl: config.map.darkStyle, + mapLightStyleUrl: config.map.lightStyle, }; } diff --git a/server/src/services/system-config.service.spec.ts b/server/src/services/system-config.service.spec.ts index 7e25e0cd46c8c..52ad6d276b94e 100644 --- a/server/src/services/system-config.service.spec.ts +++ b/server/src/services/system-config.service.spec.ts @@ -100,8 +100,8 @@ const updatedConfig = Object.freeze({ }, map: { enabled: true, - lightStyle: '', - darkStyle: '', + lightStyle: 'https://tiles.immich.cloud/v1/style/light.json', + darkStyle: 'https://tiles.immich.cloud/v1/style/dark.json', }, reverseGeocoding: { enabled: true, diff --git a/web/src/lib/components/shared-components/map/map.svelte b/web/src/lib/components/shared-components/map/map.svelte index 83ea3016fd48e..4f60131d69652 100644 --- a/web/src/lib/components/shared-components/map/map.svelte +++ b/web/src/lib/components/shared-components/map/map.svelte @@ -6,8 +6,8 @@ import Icon from '$lib/components/elements/icon.svelte'; import { Theme } from '$lib/constants'; import { colorTheme, mapSettings } from '$lib/stores/preferences.store'; - import { getAssetThumbnailUrl, getKey, handlePromiseError } from '$lib/utils'; - import { getMapStyle, MapTheme, type MapMarkerResponseDto } from '@immich/sdk'; + import { getAssetThumbnailUrl, handlePromiseError } from '$lib/utils'; + import { getServerConfig, type MapMarkerResponseDto } from '@immich/sdk'; import mapboxRtlUrl from '@mapbox/mapbox-gl-rtl-text?url'; import { mdiCog, mdiMap, mdiMapMarker } from '@mdi/js'; import type { Feature, GeoJsonProperties, Geometry, Point } from 'geojson'; @@ -57,11 +57,13 @@ let map: maplibregl.Map; let marker: maplibregl.Marker | null = null; - $: style = (() => - getMapStyle({ - theme: ($mapSettings.allowDarkMode ? $colorTheme.value : Theme.LIGHT) as unknown as MapTheme, - key: getKey(), - }) as Promise)(); + $: style = (async () => { + const config = await getServerConfig(); + const theme = $mapSettings.allowDarkMode ? $colorTheme.value : Theme.LIGHT; + const styleUrl = theme === Theme.DARK ? config.mapDarkStyleUrl : config.mapLightStyleUrl; + const style = await fetch(styleUrl).then((response) => response.json()); + return style as StyleSpecification; + })(); function handleAssetClick(assetId: string, map: Map | null) { if (!map) { diff --git a/web/src/lib/stores/server-config.store.ts b/web/src/lib/stores/server-config.store.ts index 14d1e4e66e895..358765fe0b111 100644 --- a/web/src/lib/stores/server-config.store.ts +++ b/web/src/lib/stores/server-config.store.ts @@ -32,6 +32,8 @@ export const serverConfig = writable({ isInitialized: false, isOnboarded: false, externalDomain: '', + mapDarkStyleUrl: '', + mapLightStyleUrl: '', }); export const retrieveServerConfig = async () => { From ec32a9e6109342bcb9871eac0fa22bb055cfb3af Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Tue, 24 Sep 2024 04:03:59 +0200 Subject: [PATCH 078/123] fix: set min values for face detection to reasonable values (#12877) fix: set min values for face detection to >0 --- mobile/openapi/lib/model/facial_recognition_config.dart | 4 ++-- open-api/immich-openapi-specs.json | 4 ++-- server/src/dtos/model-config.dto.ts | 4 ++-- .../machine-learning-settings.svelte | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/mobile/openapi/lib/model/facial_recognition_config.dart b/mobile/openapi/lib/model/facial_recognition_config.dart index 4acfd4e20ff17..439efbbfaeeb1 100644 --- a/mobile/openapi/lib/model/facial_recognition_config.dart +++ b/mobile/openapi/lib/model/facial_recognition_config.dart @@ -22,14 +22,14 @@ class FacialRecognitionConfig { bool enabled; - /// Minimum value: 0 + /// Minimum value: 0.1 /// Maximum value: 2 double maxDistance; /// Minimum value: 1 int minFaces; - /// Minimum value: 0 + /// Minimum value: 0.1 /// Maximum value: 1 double minScore; diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 4e7c7119781fe..99ea313063fce 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -9119,7 +9119,7 @@ "maxDistance": { "format": "double", "maximum": 2, - "minimum": 0, + "minimum": 0.1, "type": "number" }, "minFaces": { @@ -9129,7 +9129,7 @@ "minScore": { "format": "double", "maximum": 1, - "minimum": 0, + "minimum": 0.1, "type": "number" }, "modelName": { diff --git a/server/src/dtos/model-config.dto.ts b/server/src/dtos/model-config.dto.ts index dffacc793d57b..f8b9e2043f3ea 100644 --- a/server/src/dtos/model-config.dto.ts +++ b/server/src/dtos/model-config.dto.ts @@ -27,14 +27,14 @@ export class DuplicateDetectionConfig extends TaskConfig { export class FacialRecognitionConfig extends ModelConfig { @IsNumber() - @Min(0) + @Min(0.1) @Max(1) @Type(() => Number) @ApiProperty({ type: 'number', format: 'double' }) minScore!: number; @IsNumber() - @Min(0) + @Min(0.1) @Max(2) @Type(() => Number) @ApiProperty({ type: 'number', format: 'double' }) diff --git a/web/src/lib/components/admin-page/settings/machine-learning-settings/machine-learning-settings.svelte b/web/src/lib/components/admin-page/settings/machine-learning-settings/machine-learning-settings.svelte index 05a5224bd022b..aac8cd52123be 100644 --- a/web/src/lib/components/admin-page/settings/machine-learning-settings/machine-learning-settings.svelte +++ b/web/src/lib/components/admin-page/settings/machine-learning-settings/machine-learning-settings.svelte @@ -145,7 +145,7 @@ desc={$t('admin.machine_learning_min_detection_score_description')} bind:value={config.machineLearning.facialRecognition.minScore} step="0.1" - min={0} + min={0.1} max={1} disabled={disabled || !config.machineLearning.enabled || !config.machineLearning.facialRecognition.enabled} isEdited={config.machineLearning.facialRecognition.minScore !== @@ -158,7 +158,7 @@ desc={$t('admin.machine_learning_max_recognition_distance_description')} bind:value={config.machineLearning.facialRecognition.maxDistance} step="0.1" - min={0} + min={0.1} max={2} disabled={disabled || !config.machineLearning.enabled || !config.machineLearning.facialRecognition.enabled} isEdited={config.machineLearning.facialRecognition.maxDistance !== From 56f680ce04506f7969104a8866eaca330602af3a Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 23 Sep 2024 22:05:04 -0400 Subject: [PATCH 079/123] chore(deps): update typescript-projects (#12882) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- cli/package-lock.json | 200 ++++++------- docs/package-lock.json | 6 +- e2e/package-lock.json | 424 +++++++++++++-------------- server/package-lock.json | 607 ++++++++++++++++++++------------------- web/package-lock.json | 258 ++++++++--------- 5 files changed, 718 insertions(+), 777 deletions(-) diff --git a/cli/package-lock.json b/cli/package-lock.json index f74e86a385095..6e148fbe09a13 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -1353,17 +1353,17 @@ "dev": true }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.5.0.tgz", - "integrity": "sha512-lHS5hvz33iUFQKuPFGheAB84LwcJ60G8vKnEhnfcK1l8kGVLro2SFYW6K0/tj8FUhRJ0VHyg1oAfg50QGbPPHw==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.6.0.tgz", + "integrity": "sha512-UOaz/wFowmoh2G6Mr9gw60B1mm0MzUtm6Ic8G2yM1Le6gyj5Loi/N+O5mocugRGY+8OeeKmkMmbxNqUCq3B4Sg==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.5.0", - "@typescript-eslint/type-utils": "8.5.0", - "@typescript-eslint/utils": "8.5.0", - "@typescript-eslint/visitor-keys": "8.5.0", + "@typescript-eslint/scope-manager": "8.6.0", + "@typescript-eslint/type-utils": "8.6.0", + "@typescript-eslint/utils": "8.6.0", + "@typescript-eslint/visitor-keys": "8.6.0", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", @@ -1387,16 +1387,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.5.0.tgz", - "integrity": "sha512-gF77eNv0Xz2UJg/NbpWJ0kqAm35UMsvZf1GHj8D9MRFTj/V3tAciIWXfmPLsAAF/vUlpWPvUDyH1jjsr0cMVWw==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.6.0.tgz", + "integrity": "sha512-eQcbCuA2Vmw45iGfcyG4y6rS7BhWfz9MQuk409WD47qMM+bKCGQWXxvoOs1DUp+T7UBMTtRTVT+kXr7Sh4O9Ow==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "@typescript-eslint/scope-manager": "8.5.0", - "@typescript-eslint/types": "8.5.0", - "@typescript-eslint/typescript-estree": "8.5.0", - "@typescript-eslint/visitor-keys": "8.5.0", + "@typescript-eslint/scope-manager": "8.6.0", + "@typescript-eslint/types": "8.6.0", + "@typescript-eslint/typescript-estree": "8.6.0", + "@typescript-eslint/visitor-keys": "8.6.0", "debug": "^4.3.4" }, "engines": { @@ -1416,14 +1416,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.5.0.tgz", - "integrity": "sha512-06JOQ9Qgj33yvBEx6tpC8ecP9o860rsR22hWMEd12WcTRrfaFgHr2RB/CA/B+7BMhHkXT4chg2MyboGdFGawYg==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.6.0.tgz", + "integrity": "sha512-ZuoutoS5y9UOxKvpc/GkvF4cuEmpokda4wRg64JEia27wX+PysIE9q+lzDtlHHgblwUWwo5/Qn+/WyTUvDwBHw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.5.0", - "@typescript-eslint/visitor-keys": "8.5.0" + "@typescript-eslint/types": "8.6.0", + "@typescript-eslint/visitor-keys": "8.6.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1434,14 +1434,14 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.5.0.tgz", - "integrity": "sha512-N1K8Ix+lUM+cIDhL2uekVn/ZD7TZW+9/rwz8DclQpcQ9rk4sIL5CAlBC0CugWKREmDjBzI/kQqU4wkg46jWLYA==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.6.0.tgz", + "integrity": "sha512-dtePl4gsuenXVwC7dVNlb4mGDcKjDT/Ropsk4za/ouMBPplCLyznIaR+W65mvCvsyS97dymoBRrioEXI7k0XIg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "8.5.0", - "@typescript-eslint/utils": "8.5.0", + "@typescript-eslint/typescript-estree": "8.6.0", + "@typescript-eslint/utils": "8.6.0", "debug": "^4.3.4", "ts-api-utils": "^1.3.0" }, @@ -1459,9 +1459,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.5.0.tgz", - "integrity": "sha512-qjkormnQS5wF9pjSi6q60bKUHH44j2APxfh9TQRXK8wbYVeDYYdYJGIROL87LGZZ2gz3Rbmjc736qyL8deVtdw==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.6.0.tgz", + "integrity": "sha512-rojqFZGd4MQxw33SrOy09qIDS8WEldM8JWtKQLAjf/X5mGSeEFh5ixQlxssMNyPslVIk9yzWqXCsV2eFhYrYUw==", "dev": true, "license": "MIT", "engines": { @@ -1473,14 +1473,14 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.5.0.tgz", - "integrity": "sha512-vEG2Sf9P8BPQ+d0pxdfndw3xIXaoSjliG0/Ejk7UggByZPKXmJmw3GW5jV2gHNQNawBUyfahoSiCFVov0Ruf7Q==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.6.0.tgz", + "integrity": "sha512-MOVAzsKJIPIlLK239l5s06YXjNqpKTVhBVDnqUumQJja5+Y94V3+4VUFRA0G60y2jNnTVwRCkhyGQpavfsbq/g==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "@typescript-eslint/types": "8.5.0", - "@typescript-eslint/visitor-keys": "8.5.0", + "@typescript-eslint/types": "8.6.0", + "@typescript-eslint/visitor-keys": "8.6.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", @@ -1502,16 +1502,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.5.0.tgz", - "integrity": "sha512-6yyGYVL0e+VzGYp60wvkBHiqDWOpT63pdMV2CVG4LVDd5uR6q1qQN/7LafBZtAtNIn/mqXjsSeS5ggv/P0iECw==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.6.0.tgz", + "integrity": "sha512-eNp9cWnYf36NaOVjkEUznf6fEgVy1TWpE0o52e4wtojjBx7D1UV2WAWGzR+8Y5lVFtpMLPwNbC67T83DWSph4A==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.5.0", - "@typescript-eslint/types": "8.5.0", - "@typescript-eslint/typescript-estree": "8.5.0" + "@typescript-eslint/scope-manager": "8.6.0", + "@typescript-eslint/types": "8.6.0", + "@typescript-eslint/typescript-estree": "8.6.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1525,13 +1525,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.5.0.tgz", - "integrity": "sha512-yTPqMnbAZJNy2Xq2XU8AdtOW9tJIr+UQb64aXB9f3B1498Zx9JorVgFJcZpEc9UBuCCrdzKID2RGAMkYcDtZOw==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.6.0.tgz", + "integrity": "sha512-wapVFfZg9H0qOYh4grNVQiMklJGluQrOUiOhYRrQWhx7BY/+I1IYb8BczWNbbUpO+pqy0rDciv3lQH5E1bCLrg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.5.0", + "@typescript-eslint/types": "8.6.0", "eslint-visitor-keys": "^3.4.3" }, "engines": { @@ -1543,9 +1543,9 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.1.0.tgz", - "integrity": "sha512-yqCkr2nrV4o58VcVMxTVkS6Ggxzy7pmSD8JbTbhbH5PsQfUIES1QT716VUzo33wf2lX9EcWYdT3Vl2MMmjR59g==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.1.1.tgz", + "integrity": "sha512-md/A7A3c42oTT8JUHSqjP5uKTWJejzUW4jalpvs+rZ27gsURsMU8DEb+8Jf8C6Kj2gwfSHJqobDNBuoqlm0cFw==", "dev": true, "license": "MIT", "dependencies": { @@ -1566,8 +1566,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "2.1.0", - "vitest": "2.1.0" + "@vitest/browser": "2.1.1", + "vitest": "2.1.1" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -1576,14 +1576,14 @@ } }, "node_modules/@vitest/expect": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.0.tgz", - "integrity": "sha512-N3/xR4fSu0+6sVZETEtPT1orUs2+Y477JOXTcU3xKuu3uBlsgbD7/7Mz2LZ1Jr1XjwilEWlrIgSCj4N1+5ZmsQ==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.1.tgz", + "integrity": "sha512-YeueunS0HiHiQxk+KEOnq/QMzlUuOzbU1Go+PgAsHvvv3tUkJPm9xWt+6ITNTlzsMXUjmgm5T+U7KBPK2qQV6w==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "2.1.0", - "@vitest/utils": "2.1.0", + "@vitest/spy": "2.1.1", + "@vitest/utils": "2.1.1", "chai": "^5.1.1", "tinyrainbow": "^1.2.0" }, @@ -1592,9 +1592,9 @@ } }, "node_modules/@vitest/mocker": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.0.tgz", - "integrity": "sha512-ZxENovUqhzl+QiOFpagiHUNUuZ1qPd5yYTCYHomGIZOFArzn4mgX2oxZmiAItJWAaXHG6bbpb/DpSPhlk5DgtA==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.1.tgz", + "integrity": "sha512-LNN5VwOEdJqCmJ/2XJBywB11DLlkbY0ooDJW3uRX5cZyYCrc4PI/ePX0iQhE3BiEGiQmK4GE7Q/PqCkkaiPnrA==", "dev": true, "license": "MIT", "dependencies": { @@ -1606,7 +1606,7 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/spy": "2.1.0", + "@vitest/spy": "2.1.1", "msw": "^2.3.5", "vite": "^5.0.0" }, @@ -1633,13 +1633,13 @@ } }, "node_modules/@vitest/runner": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.0.tgz", - "integrity": "sha512-D9+ZiB8MbMt7qWDRJc4CRNNUlne/8E1X7dcKhZVAbcOKG58MGGYVDqAq19xlhNfMFZsW0bpVKgztBwks38Ko0w==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.1.tgz", + "integrity": "sha512-uTPuY6PWOYitIkLPidaY5L3t0JJITdGTSwBtwMjKzo5O6RCOEncz9PUN+0pDidX8kTHYjO0EwUIvhlGpnGpxmA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "2.1.0", + "@vitest/utils": "2.1.1", "pathe": "^1.1.2" }, "funding": { @@ -1647,13 +1647,13 @@ } }, "node_modules/@vitest/snapshot": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.0.tgz", - "integrity": "sha512-x69CygGMzt9VCO283K2/FYQ+nBrOj66OTKpsPykjCR4Ac3lLV+m85hj9reaIGmjBSsKzVvbxWmjWE3kF5ha3uQ==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.1.tgz", + "integrity": "sha512-BnSku1WFy7r4mm96ha2FzN99AZJgpZOWrAhtQfoxjUU5YMRpq1zmHRq7a5K9/NjqonebO7iVDla+VvZS8BOWMw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "2.1.0", + "@vitest/pretty-format": "2.1.1", "magic-string": "^0.30.11", "pathe": "^1.1.2" }, @@ -1661,23 +1661,10 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitest/snapshot/node_modules/@vitest/pretty-format": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.0.tgz", - "integrity": "sha512-7sxf2F3DNYatgmzXXcTh6cq+/fxwB47RIQqZJFoSH883wnVAoccSRT6g+dTKemUBo8Q5N4OYYj1EBXLuRKvp3Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^1.2.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, "node_modules/@vitest/spy": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.0.tgz", - "integrity": "sha512-IXX5NkbdgTYTog3F14i2LgnBc+20YmkXMx0IWai84mcxySUDRgm0ihbOfR4L0EVRBDFG85GjmQQEZNNKVVpkZw==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.1.tgz", + "integrity": "sha512-ZM39BnZ9t/xZ/nF4UwRH5il0Sw93QnZXd9NAZGRpIgj0yvVwPpLd702s/Cx955rGaMlyBQkZJ2Ir7qyY48VZ+g==", "dev": true, "license": "MIT", "dependencies": { @@ -1688,13 +1675,13 @@ } }, "node_modules/@vitest/utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.0.tgz", - "integrity": "sha512-rreyfVe0PuNqJfKYUwfPDfi6rrp0VSu0Wgvp5WBqJonP+4NvXHk48X6oBam1Lj47Hy6jbJtnMj3OcRdrkTP0tA==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.1.tgz", + "integrity": "sha512-Y6Q9TsI+qJ2CC0ZKj6VBb+T8UPz593N113nnUykqwANqhgf3QkZeHFlusgKLTqrnVHbj/XDKZcDHol+dxVT+rQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "2.1.0", + "@vitest/pretty-format": "2.1.1", "loupe": "^3.1.1", "tinyrainbow": "^1.2.0" }, @@ -1702,19 +1689,6 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitest/utils/node_modules/@vitest/pretty-format": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.0.tgz", - "integrity": "sha512-7sxf2F3DNYatgmzXXcTh6cq+/fxwB47RIQqZJFoSH883wnVAoccSRT6g+dTKemUBo8Q5N4OYYj1EBXLuRKvp3Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^1.2.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, "node_modules/acorn": { "version": "8.12.1", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz", @@ -4241,9 +4215,9 @@ } }, "node_modules/vite-node": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.0.tgz", - "integrity": "sha512-+ybYqBVUjYyIscoLzMWodus2enQDZOpGhcU6HdOVD6n8WZdk12w1GFL3mbnxLs7hPtRtqs1Wo5YF6/Tsr6fmhg==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.1.tgz", + "integrity": "sha512-N/mGckI1suG/5wQI35XeR9rsMsPqKXzq1CdUndzVstBj/HvyxxGctwnK6WX43NGt5L3Z5tcRf83g4TITKJhPrA==", "dev": true, "license": "MIT", "dependencies": { @@ -4283,19 +4257,19 @@ } }, "node_modules/vitest": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.0.tgz", - "integrity": "sha512-XuuEeyNkqbfr0FtAvd9vFbInSSNY1ykCQTYQ0sj9wPy4hx+1gR7gqVNdW0AX2wrrM1wWlN5fnJDjF9xG6mYRSQ==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.1.tgz", + "integrity": "sha512-97We7/VC0e9X5zBVkvt7SGQMGrRtn3KtySFQG5fpaMlS+l62eeXRQO633AYhSTC3z7IMebnPPNjGXVGNRFlxBA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "2.1.0", - "@vitest/mocker": "2.1.0", - "@vitest/pretty-format": "^2.1.0", - "@vitest/runner": "2.1.0", - "@vitest/snapshot": "2.1.0", - "@vitest/spy": "2.1.0", - "@vitest/utils": "2.1.0", + "@vitest/expect": "2.1.1", + "@vitest/mocker": "2.1.1", + "@vitest/pretty-format": "^2.1.1", + "@vitest/runner": "2.1.1", + "@vitest/snapshot": "2.1.1", + "@vitest/spy": "2.1.1", + "@vitest/utils": "2.1.1", "chai": "^5.1.1", "debug": "^4.3.6", "magic-string": "^0.30.11", @@ -4306,7 +4280,7 @@ "tinypool": "^1.0.0", "tinyrainbow": "^1.2.0", "vite": "^5.0.0", - "vite-node": "2.1.0", + "vite-node": "2.1.1", "why-is-node-running": "^2.3.0" }, "bin": { @@ -4321,8 +4295,8 @@ "peerDependencies": { "@edge-runtime/vm": "*", "@types/node": "^18.0.0 || >=20.0.0", - "@vitest/browser": "2.1.0", - "@vitest/ui": "2.1.0", + "@vitest/browser": "2.1.1", + "@vitest/ui": "2.1.1", "happy-dom": "*", "jsdom": "*" }, diff --git a/docs/package-lock.json b/docs/package-lock.json index 5f14d39ac7ab3..3b4e6c4f9546a 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -16091,9 +16091,9 @@ } }, "node_modules/tailwindcss": { - "version": "3.4.11", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.11.tgz", - "integrity": "sha512-qhEuBcLemjSJk5ajccN9xJFtM/h0AVCPaA6C92jNP+M2J8kX+eMJHI7R2HFKUvvAsMpcfLILMCFYSeDwpMmlUg==", + "version": "3.4.12", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.12.tgz", + "integrity": "sha512-Htf/gHj2+soPb9UayUNci/Ja3d8pTmu9ONTfh4QY8r3MATTZOzmv6UYWF7ZwikEIC8okpfqmGqrmDehua8mF8w==", "license": "MIT", "dependencies": { "@alloc/quick-lru": "^5.2.0", diff --git a/e2e/package-lock.json b/e2e/package-lock.json index ab4fd53fbf2ef..73c6ac61753e9 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -1149,13 +1149,13 @@ } }, "node_modules/@playwright/test": { - "version": "1.47.0", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.47.0.tgz", - "integrity": "sha512-SgAdlSwYVpToI4e/IH19IHHWvoijAYH5hu2MWSXptRypLSnzj51PcGD+rsOXFayde4P9ZLi+loXVwArg6IUkCA==", + "version": "1.47.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.47.1.tgz", + "integrity": "sha512-dbWpcNQZ5nj16m+A5UNScYx7HX5trIy7g4phrcitn+Nk83S32EBX/CLU4hiF4RGKX/yRc93AAqtfaXB7JWBd4Q==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.47.0" + "playwright": "1.47.1" }, "bin": { "playwright": "cli.js" @@ -1165,9 +1165,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.21.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.21.3.tgz", - "integrity": "sha512-MmKSfaB9GX+zXl6E8z4koOr/xU63AMVleLEa64v7R0QF/ZloMs5vcD1sHgM64GXXS1csaJutG+ddtzcueI/BLg==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.22.4.tgz", + "integrity": "sha512-Fxamp4aEZnfPOcGA8KSNEohV8hX7zVHOemC8jVBoBUHu5zpJK/Eu3uJwt6BMgy9fkvzxDaurgj96F/NiLukF2w==", "cpu": [ "arm" ], @@ -1179,9 +1179,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.21.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.21.3.tgz", - "integrity": "sha512-zrt8ecH07PE3sB4jPOggweBjJMzI1JG5xI2DIsUbkA+7K+Gkjys6eV7i9pOenNSDJH3eOr/jLb/PzqtmdwDq5g==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.22.4.tgz", + "integrity": "sha512-VXoK5UMrgECLYaMuGuVTOx5kcuap1Jm8g/M83RnCHBKOqvPPmROFJGQaZhGccnsFtfXQ3XYa4/jMCJvZnbJBdA==", "cpu": [ "arm64" ], @@ -1193,9 +1193,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.21.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.21.3.tgz", - "integrity": "sha512-P0UxIOrKNBFTQaXTxOH4RxuEBVCgEA5UTNV6Yz7z9QHnUJ7eLX9reOd/NYMO3+XZO2cco19mXTxDMXxit4R/eQ==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.22.4.tgz", + "integrity": "sha512-xMM9ORBqu81jyMKCDP+SZDhnX2QEVQzTcC6G18KlTQEzWK8r/oNZtKuZaCcHhnsa6fEeOBionoyl5JsAbE/36Q==", "cpu": [ "arm64" ], @@ -1207,9 +1207,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.21.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.21.3.tgz", - "integrity": "sha512-L1M0vKGO5ASKntqtsFEjTq/fD91vAqnzeaF6sfNAy55aD+Hi2pBI5DKwCO+UNDQHWsDViJLqshxOahXyLSh3EA==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.22.4.tgz", + "integrity": "sha512-aJJyYKQwbHuhTUrjWjxEvGnNNBCnmpHDvrb8JFDbeSH3m2XdHcxDd3jthAzvmoI8w/kSjd2y0udT+4okADsZIw==", "cpu": [ "x64" ], @@ -1221,9 +1221,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.21.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.21.3.tgz", - "integrity": "sha512-btVgIsCjuYFKUjopPoWiDqmoUXQDiW2A4C3Mtmp5vACm7/GnyuprqIDPNczeyR5W8rTXEbkmrJux7cJmD99D2g==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.22.4.tgz", + "integrity": "sha512-j63YtCIRAzbO+gC2L9dWXRh5BFetsv0j0va0Wi9epXDgU/XUi5dJKo4USTttVyK7fGw2nPWK0PbAvyliz50SCQ==", "cpu": [ "arm" ], @@ -1235,9 +1235,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.21.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.21.3.tgz", - "integrity": "sha512-zmjbSphplZlau6ZTkxd3+NMtE4UKVy7U4aVFMmHcgO5CUbw17ZP6QCgyxhzGaU/wFFdTfiojjbLG3/0p9HhAqA==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.22.4.tgz", + "integrity": "sha512-dJnWUgwWBX1YBRsuKKMOlXCzh2Wu1mlHzv20TpqEsfdZLb3WoJW2kIEsGwLkroYf24IrPAvOT/ZQ2OYMV6vlrg==", "cpu": [ "arm" ], @@ -1249,9 +1249,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.21.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.21.3.tgz", - "integrity": "sha512-nSZfcZtAnQPRZmUkUQwZq2OjQciR6tEoJaZVFvLHsj0MF6QhNMg0fQ6mUOsiCUpTqxTx0/O6gX0V/nYc7LrgPw==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.22.4.tgz", + "integrity": "sha512-AdPRoNi3NKVLolCN/Sp4F4N1d98c4SBnHMKoLuiG6RXgoZ4sllseuGioszumnPGmPM2O7qaAX/IJdeDU8f26Aw==", "cpu": [ "arm64" ], @@ -1263,9 +1263,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.21.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.21.3.tgz", - "integrity": "sha512-MnvSPGO8KJXIMGlQDYfvYS3IosFN2rKsvxRpPO2l2cum+Z3exiExLwVU+GExL96pn8IP+GdH8Tz70EpBhO0sIQ==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.22.4.tgz", + "integrity": "sha512-Gl0AxBtDg8uoAn5CCqQDMqAx22Wx22pjDOjBdmG0VIWX3qUBHzYmOKh8KXHL4UpogfJ14G4wk16EQogF+v8hmA==", "cpu": [ "arm64" ], @@ -1277,9 +1277,9 @@ ] }, "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.21.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.21.3.tgz", - "integrity": "sha512-+W+p/9QNDr2vE2AXU0qIy0qQE75E8RTwTwgqS2G5CRQ11vzq0tbnfBd6brWhS9bCRjAjepJe2fvvkvS3dno+iw==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.22.4.tgz", + "integrity": "sha512-3aVCK9xfWW1oGQpTsYJJPF6bfpWfhbRnhdlyhak2ZiyFLDaayz0EP5j9V1RVLAAxlmWKTDfS9wyRyY3hvhPoOg==", "cpu": [ "ppc64" ], @@ -1291,9 +1291,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.21.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.21.3.tgz", - "integrity": "sha512-yXH6K6KfqGXaxHrtr+Uoy+JpNlUlI46BKVyonGiaD74ravdnF9BUNC+vV+SIuB96hUMGShhKV693rF9QDfO6nQ==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.22.4.tgz", + "integrity": "sha512-ePYIir6VYnhgv2C5Xe9u+ico4t8sZWXschR6fMgoPUK31yQu7hTEJb7bCqivHECwIClJfKgE7zYsh1qTP3WHUA==", "cpu": [ "riscv64" ], @@ -1305,9 +1305,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.21.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.21.3.tgz", - "integrity": "sha512-R8cwY9wcnApN/KDYWTH4gV/ypvy9yZUHlbJvfaiXSB48JO3KpwSpjOGqO4jnGkLDSk1hgjYkTbTt6Q7uvPf8eg==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.22.4.tgz", + "integrity": "sha512-GqFJ9wLlbB9daxhVlrTe61vJtEY99/xB3C8e4ULVsVfflcpmR6c8UZXjtkMA6FhNONhj2eA5Tk9uAVw5orEs4Q==", "cpu": [ "s390x" ], @@ -1319,9 +1319,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.21.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.21.3.tgz", - "integrity": "sha512-kZPbX/NOPh0vhS5sI+dR8L1bU2cSO9FgxwM8r7wHzGydzfSjLRCFAT87GR5U9scj2rhzN3JPYVC7NoBbl4FZ0g==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.22.4.tgz", + "integrity": "sha512-87v0ol2sH9GE3cLQLNEy0K/R0pz1nvg76o8M5nhMR0+Q+BBGLnb35P0fVz4CQxHYXaAOhE8HhlkaZfsdUOlHwg==", "cpu": [ "x64" ], @@ -1333,9 +1333,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.21.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.21.3.tgz", - "integrity": "sha512-S0Yq+xA1VEH66uiMNhijsWAafffydd2X5b77eLHfRmfLsRSpbiAWiRHV6DEpz6aOToPsgid7TI9rGd6zB1rhbg==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.22.4.tgz", + "integrity": "sha512-UV6FZMUgePDZrFjrNGIWzDo/vABebuXBhJEqrHxrGiU6HikPy0Z3LfdtciIttEUQfuDdCn8fqh7wiFJjCNwO+g==", "cpu": [ "x64" ], @@ -1347,9 +1347,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.21.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.21.3.tgz", - "integrity": "sha512-9isNzeL34yquCPyerog+IMCNxKR8XYmGd0tHSV+OVx0TmE0aJOo9uw4fZfUuk2qxobP5sug6vNdZR6u7Mw7Q+Q==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.22.4.tgz", + "integrity": "sha512-BjI+NVVEGAXjGWYHz/vv0pBqfGoUH0IGZ0cICTn7kB9PyjrATSkX+8WkguNjWoj2qSr1im/+tTGRaY+4/PdcQw==", "cpu": [ "arm64" ], @@ -1361,9 +1361,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.21.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.21.3.tgz", - "integrity": "sha512-nMIdKnfZfzn1Vsk+RuOvl43ONTZXoAPUUxgcU0tXooqg4YrAqzfKzVenqqk2g5efWh46/D28cKFrOzDSW28gTA==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.22.4.tgz", + "integrity": "sha512-SiWG/1TuUdPvYmzmYnmd3IEifzR61Tragkbx9D3+R8mzQqDBz8v+BvZNDlkiTtI9T15KYZhP0ehn3Dld4n9J5g==", "cpu": [ "ia32" ], @@ -1375,9 +1375,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.21.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.21.3.tgz", - "integrity": "sha512-fOvu7PCQjAj4eWDEuD8Xz5gpzFqXzGlxHZozHP4b9Jxv9APtdxL6STqztDzMLuRXEc4UpXGGhx029Xgm91QBeA==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.22.4.tgz", + "integrity": "sha512-j8pPKp53/lq9lMXN57S8cFz0MynJk8OWNuUnXct/9KCpKU7DgU3bYMJhwWmcqC0UU29p8Lr0/7KEVcaM6bf47Q==", "cpu": [ "x64" ], @@ -1471,9 +1471,9 @@ } }, "node_modules/@types/estree": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", - "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", + "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", "dev": true, "license": "MIT" }, @@ -1596,9 +1596,9 @@ } }, "node_modules/@types/pg": { - "version": "8.11.9", - "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.11.9.tgz", - "integrity": "sha512-M4mYeJZRBD9lCBCGa72F44uKSV9eJrAFfjlPJagdA6pgIr2OPJULFB7nqnZzOdqXG0qzHlgtZKzTdIgbmHitSg==", + "version": "8.11.10", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.11.10.tgz", + "integrity": "sha512-LczQUW4dbOQzsH2RQ5qoeJ6qJPdrcM/DcMLoqWQkMLMsq83J5lAX3LXjdkWdpscFy67JSOWDnh7Ny/sPFykmkg==", "dev": true, "license": "MIT", "dependencies": { @@ -1733,17 +1733,17 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.5.0.tgz", - "integrity": "sha512-lHS5hvz33iUFQKuPFGheAB84LwcJ60G8vKnEhnfcK1l8kGVLro2SFYW6K0/tj8FUhRJ0VHyg1oAfg50QGbPPHw==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.6.0.tgz", + "integrity": "sha512-UOaz/wFowmoh2G6Mr9gw60B1mm0MzUtm6Ic8G2yM1Le6gyj5Loi/N+O5mocugRGY+8OeeKmkMmbxNqUCq3B4Sg==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.5.0", - "@typescript-eslint/type-utils": "8.5.0", - "@typescript-eslint/utils": "8.5.0", - "@typescript-eslint/visitor-keys": "8.5.0", + "@typescript-eslint/scope-manager": "8.6.0", + "@typescript-eslint/type-utils": "8.6.0", + "@typescript-eslint/utils": "8.6.0", + "@typescript-eslint/visitor-keys": "8.6.0", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", @@ -1767,16 +1767,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.5.0.tgz", - "integrity": "sha512-gF77eNv0Xz2UJg/NbpWJ0kqAm35UMsvZf1GHj8D9MRFTj/V3tAciIWXfmPLsAAF/vUlpWPvUDyH1jjsr0cMVWw==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.6.0.tgz", + "integrity": "sha512-eQcbCuA2Vmw45iGfcyG4y6rS7BhWfz9MQuk409WD47qMM+bKCGQWXxvoOs1DUp+T7UBMTtRTVT+kXr7Sh4O9Ow==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "@typescript-eslint/scope-manager": "8.5.0", - "@typescript-eslint/types": "8.5.0", - "@typescript-eslint/typescript-estree": "8.5.0", - "@typescript-eslint/visitor-keys": "8.5.0", + "@typescript-eslint/scope-manager": "8.6.0", + "@typescript-eslint/types": "8.6.0", + "@typescript-eslint/typescript-estree": "8.6.0", + "@typescript-eslint/visitor-keys": "8.6.0", "debug": "^4.3.4" }, "engines": { @@ -1796,14 +1796,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.5.0.tgz", - "integrity": "sha512-06JOQ9Qgj33yvBEx6tpC8ecP9o860rsR22hWMEd12WcTRrfaFgHr2RB/CA/B+7BMhHkXT4chg2MyboGdFGawYg==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.6.0.tgz", + "integrity": "sha512-ZuoutoS5y9UOxKvpc/GkvF4cuEmpokda4wRg64JEia27wX+PysIE9q+lzDtlHHgblwUWwo5/Qn+/WyTUvDwBHw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.5.0", - "@typescript-eslint/visitor-keys": "8.5.0" + "@typescript-eslint/types": "8.6.0", + "@typescript-eslint/visitor-keys": "8.6.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1814,14 +1814,14 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.5.0.tgz", - "integrity": "sha512-N1K8Ix+lUM+cIDhL2uekVn/ZD7TZW+9/rwz8DclQpcQ9rk4sIL5CAlBC0CugWKREmDjBzI/kQqU4wkg46jWLYA==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.6.0.tgz", + "integrity": "sha512-dtePl4gsuenXVwC7dVNlb4mGDcKjDT/Ropsk4za/ouMBPplCLyznIaR+W65mvCvsyS97dymoBRrioEXI7k0XIg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "8.5.0", - "@typescript-eslint/utils": "8.5.0", + "@typescript-eslint/typescript-estree": "8.6.0", + "@typescript-eslint/utils": "8.6.0", "debug": "^4.3.4", "ts-api-utils": "^1.3.0" }, @@ -1839,9 +1839,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.5.0.tgz", - "integrity": "sha512-qjkormnQS5wF9pjSi6q60bKUHH44j2APxfh9TQRXK8wbYVeDYYdYJGIROL87LGZZ2gz3Rbmjc736qyL8deVtdw==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.6.0.tgz", + "integrity": "sha512-rojqFZGd4MQxw33SrOy09qIDS8WEldM8JWtKQLAjf/X5mGSeEFh5ixQlxssMNyPslVIk9yzWqXCsV2eFhYrYUw==", "dev": true, "license": "MIT", "engines": { @@ -1853,14 +1853,14 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.5.0.tgz", - "integrity": "sha512-vEG2Sf9P8BPQ+d0pxdfndw3xIXaoSjliG0/Ejk7UggByZPKXmJmw3GW5jV2gHNQNawBUyfahoSiCFVov0Ruf7Q==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.6.0.tgz", + "integrity": "sha512-MOVAzsKJIPIlLK239l5s06YXjNqpKTVhBVDnqUumQJja5+Y94V3+4VUFRA0G60y2jNnTVwRCkhyGQpavfsbq/g==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "@typescript-eslint/types": "8.5.0", - "@typescript-eslint/visitor-keys": "8.5.0", + "@typescript-eslint/types": "8.6.0", + "@typescript-eslint/visitor-keys": "8.6.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", @@ -1908,16 +1908,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.5.0.tgz", - "integrity": "sha512-6yyGYVL0e+VzGYp60wvkBHiqDWOpT63pdMV2CVG4LVDd5uR6q1qQN/7LafBZtAtNIn/mqXjsSeS5ggv/P0iECw==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.6.0.tgz", + "integrity": "sha512-eNp9cWnYf36NaOVjkEUznf6fEgVy1TWpE0o52e4wtojjBx7D1UV2WAWGzR+8Y5lVFtpMLPwNbC67T83DWSph4A==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.5.0", - "@typescript-eslint/types": "8.5.0", - "@typescript-eslint/typescript-estree": "8.5.0" + "@typescript-eslint/scope-manager": "8.6.0", + "@typescript-eslint/types": "8.6.0", + "@typescript-eslint/typescript-estree": "8.6.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1931,13 +1931,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.5.0.tgz", - "integrity": "sha512-yTPqMnbAZJNy2Xq2XU8AdtOW9tJIr+UQb64aXB9f3B1498Zx9JorVgFJcZpEc9UBuCCrdzKID2RGAMkYcDtZOw==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.6.0.tgz", + "integrity": "sha512-wapVFfZg9H0qOYh4grNVQiMklJGluQrOUiOhYRrQWhx7BY/+I1IYb8BczWNbbUpO+pqy0rDciv3lQH5E1bCLrg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.5.0", + "@typescript-eslint/types": "8.6.0", "eslint-visitor-keys": "^3.4.3" }, "engines": { @@ -1949,9 +1949,9 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.1.0.tgz", - "integrity": "sha512-yqCkr2nrV4o58VcVMxTVkS6Ggxzy7pmSD8JbTbhbH5PsQfUIES1QT716VUzo33wf2lX9EcWYdT3Vl2MMmjR59g==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.1.1.tgz", + "integrity": "sha512-md/A7A3c42oTT8JUHSqjP5uKTWJejzUW4jalpvs+rZ27gsURsMU8DEb+8Jf8C6Kj2gwfSHJqobDNBuoqlm0cFw==", "dev": true, "license": "MIT", "dependencies": { @@ -1972,8 +1972,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "2.1.0", - "vitest": "2.1.0" + "@vitest/browser": "2.1.1", + "vitest": "2.1.1" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -1982,14 +1982,14 @@ } }, "node_modules/@vitest/expect": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.0.tgz", - "integrity": "sha512-N3/xR4fSu0+6sVZETEtPT1orUs2+Y477JOXTcU3xKuu3uBlsgbD7/7Mz2LZ1Jr1XjwilEWlrIgSCj4N1+5ZmsQ==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.1.tgz", + "integrity": "sha512-YeueunS0HiHiQxk+KEOnq/QMzlUuOzbU1Go+PgAsHvvv3tUkJPm9xWt+6ITNTlzsMXUjmgm5T+U7KBPK2qQV6w==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "2.1.0", - "@vitest/utils": "2.1.0", + "@vitest/spy": "2.1.1", + "@vitest/utils": "2.1.1", "chai": "^5.1.1", "tinyrainbow": "^1.2.0" }, @@ -1998,9 +1998,9 @@ } }, "node_modules/@vitest/mocker": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.0.tgz", - "integrity": "sha512-ZxENovUqhzl+QiOFpagiHUNUuZ1qPd5yYTCYHomGIZOFArzn4mgX2oxZmiAItJWAaXHG6bbpb/DpSPhlk5DgtA==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.1.tgz", + "integrity": "sha512-LNN5VwOEdJqCmJ/2XJBywB11DLlkbY0ooDJW3uRX5cZyYCrc4PI/ePX0iQhE3BiEGiQmK4GE7Q/PqCkkaiPnrA==", "dev": true, "license": "MIT", "dependencies": { @@ -2012,7 +2012,7 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/spy": "2.1.0", + "@vitest/spy": "2.1.1", "msw": "^2.3.5", "vite": "^5.0.0" }, @@ -2039,13 +2039,13 @@ } }, "node_modules/@vitest/runner": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.0.tgz", - "integrity": "sha512-D9+ZiB8MbMt7qWDRJc4CRNNUlne/8E1X7dcKhZVAbcOKG58MGGYVDqAq19xlhNfMFZsW0bpVKgztBwks38Ko0w==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.1.tgz", + "integrity": "sha512-uTPuY6PWOYitIkLPidaY5L3t0JJITdGTSwBtwMjKzo5O6RCOEncz9PUN+0pDidX8kTHYjO0EwUIvhlGpnGpxmA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "2.1.0", + "@vitest/utils": "2.1.1", "pathe": "^1.1.2" }, "funding": { @@ -2053,13 +2053,13 @@ } }, "node_modules/@vitest/snapshot": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.0.tgz", - "integrity": "sha512-x69CygGMzt9VCO283K2/FYQ+nBrOj66OTKpsPykjCR4Ac3lLV+m85hj9reaIGmjBSsKzVvbxWmjWE3kF5ha3uQ==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.1.tgz", + "integrity": "sha512-BnSku1WFy7r4mm96ha2FzN99AZJgpZOWrAhtQfoxjUU5YMRpq1zmHRq7a5K9/NjqonebO7iVDla+VvZS8BOWMw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "2.1.0", + "@vitest/pretty-format": "2.1.1", "magic-string": "^0.30.11", "pathe": "^1.1.2" }, @@ -2067,23 +2067,10 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitest/snapshot/node_modules/@vitest/pretty-format": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.0.tgz", - "integrity": "sha512-7sxf2F3DNYatgmzXXcTh6cq+/fxwB47RIQqZJFoSH883wnVAoccSRT6g+dTKemUBo8Q5N4OYYj1EBXLuRKvp3Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^1.2.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, "node_modules/@vitest/spy": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.0.tgz", - "integrity": "sha512-IXX5NkbdgTYTog3F14i2LgnBc+20YmkXMx0IWai84mcxySUDRgm0ihbOfR4L0EVRBDFG85GjmQQEZNNKVVpkZw==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.1.tgz", + "integrity": "sha512-ZM39BnZ9t/xZ/nF4UwRH5il0Sw93QnZXd9NAZGRpIgj0yvVwPpLd702s/Cx955rGaMlyBQkZJ2Ir7qyY48VZ+g==", "dev": true, "license": "MIT", "dependencies": { @@ -2094,13 +2081,13 @@ } }, "node_modules/@vitest/utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.0.tgz", - "integrity": "sha512-rreyfVe0PuNqJfKYUwfPDfi6rrp0VSu0Wgvp5WBqJonP+4NvXHk48X6oBam1Lj47Hy6jbJtnMj3OcRdrkTP0tA==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.1.tgz", + "integrity": "sha512-Y6Q9TsI+qJ2CC0ZKj6VBb+T8UPz593N113nnUykqwANqhgf3QkZeHFlusgKLTqrnVHbj/XDKZcDHol+dxVT+rQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "2.1.0", + "@vitest/pretty-format": "2.1.1", "loupe": "^3.1.1", "tinyrainbow": "^1.2.0" }, @@ -2108,19 +2095,6 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitest/utils/node_modules/@vitest/pretty-format": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.0.tgz", - "integrity": "sha512-7sxf2F3DNYatgmzXXcTh6cq+/fxwB47RIQqZJFoSH883wnVAoccSRT6g+dTKemUBo8Q5N4OYYj1EBXLuRKvp3Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^1.2.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, "node_modules/abbrev": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", @@ -4168,9 +4142,9 @@ } }, "node_modules/jose": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/jose/-/jose-5.8.0.tgz", - "integrity": "sha512-E7CqYpL/t7MMnfGnK/eg416OsFCVUrU/Y3Vwe7QjKhu/BkS1Ms455+2xsqZQVN57/U2MHMBvEb5SrmAZWAIntA==", + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.9.2.tgz", + "integrity": "sha512-ILI2xx/I57b20sd7rHZvgiiQrmp2mcotwsAH+5ajbpFQbrYVQdNHYlQhoA5cFb78CgtBOxtC05TeA+mcgkuCqQ==", "dev": true, "license": "MIT", "funding": { @@ -5039,15 +5013,15 @@ } }, "node_modules/pg": { - "version": "8.12.0", - "resolved": "https://registry.npmjs.org/pg/-/pg-8.12.0.tgz", - "integrity": "sha512-A+LHUSnwnxrnL/tZ+OLfqR1SxLN3c/pgDztZ47Rpbsd4jUytsTtwQo/TLPRzPJMp/1pbhYVhH9cuSZLAajNfjQ==", + "version": "8.13.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.13.0.tgz", + "integrity": "sha512-34wkUTh3SxTClfoHB3pQ7bIMvw9dpFU1audQQeZG837fmHfHpr14n/AELVDoOYVDW2h5RDWU78tFjkD+erSBsw==", "dev": true, "license": "MIT", "dependencies": { - "pg-connection-string": "^2.6.4", - "pg-pool": "^3.6.2", - "pg-protocol": "^1.6.1", + "pg-connection-string": "^2.7.0", + "pg-pool": "^3.7.0", + "pg-protocol": "^1.7.0", "pg-types": "^2.1.0", "pgpass": "1.x" }, @@ -5074,10 +5048,11 @@ "optional": true }, "node_modules/pg-connection-string": { - "version": "2.6.4", - "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.6.4.tgz", - "integrity": "sha512-v+Z7W/0EO707aNMaAEfiGnGL9sxxumwLl2fJvCQtMn9Fxsg+lPpPkdcyBSv/KFgpGdYkMfn+EI1Or2EHjpgLCA==", - "dev": true + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.7.0.tgz", + "integrity": "sha512-PI2W9mv53rXJQEOb8xNR8lH7Hr+EKa6oJa38zsK0S/ky2er16ios1wLKhZyxzD7jUReiWokc9WK5nxSnC7W1TA==", + "dev": true, + "license": "MIT" }, "node_modules/pg-int8": { "version": "1.0.1", @@ -5098,19 +5073,21 @@ } }, "node_modules/pg-pool": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.6.2.tgz", - "integrity": "sha512-Htjbg8BlwXqSBQ9V8Vjtc+vzf/6fVUuak/3/XXKA9oxZprwW3IMDQTGHP+KDmVL7rtd+R1QjbnCFPuTHm3G4hg==", + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.7.0.tgz", + "integrity": "sha512-ZOBQForurqh4zZWjrgSwwAtzJ7QiRX0ovFkZr2klsen3Nm0aoh33Ls0fzfv3imeH/nw/O27cjdz5kzYJfeGp/g==", "dev": true, + "license": "MIT", "peerDependencies": { "pg": ">=8.0" } }, "node_modules/pg-protocol": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.6.1.tgz", - "integrity": "sha512-jPIlvgoD63hrEuihvIg+tJhoGjUsLPn6poJY9N5CnlPd91c2T18T/9zBtLxZSb1EhYxBRoZJtzScCaWlYLtktg==", - "dev": true + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.7.0.tgz", + "integrity": "sha512-hTK/mE36i8fDDhgDFjy6xNOG+LCorxLG3WO17tku+ij6sVHXh1jQUJ8hYAnRhNla4QVD2H8er/FOjc/+EgC6yQ==", + "dev": true, + "license": "MIT" }, "node_modules/pg-types": { "version": "2.2.0", @@ -5158,13 +5135,13 @@ } }, "node_modules/playwright": { - "version": "1.47.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.47.0.tgz", - "integrity": "sha512-jOWiRq2pdNAX/mwLiwFYnPHpEZ4rM+fRSQpRHwEwZlP2PUANvL3+aJOF/bvISMhFD30rqMxUB4RJx9aQbfh4Ww==", + "version": "1.47.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.47.1.tgz", + "integrity": "sha512-SUEKi6947IqYbKxRiqnbUobVZY4bF1uu+ZnZNJX9DfU1tlf2UhWfvVjLf01pQx9URsOr18bFVUKXmanYWhbfkw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.47.0" + "playwright-core": "1.47.1" }, "bin": { "playwright": "cli.js" @@ -5177,9 +5154,9 @@ } }, "node_modules/playwright-core": { - "version": "1.47.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.47.0.tgz", - "integrity": "sha512-1DyHT8OqkcfCkYUD9zzUTfg7EfTd+6a8MkD/NWOvjo0u/SCNd5YmY/lJwFvUZOxJbWNds+ei7ic2+R/cRz/PDg==", + "version": "1.47.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.47.1.tgz", + "integrity": "sha512-i1iyJdLftqtt51mEk6AhYFaAJCDx0xQ/O5NU8EKaWFgMjItPVma542Nh/Aq8aLCjIJSzjaiEQGW/nyqLkGF1OQ==", "dev": true, "license": "Apache-2.0", "bin": { @@ -5629,9 +5606,9 @@ } }, "node_modules/rollup": { - "version": "4.21.3", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.21.3.tgz", - "integrity": "sha512-7sqRtBNnEbcBtMeRVc6VRsJMmpI+JU1z9VTvW8D4gXIYQFz0aLcsE6rRkyghZkLfEgUZgVvOG7A5CVz/VW5GIA==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.22.4.tgz", + "integrity": "sha512-vD8HJ5raRcWOyymsR6Z3o6+RzfEPCnVLMFJ6vRslO1jt4LO6dUo5Qnpg7y4RkZFM2DMe3WUirkI5c16onjrc6A==", "dev": true, "license": "MIT", "dependencies": { @@ -5645,25 +5622,32 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.21.3", - "@rollup/rollup-android-arm64": "4.21.3", - "@rollup/rollup-darwin-arm64": "4.21.3", - "@rollup/rollup-darwin-x64": "4.21.3", - "@rollup/rollup-linux-arm-gnueabihf": "4.21.3", - "@rollup/rollup-linux-arm-musleabihf": "4.21.3", - "@rollup/rollup-linux-arm64-gnu": "4.21.3", - "@rollup/rollup-linux-arm64-musl": "4.21.3", - "@rollup/rollup-linux-powerpc64le-gnu": "4.21.3", - "@rollup/rollup-linux-riscv64-gnu": "4.21.3", - "@rollup/rollup-linux-s390x-gnu": "4.21.3", - "@rollup/rollup-linux-x64-gnu": "4.21.3", - "@rollup/rollup-linux-x64-musl": "4.21.3", - "@rollup/rollup-win32-arm64-msvc": "4.21.3", - "@rollup/rollup-win32-ia32-msvc": "4.21.3", - "@rollup/rollup-win32-x64-msvc": "4.21.3", + "@rollup/rollup-android-arm-eabi": "4.22.4", + "@rollup/rollup-android-arm64": "4.22.4", + "@rollup/rollup-darwin-arm64": "4.22.4", + "@rollup/rollup-darwin-x64": "4.22.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.22.4", + "@rollup/rollup-linux-arm-musleabihf": "4.22.4", + "@rollup/rollup-linux-arm64-gnu": "4.22.4", + "@rollup/rollup-linux-arm64-musl": "4.22.4", + "@rollup/rollup-linux-powerpc64le-gnu": "4.22.4", + "@rollup/rollup-linux-riscv64-gnu": "4.22.4", + "@rollup/rollup-linux-s390x-gnu": "4.22.4", + "@rollup/rollup-linux-x64-gnu": "4.22.4", + "@rollup/rollup-linux-x64-musl": "4.22.4", + "@rollup/rollup-win32-arm64-msvc": "4.22.4", + "@rollup/rollup-win32-ia32-msvc": "4.22.4", + "@rollup/rollup-win32-x64-msvc": "4.22.4", "fsevents": "~2.3.2" } }, + "node_modules/rollup/node_modules/@types/estree": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", + "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", + "dev": true, + "license": "MIT" + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -6403,9 +6387,9 @@ } }, "node_modules/vite": { - "version": "5.4.6", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.6.tgz", - "integrity": "sha512-IeL5f8OO5nylsgzd9tq4qD2QqI0k2CQLGrWD0rCN0EQJZpBK5vJAx0I+GDkMOXxQX/OfFHMuLIx6ddAxGX/k+Q==", + "version": "5.4.7", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.7.tgz", + "integrity": "sha512-5l2zxqMEPVENgvzTuBpHer2awaetimj2BGkhBPdnwKbPNOlHsODU+oiazEZzLK7KhAnOrO+XGYJYn4ZlUhDtDQ==", "dev": true, "license": "MIT", "dependencies": { @@ -6463,9 +6447,9 @@ } }, "node_modules/vite-node": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.0.tgz", - "integrity": "sha512-+ybYqBVUjYyIscoLzMWodus2enQDZOpGhcU6HdOVD6n8WZdk12w1GFL3mbnxLs7hPtRtqs1Wo5YF6/Tsr6fmhg==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.1.tgz", + "integrity": "sha512-N/mGckI1suG/5wQI35XeR9rsMsPqKXzq1CdUndzVstBj/HvyxxGctwnK6WX43NGt5L3Z5tcRf83g4TITKJhPrA==", "dev": true, "license": "MIT", "dependencies": { @@ -6500,19 +6484,19 @@ } }, "node_modules/vitest": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.0.tgz", - "integrity": "sha512-XuuEeyNkqbfr0FtAvd9vFbInSSNY1ykCQTYQ0sj9wPy4hx+1gR7gqVNdW0AX2wrrM1wWlN5fnJDjF9xG6mYRSQ==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.1.tgz", + "integrity": "sha512-97We7/VC0e9X5zBVkvt7SGQMGrRtn3KtySFQG5fpaMlS+l62eeXRQO633AYhSTC3z7IMebnPPNjGXVGNRFlxBA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "2.1.0", - "@vitest/mocker": "2.1.0", - "@vitest/pretty-format": "^2.1.0", - "@vitest/runner": "2.1.0", - "@vitest/snapshot": "2.1.0", - "@vitest/spy": "2.1.0", - "@vitest/utils": "2.1.0", + "@vitest/expect": "2.1.1", + "@vitest/mocker": "2.1.1", + "@vitest/pretty-format": "^2.1.1", + "@vitest/runner": "2.1.1", + "@vitest/snapshot": "2.1.1", + "@vitest/spy": "2.1.1", + "@vitest/utils": "2.1.1", "chai": "^5.1.1", "debug": "^4.3.6", "magic-string": "^0.30.11", @@ -6523,7 +6507,7 @@ "tinypool": "^1.0.0", "tinyrainbow": "^1.2.0", "vite": "^5.0.0", - "vite-node": "2.1.0", + "vite-node": "2.1.1", "why-is-node-running": "^2.3.0" }, "bin": { @@ -6538,8 +6522,8 @@ "peerDependencies": { "@edge-runtime/vm": "*", "@types/node": "^18.0.0 || >=20.0.0", - "@vitest/browser": "2.1.0", - "@vitest/ui": "2.1.0", + "@vitest/browser": "2.1.1", + "@vitest/ui": "2.1.1", "happy-dom": "*", "jsdom": "*" }, diff --git a/server/package-lock.json b/server/package-lock.json index 9abfc6b5ce70f..ba9f33dc1e425 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -2043,12 +2043,12 @@ } }, "node_modules/@nestjs/common": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-10.4.1.tgz", - "integrity": "sha512-4CkrDx0s4XuWqFjX8WvOFV7Y6RGJd0P2OBblkhZS7nwoctoSuW5pyEa8SWak6YHNGrHRpFb6ymm5Ai4LncwRVA==", + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-10.4.3.tgz", + "integrity": "sha512-4hbLd3XIJubHSylYd/1WSi4VQvG68KM/ECYpMDqA3k3J1/T17SAg40sDoq3ZoO5OZgU0xuNyjuISdOTjs11qVg==", "dependencies": { "iterare": "1.2.1", - "tslib": "2.6.3", + "tslib": "2.7.0", "uid": "2.0.2" }, "funding": { @@ -2070,6 +2070,11 @@ } } }, + "node_modules/@nestjs/common/node_modules/tslib": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==" + }, "node_modules/@nestjs/config": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/@nestjs/config/-/config-3.2.3.tgz", @@ -2183,12 +2188,12 @@ "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==" }, "node_modules/@nestjs/platform-socket.io": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-10.4.1.tgz", - "integrity": "sha512-cxn5vKBAbqtEVPl0qVcJpR4sC12+hzcY/mYXGW6ippOKQDBNc2OF8oZXu6V3O1MvAl+VM7eNNEsLmP9DRKQlnw==", + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-10.4.3.tgz", + "integrity": "sha512-jTatT8q15LB5CFWsaIez3IigMixt7tNGJ4QLlRJ5NggPOPKRZssJnloODyEadFNHJjZiyufp5/NoPKBtNMf+lg==", "dependencies": { "socket.io": "4.7.5", - "tslib": "2.6.3" + "tslib": "2.7.0" }, "funding": { "type": "opencollective", @@ -2200,10 +2205,15 @@ "rxjs": "^7.1.0" } }, + "node_modules/@nestjs/platform-socket.io/node_modules/tslib": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==" + }, "node_modules/@nestjs/schedule": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-4.1.0.tgz", - "integrity": "sha512-WEc96WTXZW+VI/Ng+uBpiBUwm6TWtAbQ4RKWkfbmzKvmbRGzA/9k/UyAWDS9k0pp+ZcbC+MaZQtt7TjQHrwX6g==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-4.1.1.tgz", + "integrity": "sha512-VxAnCiU4HP0wWw8IdWAVfsGC/FGjyToNjjUtXDEQL6oj+w/N5QDd2VT9k6d7Jbr8PlZuBZNdWtDKSkH5bZ+RXQ==", "dependencies": { "cron": "3.1.7", "uuid": "10.0.0" @@ -2280,12 +2290,12 @@ } }, "node_modules/@nestjs/testing": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-10.4.1.tgz", - "integrity": "sha512-pR+su5+YGqCLH0RhhVkPowQK7FCORU0/PWAywPK7LScAOtD67ZoviZ7hAU4vnGdwkg4HCB0D7W8Bkg19CGU8Xw==", + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-10.4.3.tgz", + "integrity": "sha512-SBNWrMU51YAlYmW86wyjlGZ2uLnASNiOPD0lBcNIlxxei0b05/aI3nh7OPuxbXQUdedUJfPq2d2jZj4TRG4S0w==", "dev": true, "dependencies": { - "tslib": "2.6.3" + "tslib": "2.7.0" }, "funding": { "type": "opencollective", @@ -2306,6 +2316,12 @@ } } }, + "node_modules/@nestjs/testing/node_modules/tslib": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==", + "dev": true + }, "node_modules/@nestjs/typeorm": { "version": "10.0.2", "resolved": "https://registry.npmjs.org/@nestjs/typeorm/-/typeorm-10.0.2.tgz", @@ -2322,13 +2338,13 @@ } }, "node_modules/@nestjs/websockets": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-10.4.1.tgz", - "integrity": "sha512-p0Eq94WneczV2bnLEu9hl24iCIfH5eUCGgBuYOkVDySBwvya5L+gD4wUoqIqGoX1c6rkhQa+pMR7pi1EY4t93w==", + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-10.4.3.tgz", + "integrity": "sha512-EW5/GR0jImJwrb8+YpHPoFN2tlhYQzVE2yAN5Se5sygUr/ZFMNAG84sd79NmWGd4RxoxR0aFH9nRycQ/0Ebe5w==", "dependencies": { "iterare": "1.2.1", "object-hash": "3.0.0", - "tslib": "2.6.3" + "tslib": "2.7.0" }, "peerDependencies": { "@nestjs/common": "^10.0.0", @@ -2343,6 +2359,11 @@ } } }, + "node_modules/@nestjs/websockets/node_modules/tslib": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==" + }, "node_modules/@next/env": { "version": "14.2.3", "resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.3.tgz", @@ -5376,9 +5397,9 @@ } }, "node_modules/@types/nodemailer": { - "version": "6.4.15", - "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.15.tgz", - "integrity": "sha512-0EBJxawVNjPkng1zm2vopRctuWVCxk34JcIlRuXSf54habUWdz1FB7wHDqOqvDa8Mtpt0Q3LTXQkAs2LNyK5jQ==", + "version": "6.4.16", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.16.tgz", + "integrity": "sha512-uz6hN6Pp0upXMcilM61CoKyjT7sskBoOWpptkjjJp8jIMlTdc3xG01U7proKkXzruMS4hS0zqtHNkNPFB20rKQ==", "dev": true, "dependencies": { "@types/node": "*" @@ -5485,9 +5506,9 @@ "dev": true }, "node_modules/@types/react": { - "version": "18.3.5", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.5.tgz", - "integrity": "sha512-WeqMfGJLGuLCqHGYRGHxnKrXcTitc6L/nBUWfWPcTarG3t9PsquqUMuVeXZeca+mglY4Vo5GZjCi0A3Or2lnxA==", + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.7.tgz", + "integrity": "sha512-KUnDCJF5+AiZd8owLIeVHqmW9yM4sqmDVf2JRJiBMFkGvkoZ4/WyV2lL4zVsoinmRS/W3FeEdZLEWFRofnT2FQ==", "dev": true, "dependencies": { "@types/prop-types": "*", @@ -5604,16 +5625,16 @@ "integrity": "sha512-c/hzNDBh7eRF+KbCf+OoZxKbnkpaK/cKp9iLQWqB7muXtM+MtL9SUUH8vCFcLn6dH1Qm05jiexK0ofWY7TfOhQ==" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.5.0.tgz", - "integrity": "sha512-lHS5hvz33iUFQKuPFGheAB84LwcJ60G8vKnEhnfcK1l8kGVLro2SFYW6K0/tj8FUhRJ0VHyg1oAfg50QGbPPHw==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.6.0.tgz", + "integrity": "sha512-UOaz/wFowmoh2G6Mr9gw60B1mm0MzUtm6Ic8G2yM1Le6gyj5Loi/N+O5mocugRGY+8OeeKmkMmbxNqUCq3B4Sg==", "dev": true, "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.5.0", - "@typescript-eslint/type-utils": "8.5.0", - "@typescript-eslint/utils": "8.5.0", - "@typescript-eslint/visitor-keys": "8.5.0", + "@typescript-eslint/scope-manager": "8.6.0", + "@typescript-eslint/type-utils": "8.6.0", + "@typescript-eslint/utils": "8.6.0", + "@typescript-eslint/visitor-keys": "8.6.0", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", @@ -5637,15 +5658,15 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.5.0.tgz", - "integrity": "sha512-gF77eNv0Xz2UJg/NbpWJ0kqAm35UMsvZf1GHj8D9MRFTj/V3tAciIWXfmPLsAAF/vUlpWPvUDyH1jjsr0cMVWw==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.6.0.tgz", + "integrity": "sha512-eQcbCuA2Vmw45iGfcyG4y6rS7BhWfz9MQuk409WD47qMM+bKCGQWXxvoOs1DUp+T7UBMTtRTVT+kXr7Sh4O9Ow==", "dev": true, "dependencies": { - "@typescript-eslint/scope-manager": "8.5.0", - "@typescript-eslint/types": "8.5.0", - "@typescript-eslint/typescript-estree": "8.5.0", - "@typescript-eslint/visitor-keys": "8.5.0", + "@typescript-eslint/scope-manager": "8.6.0", + "@typescript-eslint/types": "8.6.0", + "@typescript-eslint/typescript-estree": "8.6.0", + "@typescript-eslint/visitor-keys": "8.6.0", "debug": "^4.3.4" }, "engines": { @@ -5665,13 +5686,13 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.5.0.tgz", - "integrity": "sha512-06JOQ9Qgj33yvBEx6tpC8ecP9o860rsR22hWMEd12WcTRrfaFgHr2RB/CA/B+7BMhHkXT4chg2MyboGdFGawYg==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.6.0.tgz", + "integrity": "sha512-ZuoutoS5y9UOxKvpc/GkvF4cuEmpokda4wRg64JEia27wX+PysIE9q+lzDtlHHgblwUWwo5/Qn+/WyTUvDwBHw==", "dev": true, "dependencies": { - "@typescript-eslint/types": "8.5.0", - "@typescript-eslint/visitor-keys": "8.5.0" + "@typescript-eslint/types": "8.6.0", + "@typescript-eslint/visitor-keys": "8.6.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5682,13 +5703,13 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.5.0.tgz", - "integrity": "sha512-N1K8Ix+lUM+cIDhL2uekVn/ZD7TZW+9/rwz8DclQpcQ9rk4sIL5CAlBC0CugWKREmDjBzI/kQqU4wkg46jWLYA==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.6.0.tgz", + "integrity": "sha512-dtePl4gsuenXVwC7dVNlb4mGDcKjDT/Ropsk4za/ouMBPplCLyznIaR+W65mvCvsyS97dymoBRrioEXI7k0XIg==", "dev": true, "dependencies": { - "@typescript-eslint/typescript-estree": "8.5.0", - "@typescript-eslint/utils": "8.5.0", + "@typescript-eslint/typescript-estree": "8.6.0", + "@typescript-eslint/utils": "8.6.0", "debug": "^4.3.4", "ts-api-utils": "^1.3.0" }, @@ -5706,9 +5727,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.5.0.tgz", - "integrity": "sha512-qjkormnQS5wF9pjSi6q60bKUHH44j2APxfh9TQRXK8wbYVeDYYdYJGIROL87LGZZ2gz3Rbmjc736qyL8deVtdw==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.6.0.tgz", + "integrity": "sha512-rojqFZGd4MQxw33SrOy09qIDS8WEldM8JWtKQLAjf/X5mGSeEFh5ixQlxssMNyPslVIk9yzWqXCsV2eFhYrYUw==", "dev": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5719,13 +5740,13 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.5.0.tgz", - "integrity": "sha512-vEG2Sf9P8BPQ+d0pxdfndw3xIXaoSjliG0/Ejk7UggByZPKXmJmw3GW5jV2gHNQNawBUyfahoSiCFVov0Ruf7Q==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.6.0.tgz", + "integrity": "sha512-MOVAzsKJIPIlLK239l5s06YXjNqpKTVhBVDnqUumQJja5+Y94V3+4VUFRA0G60y2jNnTVwRCkhyGQpavfsbq/g==", "dev": true, "dependencies": { - "@typescript-eslint/types": "8.5.0", - "@typescript-eslint/visitor-keys": "8.5.0", + "@typescript-eslint/types": "8.6.0", + "@typescript-eslint/visitor-keys": "8.6.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", @@ -5771,15 +5792,15 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.5.0.tgz", - "integrity": "sha512-6yyGYVL0e+VzGYp60wvkBHiqDWOpT63pdMV2CVG4LVDd5uR6q1qQN/7LafBZtAtNIn/mqXjsSeS5ggv/P0iECw==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.6.0.tgz", + "integrity": "sha512-eNp9cWnYf36NaOVjkEUznf6fEgVy1TWpE0o52e4wtojjBx7D1UV2WAWGzR+8Y5lVFtpMLPwNbC67T83DWSph4A==", "dev": true, "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.5.0", - "@typescript-eslint/types": "8.5.0", - "@typescript-eslint/typescript-estree": "8.5.0" + "@typescript-eslint/scope-manager": "8.6.0", + "@typescript-eslint/types": "8.6.0", + "@typescript-eslint/typescript-estree": "8.6.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5793,12 +5814,12 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.5.0.tgz", - "integrity": "sha512-yTPqMnbAZJNy2Xq2XU8AdtOW9tJIr+UQb64aXB9f3B1498Zx9JorVgFJcZpEc9UBuCCrdzKID2RGAMkYcDtZOw==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.6.0.tgz", + "integrity": "sha512-wapVFfZg9H0qOYh4grNVQiMklJGluQrOUiOhYRrQWhx7BY/+I1IYb8BczWNbbUpO+pqy0rDciv3lQH5E1bCLrg==", "dev": true, "dependencies": { - "@typescript-eslint/types": "8.5.0", + "@typescript-eslint/types": "8.6.0", "eslint-visitor-keys": "^3.4.3" }, "engines": { @@ -5810,9 +5831,9 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.1.0.tgz", - "integrity": "sha512-yqCkr2nrV4o58VcVMxTVkS6Ggxzy7pmSD8JbTbhbH5PsQfUIES1QT716VUzo33wf2lX9EcWYdT3Vl2MMmjR59g==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.1.1.tgz", + "integrity": "sha512-md/A7A3c42oTT8JUHSqjP5uKTWJejzUW4jalpvs+rZ27gsURsMU8DEb+8Jf8C6Kj2gwfSHJqobDNBuoqlm0cFw==", "dev": true, "dependencies": { "@ampproject/remapping": "^2.3.0", @@ -5832,8 +5853,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "2.1.0", - "vitest": "2.1.0" + "@vitest/browser": "2.1.1", + "vitest": "2.1.1" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -5851,13 +5872,13 @@ } }, "node_modules/@vitest/expect": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.0.tgz", - "integrity": "sha512-N3/xR4fSu0+6sVZETEtPT1orUs2+Y477JOXTcU3xKuu3uBlsgbD7/7Mz2LZ1Jr1XjwilEWlrIgSCj4N1+5ZmsQ==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.1.tgz", + "integrity": "sha512-YeueunS0HiHiQxk+KEOnq/QMzlUuOzbU1Go+PgAsHvvv3tUkJPm9xWt+6ITNTlzsMXUjmgm5T+U7KBPK2qQV6w==", "dev": true, "dependencies": { - "@vitest/spy": "2.1.0", - "@vitest/utils": "2.1.0", + "@vitest/spy": "2.1.1", + "@vitest/utils": "2.1.1", "chai": "^5.1.1", "tinyrainbow": "^1.2.0" }, @@ -5866,9 +5887,9 @@ } }, "node_modules/@vitest/mocker": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.0.tgz", - "integrity": "sha512-ZxENovUqhzl+QiOFpagiHUNUuZ1qPd5yYTCYHomGIZOFArzn4mgX2oxZmiAItJWAaXHG6bbpb/DpSPhlk5DgtA==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.1.tgz", + "integrity": "sha512-LNN5VwOEdJqCmJ/2XJBywB11DLlkbY0ooDJW3uRX5cZyYCrc4PI/ePX0iQhE3BiEGiQmK4GE7Q/PqCkkaiPnrA==", "dev": true, "dependencies": { "@vitest/spy": "^2.1.0-beta.1", @@ -5879,7 +5900,7 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/spy": "2.1.0", + "@vitest/spy": "2.1.1", "msw": "^2.3.5", "vite": "^5.0.0" }, @@ -5914,12 +5935,12 @@ } }, "node_modules/@vitest/runner": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.0.tgz", - "integrity": "sha512-D9+ZiB8MbMt7qWDRJc4CRNNUlne/8E1X7dcKhZVAbcOKG58MGGYVDqAq19xlhNfMFZsW0bpVKgztBwks38Ko0w==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.1.tgz", + "integrity": "sha512-uTPuY6PWOYitIkLPidaY5L3t0JJITdGTSwBtwMjKzo5O6RCOEncz9PUN+0pDidX8kTHYjO0EwUIvhlGpnGpxmA==", "dev": true, "dependencies": { - "@vitest/utils": "2.1.0", + "@vitest/utils": "2.1.1", "pathe": "^1.1.2" }, "funding": { @@ -5927,12 +5948,12 @@ } }, "node_modules/@vitest/snapshot": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.0.tgz", - "integrity": "sha512-x69CygGMzt9VCO283K2/FYQ+nBrOj66OTKpsPykjCR4Ac3lLV+m85hj9reaIGmjBSsKzVvbxWmjWE3kF5ha3uQ==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.1.tgz", + "integrity": "sha512-BnSku1WFy7r4mm96ha2FzN99AZJgpZOWrAhtQfoxjUU5YMRpq1zmHRq7a5K9/NjqonebO7iVDla+VvZS8BOWMw==", "dev": true, "dependencies": { - "@vitest/pretty-format": "2.1.0", + "@vitest/pretty-format": "2.1.1", "magic-string": "^0.30.11", "pathe": "^1.1.2" }, @@ -5940,18 +5961,6 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitest/snapshot/node_modules/@vitest/pretty-format": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.0.tgz", - "integrity": "sha512-7sxf2F3DNYatgmzXXcTh6cq+/fxwB47RIQqZJFoSH883wnVAoccSRT6g+dTKemUBo8Q5N4OYYj1EBXLuRKvp3Q==", - "dev": true, - "dependencies": { - "tinyrainbow": "^1.2.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, "node_modules/@vitest/snapshot/node_modules/magic-string": { "version": "0.30.11", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.11.tgz", @@ -5962,9 +5971,9 @@ } }, "node_modules/@vitest/spy": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.0.tgz", - "integrity": "sha512-IXX5NkbdgTYTog3F14i2LgnBc+20YmkXMx0IWai84mcxySUDRgm0ihbOfR4L0EVRBDFG85GjmQQEZNNKVVpkZw==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.1.tgz", + "integrity": "sha512-ZM39BnZ9t/xZ/nF4UwRH5il0Sw93QnZXd9NAZGRpIgj0yvVwPpLd702s/Cx955rGaMlyBQkZJ2Ir7qyY48VZ+g==", "dev": true, "dependencies": { "tinyspy": "^3.0.0" @@ -5974,12 +5983,12 @@ } }, "node_modules/@vitest/utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.0.tgz", - "integrity": "sha512-rreyfVe0PuNqJfKYUwfPDfi6rrp0VSu0Wgvp5WBqJonP+4NvXHk48X6oBam1Lj47Hy6jbJtnMj3OcRdrkTP0tA==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.1.tgz", + "integrity": "sha512-Y6Q9TsI+qJ2CC0ZKj6VBb+T8UPz593N113nnUykqwANqhgf3QkZeHFlusgKLTqrnVHbj/XDKZcDHol+dxVT+rQ==", "dev": true, "dependencies": { - "@vitest/pretty-format": "2.1.0", + "@vitest/pretty-format": "2.1.1", "loupe": "^3.1.1", "tinyrainbow": "^1.2.0" }, @@ -5987,18 +5996,6 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitest/utils/node_modules/@vitest/pretty-format": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.0.tgz", - "integrity": "sha512-7sxf2F3DNYatgmzXXcTh6cq+/fxwB47RIQqZJFoSH883wnVAoccSRT6g+dTKemUBo8Q5N4OYYj1EBXLuRKvp3Q==", - "dev": true, - "dependencies": { - "tinyrainbow": "^1.2.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, "node_modules/@webassemblyjs/ast": { "version": "1.12.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.12.1.tgz", @@ -11311,13 +11308,13 @@ } }, "node_modules/pg": { - "version": "8.12.0", - "resolved": "https://registry.npmjs.org/pg/-/pg-8.12.0.tgz", - "integrity": "sha512-A+LHUSnwnxrnL/tZ+OLfqR1SxLN3c/pgDztZ47Rpbsd4jUytsTtwQo/TLPRzPJMp/1pbhYVhH9cuSZLAajNfjQ==", + "version": "8.13.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.13.0.tgz", + "integrity": "sha512-34wkUTh3SxTClfoHB3pQ7bIMvw9dpFU1audQQeZG837fmHfHpr14n/AELVDoOYVDW2h5RDWU78tFjkD+erSBsw==", "dependencies": { - "pg-connection-string": "^2.6.4", - "pg-pool": "^3.6.2", - "pg-protocol": "^1.6.1", + "pg-connection-string": "^2.7.0", + "pg-pool": "^3.7.0", + "pg-protocol": "^1.7.0", "pg-types": "^2.1.0", "pgpass": "1.x" }, @@ -11343,9 +11340,9 @@ "optional": true }, "node_modules/pg-connection-string": { - "version": "2.6.4", - "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.6.4.tgz", - "integrity": "sha512-v+Z7W/0EO707aNMaAEfiGnGL9sxxumwLl2fJvCQtMn9Fxsg+lPpPkdcyBSv/KFgpGdYkMfn+EI1Or2EHjpgLCA==" + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.7.0.tgz", + "integrity": "sha512-PI2W9mv53rXJQEOb8xNR8lH7Hr+EKa6oJa38zsK0S/ky2er16ios1wLKhZyxzD7jUReiWokc9WK5nxSnC7W1TA==" }, "node_modules/pg-int8": { "version": "1.0.1", @@ -11364,17 +11361,17 @@ } }, "node_modules/pg-pool": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.6.2.tgz", - "integrity": "sha512-Htjbg8BlwXqSBQ9V8Vjtc+vzf/6fVUuak/3/XXKA9oxZprwW3IMDQTGHP+KDmVL7rtd+R1QjbnCFPuTHm3G4hg==", + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.7.0.tgz", + "integrity": "sha512-ZOBQForurqh4zZWjrgSwwAtzJ7QiRX0ovFkZr2klsen3Nm0aoh33Ls0fzfv3imeH/nw/O27cjdz5kzYJfeGp/g==", "peerDependencies": { "pg": ">=8.0" } }, "node_modules/pg-protocol": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.6.1.tgz", - "integrity": "sha512-jPIlvgoD63hrEuihvIg+tJhoGjUsLPn6poJY9N5CnlPd91c2T18T/9zBtLxZSb1EhYxBRoZJtzScCaWlYLtktg==" + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.7.0.tgz", + "integrity": "sha512-hTK/mE36i8fDDhgDFjy6xNOG+LCorxLG3WO17tku+ij6sVHXh1jQUJ8hYAnRhNla4QVD2H8er/FOjc/+EgC6yQ==" }, "node_modules/pg-types": { "version": "2.2.0", @@ -14565,9 +14562,9 @@ } }, "node_modules/ua-parser-js": { - "version": "1.0.38", - "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.38.tgz", - "integrity": "sha512-Aq5ppTOfvrCMgAPneW1HfWj66Xi7XL+/mIy996R1/CLS/rcyJQm6QZdsKrUeivDFQ+Oc9Wyuwor8Ze8peEoUoQ==", + "version": "1.0.39", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.39.tgz", + "integrity": "sha512-k24RCVWlEcjkdOxYmVJgeD/0a1TiSpqLg+ZalVGV9lsnr4yqu0w7tX/x2xX6G4zpkgQnRf89lxuZ1wsbjXM8lw==", "funding": [ { "type": "opencollective", @@ -14582,6 +14579,9 @@ "url": "https://github.com/sponsors/faisalman" } ], + "bin": { + "ua-parser-js": "script/cli.js" + }, "engines": { "node": "*" } @@ -14861,9 +14861,9 @@ } }, "node_modules/vite-node": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.0.tgz", - "integrity": "sha512-+ybYqBVUjYyIscoLzMWodus2enQDZOpGhcU6HdOVD6n8WZdk12w1GFL3mbnxLs7hPtRtqs1Wo5YF6/Tsr6fmhg==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.1.tgz", + "integrity": "sha512-N/mGckI1suG/5wQI35XeR9rsMsPqKXzq1CdUndzVstBj/HvyxxGctwnK6WX43NGt5L3Z5tcRf83g4TITKJhPrA==", "dev": true, "dependencies": { "cac": "^6.7.14", @@ -14901,18 +14901,18 @@ } }, "node_modules/vitest": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.0.tgz", - "integrity": "sha512-XuuEeyNkqbfr0FtAvd9vFbInSSNY1ykCQTYQ0sj9wPy4hx+1gR7gqVNdW0AX2wrrM1wWlN5fnJDjF9xG6mYRSQ==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.1.tgz", + "integrity": "sha512-97We7/VC0e9X5zBVkvt7SGQMGrRtn3KtySFQG5fpaMlS+l62eeXRQO633AYhSTC3z7IMebnPPNjGXVGNRFlxBA==", "dev": true, "dependencies": { - "@vitest/expect": "2.1.0", - "@vitest/mocker": "2.1.0", - "@vitest/pretty-format": "^2.1.0", - "@vitest/runner": "2.1.0", - "@vitest/snapshot": "2.1.0", - "@vitest/spy": "2.1.0", - "@vitest/utils": "2.1.0", + "@vitest/expect": "2.1.1", + "@vitest/mocker": "2.1.1", + "@vitest/pretty-format": "^2.1.1", + "@vitest/runner": "2.1.1", + "@vitest/snapshot": "2.1.1", + "@vitest/spy": "2.1.1", + "@vitest/utils": "2.1.1", "chai": "^5.1.1", "debug": "^4.3.6", "magic-string": "^0.30.11", @@ -14923,7 +14923,7 @@ "tinypool": "^1.0.0", "tinyrainbow": "^1.2.0", "vite": "^5.0.0", - "vite-node": "2.1.0", + "vite-node": "2.1.1", "why-is-node-running": "^2.3.0" }, "bin": { @@ -14938,8 +14938,8 @@ "peerDependencies": { "@edge-runtime/vm": "*", "@types/node": "^18.0.0 || >=20.0.0", - "@vitest/browser": "2.1.0", - "@vitest/ui": "2.1.0", + "@vitest/browser": "2.1.1", + "@vitest/ui": "2.1.1", "happy-dom": "*", "jsdom": "*" }, @@ -16512,13 +16512,20 @@ } }, "@nestjs/common": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-10.4.1.tgz", - "integrity": "sha512-4CkrDx0s4XuWqFjX8WvOFV7Y6RGJd0P2OBblkhZS7nwoctoSuW5pyEa8SWak6YHNGrHRpFb6ymm5Ai4LncwRVA==", + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-10.4.3.tgz", + "integrity": "sha512-4hbLd3XIJubHSylYd/1WSi4VQvG68KM/ECYpMDqA3k3J1/T17SAg40sDoq3ZoO5OZgU0xuNyjuISdOTjs11qVg==", "requires": { "iterare": "1.2.1", - "tslib": "2.6.3", + "tslib": "2.7.0", "uid": "2.0.2" + }, + "dependencies": { + "tslib": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==" + } } }, "@nestjs/config": { @@ -16585,18 +16592,25 @@ } }, "@nestjs/platform-socket.io": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-10.4.1.tgz", - "integrity": "sha512-cxn5vKBAbqtEVPl0qVcJpR4sC12+hzcY/mYXGW6ippOKQDBNc2OF8oZXu6V3O1MvAl+VM7eNNEsLmP9DRKQlnw==", + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-10.4.3.tgz", + "integrity": "sha512-jTatT8q15LB5CFWsaIez3IigMixt7tNGJ4QLlRJ5NggPOPKRZssJnloODyEadFNHJjZiyufp5/NoPKBtNMf+lg==", "requires": { "socket.io": "4.7.5", - "tslib": "2.6.3" + "tslib": "2.7.0" + }, + "dependencies": { + "tslib": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==" + } } }, "@nestjs/schedule": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-4.1.0.tgz", - "integrity": "sha512-WEc96WTXZW+VI/Ng+uBpiBUwm6TWtAbQ4RKWkfbmzKvmbRGzA/9k/UyAWDS9k0pp+ZcbC+MaZQtt7TjQHrwX6g==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-4.1.1.tgz", + "integrity": "sha512-VxAnCiU4HP0wWw8IdWAVfsGC/FGjyToNjjUtXDEQL6oj+w/N5QDd2VT9k6d7Jbr8PlZuBZNdWtDKSkH5bZ+RXQ==", "requires": { "cron": "3.1.7", "uuid": "10.0.0" @@ -16644,12 +16658,20 @@ } }, "@nestjs/testing": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-10.4.1.tgz", - "integrity": "sha512-pR+su5+YGqCLH0RhhVkPowQK7FCORU0/PWAywPK7LScAOtD67ZoviZ7hAU4vnGdwkg4HCB0D7W8Bkg19CGU8Xw==", + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-10.4.3.tgz", + "integrity": "sha512-SBNWrMU51YAlYmW86wyjlGZ2uLnASNiOPD0lBcNIlxxei0b05/aI3nh7OPuxbXQUdedUJfPq2d2jZj4TRG4S0w==", "dev": true, "requires": { - "tslib": "2.6.3" + "tslib": "2.7.0" + }, + "dependencies": { + "tslib": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==", + "dev": true + } } }, "@nestjs/typeorm": { @@ -16661,13 +16683,20 @@ } }, "@nestjs/websockets": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-10.4.1.tgz", - "integrity": "sha512-p0Eq94WneczV2bnLEu9hl24iCIfH5eUCGgBuYOkVDySBwvya5L+gD4wUoqIqGoX1c6rkhQa+pMR7pi1EY4t93w==", + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-10.4.3.tgz", + "integrity": "sha512-EW5/GR0jImJwrb8+YpHPoFN2tlhYQzVE2yAN5Se5sygUr/ZFMNAG84sd79NmWGd4RxoxR0aFH9nRycQ/0Ebe5w==", "requires": { "iterare": "1.2.1", "object-hash": "3.0.0", - "tslib": "2.6.3" + "tslib": "2.7.0" + }, + "dependencies": { + "tslib": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==" + } } }, "@next/env": { @@ -18680,9 +18709,9 @@ } }, "@types/nodemailer": { - "version": "6.4.15", - "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.15.tgz", - "integrity": "sha512-0EBJxawVNjPkng1zm2vopRctuWVCxk34JcIlRuXSf54habUWdz1FB7wHDqOqvDa8Mtpt0Q3LTXQkAs2LNyK5jQ==", + "version": "6.4.16", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.16.tgz", + "integrity": "sha512-uz6hN6Pp0upXMcilM61CoKyjT7sskBoOWpptkjjJp8jIMlTdc3xG01U7proKkXzruMS4hS0zqtHNkNPFB20rKQ==", "dev": true, "requires": { "@types/node": "*" @@ -18776,9 +18805,9 @@ "dev": true }, "@types/react": { - "version": "18.3.5", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.5.tgz", - "integrity": "sha512-WeqMfGJLGuLCqHGYRGHxnKrXcTitc6L/nBUWfWPcTarG3t9PsquqUMuVeXZeca+mglY4Vo5GZjCi0A3Or2lnxA==", + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.7.tgz", + "integrity": "sha512-KUnDCJF5+AiZd8owLIeVHqmW9yM4sqmDVf2JRJiBMFkGvkoZ4/WyV2lL4zVsoinmRS/W3FeEdZLEWFRofnT2FQ==", "dev": true, "requires": { "@types/prop-types": "*", @@ -18895,16 +18924,16 @@ "integrity": "sha512-c/hzNDBh7eRF+KbCf+OoZxKbnkpaK/cKp9iLQWqB7muXtM+MtL9SUUH8vCFcLn6dH1Qm05jiexK0ofWY7TfOhQ==" }, "@typescript-eslint/eslint-plugin": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.5.0.tgz", - "integrity": "sha512-lHS5hvz33iUFQKuPFGheAB84LwcJ60G8vKnEhnfcK1l8kGVLro2SFYW6K0/tj8FUhRJ0VHyg1oAfg50QGbPPHw==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.6.0.tgz", + "integrity": "sha512-UOaz/wFowmoh2G6Mr9gw60B1mm0MzUtm6Ic8G2yM1Le6gyj5Loi/N+O5mocugRGY+8OeeKmkMmbxNqUCq3B4Sg==", "dev": true, "requires": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.5.0", - "@typescript-eslint/type-utils": "8.5.0", - "@typescript-eslint/utils": "8.5.0", - "@typescript-eslint/visitor-keys": "8.5.0", + "@typescript-eslint/scope-manager": "8.6.0", + "@typescript-eslint/type-utils": "8.6.0", + "@typescript-eslint/utils": "8.6.0", + "@typescript-eslint/visitor-keys": "8.6.0", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", @@ -18912,54 +18941,54 @@ } }, "@typescript-eslint/parser": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.5.0.tgz", - "integrity": "sha512-gF77eNv0Xz2UJg/NbpWJ0kqAm35UMsvZf1GHj8D9MRFTj/V3tAciIWXfmPLsAAF/vUlpWPvUDyH1jjsr0cMVWw==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.6.0.tgz", + "integrity": "sha512-eQcbCuA2Vmw45iGfcyG4y6rS7BhWfz9MQuk409WD47qMM+bKCGQWXxvoOs1DUp+T7UBMTtRTVT+kXr7Sh4O9Ow==", "dev": true, "requires": { - "@typescript-eslint/scope-manager": "8.5.0", - "@typescript-eslint/types": "8.5.0", - "@typescript-eslint/typescript-estree": "8.5.0", - "@typescript-eslint/visitor-keys": "8.5.0", + "@typescript-eslint/scope-manager": "8.6.0", + "@typescript-eslint/types": "8.6.0", + "@typescript-eslint/typescript-estree": "8.6.0", + "@typescript-eslint/visitor-keys": "8.6.0", "debug": "^4.3.4" } }, "@typescript-eslint/scope-manager": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.5.0.tgz", - "integrity": "sha512-06JOQ9Qgj33yvBEx6tpC8ecP9o860rsR22hWMEd12WcTRrfaFgHr2RB/CA/B+7BMhHkXT4chg2MyboGdFGawYg==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.6.0.tgz", + "integrity": "sha512-ZuoutoS5y9UOxKvpc/GkvF4cuEmpokda4wRg64JEia27wX+PysIE9q+lzDtlHHgblwUWwo5/Qn+/WyTUvDwBHw==", "dev": true, "requires": { - "@typescript-eslint/types": "8.5.0", - "@typescript-eslint/visitor-keys": "8.5.0" + "@typescript-eslint/types": "8.6.0", + "@typescript-eslint/visitor-keys": "8.6.0" } }, "@typescript-eslint/type-utils": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.5.0.tgz", - "integrity": "sha512-N1K8Ix+lUM+cIDhL2uekVn/ZD7TZW+9/rwz8DclQpcQ9rk4sIL5CAlBC0CugWKREmDjBzI/kQqU4wkg46jWLYA==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.6.0.tgz", + "integrity": "sha512-dtePl4gsuenXVwC7dVNlb4mGDcKjDT/Ropsk4za/ouMBPplCLyznIaR+W65mvCvsyS97dymoBRrioEXI7k0XIg==", "dev": true, "requires": { - "@typescript-eslint/typescript-estree": "8.5.0", - "@typescript-eslint/utils": "8.5.0", + "@typescript-eslint/typescript-estree": "8.6.0", + "@typescript-eslint/utils": "8.6.0", "debug": "^4.3.4", "ts-api-utils": "^1.3.0" } }, "@typescript-eslint/types": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.5.0.tgz", - "integrity": "sha512-qjkormnQS5wF9pjSi6q60bKUHH44j2APxfh9TQRXK8wbYVeDYYdYJGIROL87LGZZ2gz3Rbmjc736qyL8deVtdw==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.6.0.tgz", + "integrity": "sha512-rojqFZGd4MQxw33SrOy09qIDS8WEldM8JWtKQLAjf/X5mGSeEFh5ixQlxssMNyPslVIk9yzWqXCsV2eFhYrYUw==", "dev": true }, "@typescript-eslint/typescript-estree": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.5.0.tgz", - "integrity": "sha512-vEG2Sf9P8BPQ+d0pxdfndw3xIXaoSjliG0/Ejk7UggByZPKXmJmw3GW5jV2gHNQNawBUyfahoSiCFVov0Ruf7Q==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.6.0.tgz", + "integrity": "sha512-MOVAzsKJIPIlLK239l5s06YXjNqpKTVhBVDnqUumQJja5+Y94V3+4VUFRA0G60y2jNnTVwRCkhyGQpavfsbq/g==", "dev": true, "requires": { - "@typescript-eslint/types": "8.5.0", - "@typescript-eslint/visitor-keys": "8.5.0", + "@typescript-eslint/types": "8.6.0", + "@typescript-eslint/visitor-keys": "8.6.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", @@ -18989,31 +19018,31 @@ } }, "@typescript-eslint/utils": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.5.0.tgz", - "integrity": "sha512-6yyGYVL0e+VzGYp60wvkBHiqDWOpT63pdMV2CVG4LVDd5uR6q1qQN/7LafBZtAtNIn/mqXjsSeS5ggv/P0iECw==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.6.0.tgz", + "integrity": "sha512-eNp9cWnYf36NaOVjkEUznf6fEgVy1TWpE0o52e4wtojjBx7D1UV2WAWGzR+8Y5lVFtpMLPwNbC67T83DWSph4A==", "dev": true, "requires": { "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.5.0", - "@typescript-eslint/types": "8.5.0", - "@typescript-eslint/typescript-estree": "8.5.0" + "@typescript-eslint/scope-manager": "8.6.0", + "@typescript-eslint/types": "8.6.0", + "@typescript-eslint/typescript-estree": "8.6.0" } }, "@typescript-eslint/visitor-keys": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.5.0.tgz", - "integrity": "sha512-yTPqMnbAZJNy2Xq2XU8AdtOW9tJIr+UQb64aXB9f3B1498Zx9JorVgFJcZpEc9UBuCCrdzKID2RGAMkYcDtZOw==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.6.0.tgz", + "integrity": "sha512-wapVFfZg9H0qOYh4grNVQiMklJGluQrOUiOhYRrQWhx7BY/+I1IYb8BczWNbbUpO+pqy0rDciv3lQH5E1bCLrg==", "dev": true, "requires": { - "@typescript-eslint/types": "8.5.0", + "@typescript-eslint/types": "8.6.0", "eslint-visitor-keys": "^3.4.3" } }, "@vitest/coverage-v8": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.1.0.tgz", - "integrity": "sha512-yqCkr2nrV4o58VcVMxTVkS6Ggxzy7pmSD8JbTbhbH5PsQfUIES1QT716VUzo33wf2lX9EcWYdT3Vl2MMmjR59g==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.1.1.tgz", + "integrity": "sha512-md/A7A3c42oTT8JUHSqjP5uKTWJejzUW4jalpvs+rZ27gsURsMU8DEb+8Jf8C6Kj2gwfSHJqobDNBuoqlm0cFw==", "dev": true, "requires": { "@ampproject/remapping": "^2.3.0", @@ -19042,21 +19071,21 @@ } }, "@vitest/expect": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.0.tgz", - "integrity": "sha512-N3/xR4fSu0+6sVZETEtPT1orUs2+Y477JOXTcU3xKuu3uBlsgbD7/7Mz2LZ1Jr1XjwilEWlrIgSCj4N1+5ZmsQ==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.1.tgz", + "integrity": "sha512-YeueunS0HiHiQxk+KEOnq/QMzlUuOzbU1Go+PgAsHvvv3tUkJPm9xWt+6ITNTlzsMXUjmgm5T+U7KBPK2qQV6w==", "dev": true, "requires": { - "@vitest/spy": "2.1.0", - "@vitest/utils": "2.1.0", + "@vitest/spy": "2.1.1", + "@vitest/utils": "2.1.1", "chai": "^5.1.1", "tinyrainbow": "^1.2.0" } }, "@vitest/mocker": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.0.tgz", - "integrity": "sha512-ZxENovUqhzl+QiOFpagiHUNUuZ1qPd5yYTCYHomGIZOFArzn4mgX2oxZmiAItJWAaXHG6bbpb/DpSPhlk5DgtA==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.1.tgz", + "integrity": "sha512-LNN5VwOEdJqCmJ/2XJBywB11DLlkbY0ooDJW3uRX5cZyYCrc4PI/ePX0iQhE3BiEGiQmK4GE7Q/PqCkkaiPnrA==", "dev": true, "requires": { "@vitest/spy": "^2.1.0-beta.1", @@ -19085,35 +19114,26 @@ } }, "@vitest/runner": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.0.tgz", - "integrity": "sha512-D9+ZiB8MbMt7qWDRJc4CRNNUlne/8E1X7dcKhZVAbcOKG58MGGYVDqAq19xlhNfMFZsW0bpVKgztBwks38Ko0w==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.1.tgz", + "integrity": "sha512-uTPuY6PWOYitIkLPidaY5L3t0JJITdGTSwBtwMjKzo5O6RCOEncz9PUN+0pDidX8kTHYjO0EwUIvhlGpnGpxmA==", "dev": true, "requires": { - "@vitest/utils": "2.1.0", + "@vitest/utils": "2.1.1", "pathe": "^1.1.2" } }, "@vitest/snapshot": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.0.tgz", - "integrity": "sha512-x69CygGMzt9VCO283K2/FYQ+nBrOj66OTKpsPykjCR4Ac3lLV+m85hj9reaIGmjBSsKzVvbxWmjWE3kF5ha3uQ==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.1.tgz", + "integrity": "sha512-BnSku1WFy7r4mm96ha2FzN99AZJgpZOWrAhtQfoxjUU5YMRpq1zmHRq7a5K9/NjqonebO7iVDla+VvZS8BOWMw==", "dev": true, "requires": { - "@vitest/pretty-format": "2.1.0", + "@vitest/pretty-format": "2.1.1", "magic-string": "^0.30.11", "pathe": "^1.1.2" }, "dependencies": { - "@vitest/pretty-format": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.0.tgz", - "integrity": "sha512-7sxf2F3DNYatgmzXXcTh6cq+/fxwB47RIQqZJFoSH883wnVAoccSRT6g+dTKemUBo8Q5N4OYYj1EBXLuRKvp3Q==", - "dev": true, - "requires": { - "tinyrainbow": "^1.2.0" - } - }, "magic-string": { "version": "0.30.11", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.11.tgz", @@ -19126,34 +19146,23 @@ } }, "@vitest/spy": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.0.tgz", - "integrity": "sha512-IXX5NkbdgTYTog3F14i2LgnBc+20YmkXMx0IWai84mcxySUDRgm0ihbOfR4L0EVRBDFG85GjmQQEZNNKVVpkZw==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.1.tgz", + "integrity": "sha512-ZM39BnZ9t/xZ/nF4UwRH5il0Sw93QnZXd9NAZGRpIgj0yvVwPpLd702s/Cx955rGaMlyBQkZJ2Ir7qyY48VZ+g==", "dev": true, "requires": { "tinyspy": "^3.0.0" } }, "@vitest/utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.0.tgz", - "integrity": "sha512-rreyfVe0PuNqJfKYUwfPDfi6rrp0VSu0Wgvp5WBqJonP+4NvXHk48X6oBam1Lj47Hy6jbJtnMj3OcRdrkTP0tA==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.1.tgz", + "integrity": "sha512-Y6Q9TsI+qJ2CC0ZKj6VBb+T8UPz593N113nnUykqwANqhgf3QkZeHFlusgKLTqrnVHbj/XDKZcDHol+dxVT+rQ==", "dev": true, "requires": { - "@vitest/pretty-format": "2.1.0", + "@vitest/pretty-format": "2.1.1", "loupe": "^3.1.1", "tinyrainbow": "^1.2.0" - }, - "dependencies": { - "@vitest/pretty-format": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.0.tgz", - "integrity": "sha512-7sxf2F3DNYatgmzXXcTh6cq+/fxwB47RIQqZJFoSH883wnVAoccSRT6g+dTKemUBo8Q5N4OYYj1EBXLuRKvp3Q==", - "dev": true, - "requires": { - "tinyrainbow": "^1.2.0" - } - } } }, "@webassemblyjs/ast": { @@ -23084,14 +23093,14 @@ "integrity": "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==" }, "pg": { - "version": "8.12.0", - "resolved": "https://registry.npmjs.org/pg/-/pg-8.12.0.tgz", - "integrity": "sha512-A+LHUSnwnxrnL/tZ+OLfqR1SxLN3c/pgDztZ47Rpbsd4jUytsTtwQo/TLPRzPJMp/1pbhYVhH9cuSZLAajNfjQ==", + "version": "8.13.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.13.0.tgz", + "integrity": "sha512-34wkUTh3SxTClfoHB3pQ7bIMvw9dpFU1audQQeZG837fmHfHpr14n/AELVDoOYVDW2h5RDWU78tFjkD+erSBsw==", "requires": { "pg-cloudflare": "^1.1.1", - "pg-connection-string": "^2.6.4", - "pg-pool": "^3.6.2", - "pg-protocol": "^1.6.1", + "pg-connection-string": "^2.7.0", + "pg-pool": "^3.7.0", + "pg-protocol": "^1.7.0", "pg-types": "^2.1.0", "pgpass": "1.x" } @@ -23103,9 +23112,9 @@ "optional": true }, "pg-connection-string": { - "version": "2.6.4", - "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.6.4.tgz", - "integrity": "sha512-v+Z7W/0EO707aNMaAEfiGnGL9sxxumwLl2fJvCQtMn9Fxsg+lPpPkdcyBSv/KFgpGdYkMfn+EI1Or2EHjpgLCA==" + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.7.0.tgz", + "integrity": "sha512-PI2W9mv53rXJQEOb8xNR8lH7Hr+EKa6oJa38zsK0S/ky2er16ios1wLKhZyxzD7jUReiWokc9WK5nxSnC7W1TA==" }, "pg-int8": { "version": "1.0.1", @@ -23118,15 +23127,15 @@ "integrity": "sha512-BM/Thnrw5jm2kKLE5uJkXqqExRUY/toLHda65XgFTBTFYZyopbKjBe29Ii3RbkvlsMoFwD+tHeGaCjjv0gHlyw==" }, "pg-pool": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.6.2.tgz", - "integrity": "sha512-Htjbg8BlwXqSBQ9V8Vjtc+vzf/6fVUuak/3/XXKA9oxZprwW3IMDQTGHP+KDmVL7rtd+R1QjbnCFPuTHm3G4hg==", + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.7.0.tgz", + "integrity": "sha512-ZOBQForurqh4zZWjrgSwwAtzJ7QiRX0ovFkZr2klsen3Nm0aoh33Ls0fzfv3imeH/nw/O27cjdz5kzYJfeGp/g==", "requires": {} }, "pg-protocol": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.6.1.tgz", - "integrity": "sha512-jPIlvgoD63hrEuihvIg+tJhoGjUsLPn6poJY9N5CnlPd91c2T18T/9zBtLxZSb1EhYxBRoZJtzScCaWlYLtktg==" + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.7.0.tgz", + "integrity": "sha512-hTK/mE36i8fDDhgDFjy6xNOG+LCorxLG3WO17tku+ij6sVHXh1jQUJ8hYAnRhNla4QVD2H8er/FOjc/+EgC6yQ==" }, "pg-types": { "version": "2.2.0", @@ -25268,9 +25277,9 @@ "devOptional": true }, "ua-parser-js": { - "version": "1.0.38", - "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.38.tgz", - "integrity": "sha512-Aq5ppTOfvrCMgAPneW1HfWj66Xi7XL+/mIy996R1/CLS/rcyJQm6QZdsKrUeivDFQ+Oc9Wyuwor8Ze8peEoUoQ==" + "version": "1.0.39", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.39.tgz", + "integrity": "sha512-k24RCVWlEcjkdOxYmVJgeD/0a1TiSpqLg+ZalVGV9lsnr4yqu0w7tX/x2xX6G4zpkgQnRf89lxuZ1wsbjXM8lw==" }, "uglify-js": { "version": "3.17.4", @@ -25435,9 +25444,9 @@ } }, "vite-node": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.0.tgz", - "integrity": "sha512-+ybYqBVUjYyIscoLzMWodus2enQDZOpGhcU6HdOVD6n8WZdk12w1GFL3mbnxLs7hPtRtqs1Wo5YF6/Tsr6fmhg==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.1.tgz", + "integrity": "sha512-N/mGckI1suG/5wQI35XeR9rsMsPqKXzq1CdUndzVstBj/HvyxxGctwnK6WX43NGt5L3Z5tcRf83g4TITKJhPrA==", "dev": true, "requires": { "cac": "^6.7.14", @@ -25458,18 +25467,18 @@ } }, "vitest": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.0.tgz", - "integrity": "sha512-XuuEeyNkqbfr0FtAvd9vFbInSSNY1ykCQTYQ0sj9wPy4hx+1gR7gqVNdW0AX2wrrM1wWlN5fnJDjF9xG6mYRSQ==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.1.tgz", + "integrity": "sha512-97We7/VC0e9X5zBVkvt7SGQMGrRtn3KtySFQG5fpaMlS+l62eeXRQO633AYhSTC3z7IMebnPPNjGXVGNRFlxBA==", "dev": true, "requires": { - "@vitest/expect": "2.1.0", - "@vitest/mocker": "2.1.0", - "@vitest/pretty-format": "^2.1.0", - "@vitest/runner": "2.1.0", - "@vitest/snapshot": "2.1.0", - "@vitest/spy": "2.1.0", - "@vitest/utils": "2.1.0", + "@vitest/expect": "2.1.1", + "@vitest/mocker": "2.1.1", + "@vitest/pretty-format": "^2.1.1", + "@vitest/runner": "2.1.1", + "@vitest/snapshot": "2.1.1", + "@vitest/spy": "2.1.1", + "@vitest/utils": "2.1.1", "chai": "^5.1.1", "debug": "^4.3.6", "magic-string": "^0.30.11", @@ -25480,7 +25489,7 @@ "tinypool": "^1.0.0", "tinyrainbow": "^1.2.0", "vite": "^5.0.0", - "vite-node": "2.1.0", + "vite-node": "2.1.1", "why-is-node-running": "^2.3.0" }, "dependencies": { diff --git a/web/package-lock.json b/web/package-lock.json index ce30d1ccb473d..b652f58ce0624 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -759,9 +759,9 @@ } }, "node_modules/@faker-js/faker": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-9.0.0.tgz", - "integrity": "sha512-dTDHJSmz6c1OJ6HO7jiUiIb4sB20Dlkb3pxYsKm0qTXm2Bmj97rlXIhlvaFsW2rvCi+OLlwKLVSS6ZxFUVZvjQ==", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-9.0.1.tgz", + "integrity": "sha512-4mDeYIgM3By7X6t5E6eYwLAa+2h4DeZDF7thhzIg6XB76jeEvMwadYAMCFJL/R4AnEBcAUO9+gL0vhy3s+qvZA==", "dev": true, "funding": [ { @@ -1875,9 +1875,9 @@ "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==" }, "node_modules/@sveltejs/adapter-static": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-3.0.4.tgz", - "integrity": "sha512-Qm4GAHCnRXwfWG9/AtnQ7mqjyjTs7i0Opyb8H2KH9rMR7fLxqiPx/tXeoE6HHo66+72CjyOb4nFH3lrejY4vzA==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-3.0.5.tgz", + "integrity": "sha512-kFJR7RxeB6FBvrKZWAEzIALatgy11ISaaZbcPup8JdWUdrmmfUHHTJ738YHJTEfnCiiXi6aX8Q6ePY7tnSMD6Q==", "dev": true, "license": "MIT", "peerDependencies": { @@ -1885,9 +1885,9 @@ } }, "node_modules/@sveltejs/enhanced-img": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/@sveltejs/enhanced-img/-/enhanced-img-0.3.4.tgz", - "integrity": "sha512-eX+ob5uWr0bTLMKeG9nhhM84aR88hqiLiyEfWZPX7ijhk/wlmYSUX9nOiaVHh2ct1U+Ju9Hhb90Copw+ZNOB8w==", + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/@sveltejs/enhanced-img/-/enhanced-img-0.3.8.tgz", + "integrity": "sha512-n66u46ZeqHltiTm0BEjWptYmCrCY0EltEEvakmC7d5o5ZejDbOvOWm914mebbRKaP2Bezv65TNCod/wqvw/0KA==", "dev": true, "license": "MIT", "dependencies": { @@ -1901,9 +1901,9 @@ } }, "node_modules/@sveltejs/kit": { - "version": "2.5.26", - "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.5.26.tgz", - "integrity": "sha512-8l1JTIM2L+bS8ebq1E+nGjv/YSKSnD9Q19bYIUkc41vaEG2JjVUx6ikvPIJv2hkQAuqJLzoPrXlKk4KcyWOv3Q==", + "version": "2.5.28", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.5.28.tgz", + "integrity": "sha512-/O7pvFGBsQPcFa9UrW8eUC5uHTOXLsUp3SN0dY6YmRAL9nfPSrJsSJk//j5vMpinSshzUjteAFcfQTU+04Ka1w==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -2318,17 +2318,17 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.5.0.tgz", - "integrity": "sha512-lHS5hvz33iUFQKuPFGheAB84LwcJ60G8vKnEhnfcK1l8kGVLro2SFYW6K0/tj8FUhRJ0VHyg1oAfg50QGbPPHw==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.6.0.tgz", + "integrity": "sha512-UOaz/wFowmoh2G6Mr9gw60B1mm0MzUtm6Ic8G2yM1Le6gyj5Loi/N+O5mocugRGY+8OeeKmkMmbxNqUCq3B4Sg==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.5.0", - "@typescript-eslint/type-utils": "8.5.0", - "@typescript-eslint/utils": "8.5.0", - "@typescript-eslint/visitor-keys": "8.5.0", + "@typescript-eslint/scope-manager": "8.6.0", + "@typescript-eslint/type-utils": "8.6.0", + "@typescript-eslint/utils": "8.6.0", + "@typescript-eslint/visitor-keys": "8.6.0", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", @@ -2352,16 +2352,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.5.0.tgz", - "integrity": "sha512-gF77eNv0Xz2UJg/NbpWJ0kqAm35UMsvZf1GHj8D9MRFTj/V3tAciIWXfmPLsAAF/vUlpWPvUDyH1jjsr0cMVWw==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.6.0.tgz", + "integrity": "sha512-eQcbCuA2Vmw45iGfcyG4y6rS7BhWfz9MQuk409WD47qMM+bKCGQWXxvoOs1DUp+T7UBMTtRTVT+kXr7Sh4O9Ow==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "@typescript-eslint/scope-manager": "8.5.0", - "@typescript-eslint/types": "8.5.0", - "@typescript-eslint/typescript-estree": "8.5.0", - "@typescript-eslint/visitor-keys": "8.5.0", + "@typescript-eslint/scope-manager": "8.6.0", + "@typescript-eslint/types": "8.6.0", + "@typescript-eslint/typescript-estree": "8.6.0", + "@typescript-eslint/visitor-keys": "8.6.0", "debug": "^4.3.4" }, "engines": { @@ -2381,14 +2381,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.5.0.tgz", - "integrity": "sha512-06JOQ9Qgj33yvBEx6tpC8ecP9o860rsR22hWMEd12WcTRrfaFgHr2RB/CA/B+7BMhHkXT4chg2MyboGdFGawYg==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.6.0.tgz", + "integrity": "sha512-ZuoutoS5y9UOxKvpc/GkvF4cuEmpokda4wRg64JEia27wX+PysIE9q+lzDtlHHgblwUWwo5/Qn+/WyTUvDwBHw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.5.0", - "@typescript-eslint/visitor-keys": "8.5.0" + "@typescript-eslint/types": "8.6.0", + "@typescript-eslint/visitor-keys": "8.6.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2399,14 +2399,14 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.5.0.tgz", - "integrity": "sha512-N1K8Ix+lUM+cIDhL2uekVn/ZD7TZW+9/rwz8DclQpcQ9rk4sIL5CAlBC0CugWKREmDjBzI/kQqU4wkg46jWLYA==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.6.0.tgz", + "integrity": "sha512-dtePl4gsuenXVwC7dVNlb4mGDcKjDT/Ropsk4za/ouMBPplCLyznIaR+W65mvCvsyS97dymoBRrioEXI7k0XIg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "8.5.0", - "@typescript-eslint/utils": "8.5.0", + "@typescript-eslint/typescript-estree": "8.6.0", + "@typescript-eslint/utils": "8.6.0", "debug": "^4.3.4", "ts-api-utils": "^1.3.0" }, @@ -2424,9 +2424,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.5.0.tgz", - "integrity": "sha512-qjkormnQS5wF9pjSi6q60bKUHH44j2APxfh9TQRXK8wbYVeDYYdYJGIROL87LGZZ2gz3Rbmjc736qyL8deVtdw==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.6.0.tgz", + "integrity": "sha512-rojqFZGd4MQxw33SrOy09qIDS8WEldM8JWtKQLAjf/X5mGSeEFh5ixQlxssMNyPslVIk9yzWqXCsV2eFhYrYUw==", "dev": true, "license": "MIT", "engines": { @@ -2438,14 +2438,14 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.5.0.tgz", - "integrity": "sha512-vEG2Sf9P8BPQ+d0pxdfndw3xIXaoSjliG0/Ejk7UggByZPKXmJmw3GW5jV2gHNQNawBUyfahoSiCFVov0Ruf7Q==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.6.0.tgz", + "integrity": "sha512-MOVAzsKJIPIlLK239l5s06YXjNqpKTVhBVDnqUumQJja5+Y94V3+4VUFRA0G60y2jNnTVwRCkhyGQpavfsbq/g==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "@typescript-eslint/types": "8.5.0", - "@typescript-eslint/visitor-keys": "8.5.0", + "@typescript-eslint/types": "8.6.0", + "@typescript-eslint/visitor-keys": "8.6.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", @@ -2493,16 +2493,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.5.0.tgz", - "integrity": "sha512-6yyGYVL0e+VzGYp60wvkBHiqDWOpT63pdMV2CVG4LVDd5uR6q1qQN/7LafBZtAtNIn/mqXjsSeS5ggv/P0iECw==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.6.0.tgz", + "integrity": "sha512-eNp9cWnYf36NaOVjkEUznf6fEgVy1TWpE0o52e4wtojjBx7D1UV2WAWGzR+8Y5lVFtpMLPwNbC67T83DWSph4A==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.5.0", - "@typescript-eslint/types": "8.5.0", - "@typescript-eslint/typescript-estree": "8.5.0" + "@typescript-eslint/scope-manager": "8.6.0", + "@typescript-eslint/types": "8.6.0", + "@typescript-eslint/typescript-estree": "8.6.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2516,13 +2516,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.5.0.tgz", - "integrity": "sha512-yTPqMnbAZJNy2Xq2XU8AdtOW9tJIr+UQb64aXB9f3B1498Zx9JorVgFJcZpEc9UBuCCrdzKID2RGAMkYcDtZOw==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.6.0.tgz", + "integrity": "sha512-wapVFfZg9H0qOYh4grNVQiMklJGluQrOUiOhYRrQWhx7BY/+I1IYb8BczWNbbUpO+pqy0rDciv3lQH5E1bCLrg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.5.0", + "@typescript-eslint/types": "8.6.0", "eslint-visitor-keys": "^3.4.3" }, "engines": { @@ -2534,9 +2534,9 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.1.0.tgz", - "integrity": "sha512-yqCkr2nrV4o58VcVMxTVkS6Ggxzy7pmSD8JbTbhbH5PsQfUIES1QT716VUzo33wf2lX9EcWYdT3Vl2MMmjR59g==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.1.1.tgz", + "integrity": "sha512-md/A7A3c42oTT8JUHSqjP5uKTWJejzUW4jalpvs+rZ27gsURsMU8DEb+8Jf8C6Kj2gwfSHJqobDNBuoqlm0cFw==", "dev": true, "license": "MIT", "dependencies": { @@ -2557,8 +2557,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "2.1.0", - "vitest": "2.1.0" + "@vitest/browser": "2.1.1", + "vitest": "2.1.1" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -2567,14 +2567,14 @@ } }, "node_modules/@vitest/expect": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.0.tgz", - "integrity": "sha512-N3/xR4fSu0+6sVZETEtPT1orUs2+Y477JOXTcU3xKuu3uBlsgbD7/7Mz2LZ1Jr1XjwilEWlrIgSCj4N1+5ZmsQ==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.1.tgz", + "integrity": "sha512-YeueunS0HiHiQxk+KEOnq/QMzlUuOzbU1Go+PgAsHvvv3tUkJPm9xWt+6ITNTlzsMXUjmgm5T+U7KBPK2qQV6w==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "2.1.0", - "@vitest/utils": "2.1.0", + "@vitest/spy": "2.1.1", + "@vitest/utils": "2.1.1", "chai": "^5.1.1", "tinyrainbow": "^1.2.0" }, @@ -2583,9 +2583,9 @@ } }, "node_modules/@vitest/mocker": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.0.tgz", - "integrity": "sha512-ZxENovUqhzl+QiOFpagiHUNUuZ1qPd5yYTCYHomGIZOFArzn4mgX2oxZmiAItJWAaXHG6bbpb/DpSPhlk5DgtA==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.1.tgz", + "integrity": "sha512-LNN5VwOEdJqCmJ/2XJBywB11DLlkbY0ooDJW3uRX5cZyYCrc4PI/ePX0iQhE3BiEGiQmK4GE7Q/PqCkkaiPnrA==", "dev": true, "license": "MIT", "dependencies": { @@ -2597,7 +2597,7 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/spy": "2.1.0", + "@vitest/spy": "2.1.1", "msw": "^2.3.5", "vite": "^5.0.0" }, @@ -2624,13 +2624,13 @@ } }, "node_modules/@vitest/runner": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.0.tgz", - "integrity": "sha512-D9+ZiB8MbMt7qWDRJc4CRNNUlne/8E1X7dcKhZVAbcOKG58MGGYVDqAq19xlhNfMFZsW0bpVKgztBwks38Ko0w==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.1.tgz", + "integrity": "sha512-uTPuY6PWOYitIkLPidaY5L3t0JJITdGTSwBtwMjKzo5O6RCOEncz9PUN+0pDidX8kTHYjO0EwUIvhlGpnGpxmA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "2.1.0", + "@vitest/utils": "2.1.1", "pathe": "^1.1.2" }, "funding": { @@ -2638,13 +2638,13 @@ } }, "node_modules/@vitest/snapshot": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.0.tgz", - "integrity": "sha512-x69CygGMzt9VCO283K2/FYQ+nBrOj66OTKpsPykjCR4Ac3lLV+m85hj9reaIGmjBSsKzVvbxWmjWE3kF5ha3uQ==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.1.tgz", + "integrity": "sha512-BnSku1WFy7r4mm96ha2FzN99AZJgpZOWrAhtQfoxjUU5YMRpq1zmHRq7a5K9/NjqonebO7iVDla+VvZS8BOWMw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "2.1.0", + "@vitest/pretty-format": "2.1.1", "magic-string": "^0.30.11", "pathe": "^1.1.2" }, @@ -2652,23 +2652,10 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitest/snapshot/node_modules/@vitest/pretty-format": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.0.tgz", - "integrity": "sha512-7sxf2F3DNYatgmzXXcTh6cq+/fxwB47RIQqZJFoSH883wnVAoccSRT6g+dTKemUBo8Q5N4OYYj1EBXLuRKvp3Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^1.2.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, "node_modules/@vitest/spy": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.0.tgz", - "integrity": "sha512-IXX5NkbdgTYTog3F14i2LgnBc+20YmkXMx0IWai84mcxySUDRgm0ihbOfR4L0EVRBDFG85GjmQQEZNNKVVpkZw==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.1.tgz", + "integrity": "sha512-ZM39BnZ9t/xZ/nF4UwRH5il0Sw93QnZXd9NAZGRpIgj0yvVwPpLd702s/Cx955rGaMlyBQkZJ2Ir7qyY48VZ+g==", "dev": true, "license": "MIT", "dependencies": { @@ -2679,13 +2666,13 @@ } }, "node_modules/@vitest/utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.0.tgz", - "integrity": "sha512-rreyfVe0PuNqJfKYUwfPDfi6rrp0VSu0Wgvp5WBqJonP+4NvXHk48X6oBam1Lj47Hy6jbJtnMj3OcRdrkTP0tA==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.1.tgz", + "integrity": "sha512-Y6Q9TsI+qJ2CC0ZKj6VBb+T8UPz593N113nnUykqwANqhgf3QkZeHFlusgKLTqrnVHbj/XDKZcDHol+dxVT+rQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "2.1.0", + "@vitest/pretty-format": "2.1.1", "loupe": "^3.1.1", "tinyrainbow": "^1.2.0" }, @@ -2693,23 +2680,10 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitest/utils/node_modules/@vitest/pretty-format": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.0.tgz", - "integrity": "sha512-7sxf2F3DNYatgmzXXcTh6cq+/fxwB47RIQqZJFoSH883wnVAoccSRT6g+dTKemUBo8Q5N4OYYj1EBXLuRKvp3Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^1.2.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, "node_modules/@zoom-image/core": { - "version": "0.37.1", - "resolved": "https://registry.npmjs.org/@zoom-image/core/-/core-0.37.1.tgz", - "integrity": "sha512-mIJaZJBi3jvOD2gtzoSe4yhnxfvx7GcYlVTLoJE6VPawb3Ei5dvHuRRXa8/dNHtCf1Xf2RNSEm1Za2+TqkAiBQ==", + "version": "0.38.0", + "resolved": "https://registry.npmjs.org/@zoom-image/core/-/core-0.38.0.tgz", + "integrity": "sha512-rA6/qTGfsRtWRs+WfMF0dIs+Ft9GBFusxXzEqqFsQa/0iYtN0MmOiuKzXGYPcIFKTbmQW/qqk0afIBtWd9163g==", "license": "MIT", "dependencies": { "@namnode/store": "^0.1.0" @@ -2720,12 +2694,12 @@ } }, "node_modules/@zoom-image/svelte": { - "version": "0.2.21", - "resolved": "https://registry.npmjs.org/@zoom-image/svelte/-/svelte-0.2.21.tgz", - "integrity": "sha512-242xKpIaVZC/cymvNF4+JlcKwAaM9l3W2QS4DHSsnqT8xvPBgBgns+1lqOuYYKSAa85DB1UL0NMBhTg8Gk4RpA==", + "version": "0.2.22", + "resolved": "https://registry.npmjs.org/@zoom-image/svelte/-/svelte-0.2.22.tgz", + "integrity": "sha512-lExo4M511/HtkmCsBzV5f8ABs8bEMZGtIrwl1pJro77iJ+5j9Yt7KUlPs6o+Yp028T6fqGJUsOCxCNWNZn9BIg==", "license": "MIT", "dependencies": { - "@zoom-image/core": "0.37.1" + "@zoom-image/core": "0.38.0" }, "funding": { "type": "github", @@ -3864,9 +3838,9 @@ } }, "node_modules/eslint-plugin-svelte": { - "version": "2.43.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-svelte/-/eslint-plugin-svelte-2.43.0.tgz", - "integrity": "sha512-REkxQWvg2pp7QVLxQNa+dJ97xUqRe7Y2JJbSWkHSuszu0VcblZtXkPBPckkivk99y5CdLw4slqfPylL2d/X4jQ==", + "version": "2.44.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-svelte/-/eslint-plugin-svelte-2.44.0.tgz", + "integrity": "sha512-wav4MOs02vBb1WjvTCYItwJCxMkuk2Z4p+K/eyjL0N/z7ahXLP+0LtQQjiKc2ezuif7GnZLbD1F3o1VHzSvdVg==", "dev": true, "license": "MIT", "dependencies": { @@ -3880,7 +3854,7 @@ "postcss-safe-parser": "^6.0.0", "postcss-selector-parser": "^6.1.0", "semver": "^7.6.2", - "svelte-eslint-parser": "^0.41.0" + "svelte-eslint-parser": "^0.41.1" }, "engines": { "node": "^14.17.0 || >=16.0.0" @@ -7160,9 +7134,9 @@ } }, "node_modules/svelte-eslint-parser": { - "version": "0.41.0", - "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-0.41.0.tgz", - "integrity": "sha512-L6f4hOL+AbgfBIB52Z310pg1d2QjRqm7wy3kI1W6hhdhX5bvu7+f0R6w4ykp5HoDdzq+vGhIJmsisaiJDGmVfA==", + "version": "0.41.1", + "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-0.41.1.tgz", + "integrity": "sha512-08ndI6zTghzI8SuJAFpvMbA/haPSGn3xz19pjre19yYMw8Nw/wQJ2PrZBI/L8ijGTgtkWCQQiLLy+Z1tfaCwNA==", "dev": true, "license": "MIT", "dependencies": { @@ -7678,9 +7652,9 @@ "peer": true }, "node_modules/tailwindcss": { - "version": "3.4.11", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.11.tgz", - "integrity": "sha512-qhEuBcLemjSJk5ajccN9xJFtM/h0AVCPaA6C92jNP+M2J8kX+eMJHI7R2HFKUvvAsMpcfLILMCFYSeDwpMmlUg==", + "version": "3.4.12", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.12.tgz", + "integrity": "sha512-Htf/gHj2+soPb9UayUNci/Ja3d8pTmu9ONTfh4QY8r3MATTZOzmv6UYWF7ZwikEIC8okpfqmGqrmDehua8mF8w==", "dev": true, "license": "MIT", "dependencies": { @@ -8246,9 +8220,9 @@ } }, "node_modules/vite-node": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.0.tgz", - "integrity": "sha512-+ybYqBVUjYyIscoLzMWodus2enQDZOpGhcU6HdOVD6n8WZdk12w1GFL3mbnxLs7hPtRtqs1Wo5YF6/Tsr6fmhg==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.1.tgz", + "integrity": "sha512-N/mGckI1suG/5wQI35XeR9rsMsPqKXzq1CdUndzVstBj/HvyxxGctwnK6WX43NGt5L3Z5tcRf83g4TITKJhPrA==", "dev": true, "license": "MIT", "dependencies": { @@ -8282,19 +8256,19 @@ } }, "node_modules/vitest": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.0.tgz", - "integrity": "sha512-XuuEeyNkqbfr0FtAvd9vFbInSSNY1ykCQTYQ0sj9wPy4hx+1gR7gqVNdW0AX2wrrM1wWlN5fnJDjF9xG6mYRSQ==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.1.tgz", + "integrity": "sha512-97We7/VC0e9X5zBVkvt7SGQMGrRtn3KtySFQG5fpaMlS+l62eeXRQO633AYhSTC3z7IMebnPPNjGXVGNRFlxBA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "2.1.0", - "@vitest/mocker": "2.1.0", - "@vitest/pretty-format": "^2.1.0", - "@vitest/runner": "2.1.0", - "@vitest/snapshot": "2.1.0", - "@vitest/spy": "2.1.0", - "@vitest/utils": "2.1.0", + "@vitest/expect": "2.1.1", + "@vitest/mocker": "2.1.1", + "@vitest/pretty-format": "^2.1.1", + "@vitest/runner": "2.1.1", + "@vitest/snapshot": "2.1.1", + "@vitest/spy": "2.1.1", + "@vitest/utils": "2.1.1", "chai": "^5.1.1", "debug": "^4.3.6", "magic-string": "^0.30.11", @@ -8305,7 +8279,7 @@ "tinypool": "^1.0.0", "tinyrainbow": "^1.2.0", "vite": "^5.0.0", - "vite-node": "2.1.0", + "vite-node": "2.1.1", "why-is-node-running": "^2.3.0" }, "bin": { @@ -8320,8 +8294,8 @@ "peerDependencies": { "@edge-runtime/vm": "*", "@types/node": "^18.0.0 || >=20.0.0", - "@vitest/browser": "2.1.0", - "@vitest/ui": "2.1.0", + "@vitest/browser": "2.1.1", + "@vitest/ui": "2.1.1", "happy-dom": "*", "jsdom": "*" }, From e0fa3cdbc75817226bebe6eb58dd9a069e112d39 Mon Sep 17 00:00:00 2001 From: Fynn Petersen-Frey <10599762+fyfrey@users.noreply.github.com> Date: Tue, 24 Sep 2024 08:24:48 +0200 Subject: [PATCH 080/123] refactor(mobile): more repositories (#12879) * ExifInfoRepository * ActivityApiRepository * initial AssetApiRepository --- mobile/analysis_options.yaml | 9 +-- .../interfaces/activity_api.interface.dart | 16 ++++ mobile/lib/interfaces/asset.interface.dart | 12 +++ .../lib/interfaces/asset_api.interface.dart | 16 ++++ .../lib/interfaces/exif_info.interface.dart | 9 +++ .../lib/models/activities/activity.model.dart | 17 ++-- .../providers/activity_service.provider.dart | 4 +- .../activity_statistics.provider.dart | 2 +- .../repositories/activity_api.repository.dart | 67 +++++++++++++++ .../repositories/album_api.repository.dart | 25 +++--- mobile/lib/repositories/asset.repository.dart | 80 ++++++++++++++++++ .../repositories/asset_api.repository.dart | 25 ++++++ .../lib/repositories/base_api.repository.dart | 11 +++ .../repositories/exif_info.repository.dart | 28 +++++++ mobile/lib/services/activity.service.dart | 48 ++++------- mobile/lib/services/asset.service.dart | 52 ++++++++++++ .../services/asset_description.service.dart | 66 --------------- .../services/backup_verification.service.dart | 81 +++++++------------ .../asset_viewer/description_input.dart | 10 ++- .../activity_statistics_provider_test.dart | 7 +- 20 files changed, 392 insertions(+), 193 deletions(-) create mode 100644 mobile/lib/interfaces/activity_api.interface.dart create mode 100644 mobile/lib/interfaces/asset_api.interface.dart create mode 100644 mobile/lib/interfaces/exif_info.interface.dart create mode 100644 mobile/lib/repositories/activity_api.repository.dart create mode 100644 mobile/lib/repositories/asset_api.repository.dart create mode 100644 mobile/lib/repositories/base_api.repository.dart create mode 100644 mobile/lib/repositories/exif_info.repository.dart delete mode 100644 mobile/lib/services/asset_description.service.dart diff --git a/mobile/analysis_options.yaml b/mobile/analysis_options.yaml index 8f9d41d73610e..e996a54372b6e 100644 --- a/mobile/analysis_options.yaml +++ b/mobile/analysis_options.yaml @@ -64,7 +64,7 @@ custom_lint: allowed: # required / wanted - lib/entities/*.entity.dart - - lib/repositories/{album,asset,backup,user}.repository.dart + - lib/repositories/{album,asset,backup,exif_info,user}.repository.dart # acceptable exceptions for the time being - integration_test/test_utils/general_helper.dart - lib/main.dart @@ -75,7 +75,7 @@ custom_lint: - lib/pages/common/{album_asset_selection,gallery_viewer}.page.dart - lib/providers/{archive,asset,authentication,db,favorite,partner,trash,user}.provider.dart - lib/providers/{album/album,album/shared_album,asset_viewer/asset_stack,asset_viewer/render_list,backup/backup,backup/manual_upload,search/all_motion_photos,search/recently_added_asset}.provider.dart - - lib/services/{asset,asset_description,background,backup,backup_verification,hash,immich_logger,memory,partner,person,search,stack,sync,user}.service.dart + - lib/services/{asset,background,backup,hash,immich_logger,memory,partner,person,search,stack,sync,user}.service.dart - lib/widgets/asset_grid/{asset_grid_data_structure,thumbnail_image}.dart - import_rule_openapi: @@ -83,13 +83,12 @@ custom_lint: restrict: package:openapi allowed: # requried / wanted - - lib/repositories/album_api.repository.dart + - lib/repositories/*_api.repository.dart # acceptable exceptions for the time being - lib/entities/{album,asset,exif_info,user}.entity.dart # to convert DTOs to entities - lib/utils/{image_url_builder,openapi_patching}.dart # utils are fine - test/modules/utils/openapi_patching_test.dart # filename is self-explanatory... # refactor - - lib/models/activities/activity.model.dart - lib/models/map/map_marker.model.dart - lib/models/search/search_filter.model.dart - lib/models/server_info/server_{config,disk_info,features,version}.model.dart @@ -102,7 +101,7 @@ custom_lint: - lib/providers/search/{people,search,search_filter}.provider.dart - lib/providers/websocket.provider.dart - lib/routing/auth_guard.dart - - lib/services/{activity,api,asset,asset_description,backup,memory,oauth,partner,person,search,shared_link,stack,trash,user}.service.dart + - lib/services/{api,asset,backup,memory,oauth,partner,person,search,shared_link,stack,trash,user}.service.dart - lib/widgets/album/album_thumbnail_listtile.dart - lib/widgets/forms/login/login_form.dart - lib/widgets/search/search_filter/{camera_picker,location_picker,people_picker}.dart diff --git a/mobile/lib/interfaces/activity_api.interface.dart b/mobile/lib/interfaces/activity_api.interface.dart new file mode 100644 index 0000000000000..99aef6f4d4668 --- /dev/null +++ b/mobile/lib/interfaces/activity_api.interface.dart @@ -0,0 +1,16 @@ +import 'package:immich_mobile/models/activities/activity.model.dart'; + +abstract interface class IActivityApiRepository { + Future> getAll( + String albumId, { + String? assetId, + }); + Future create( + String albumId, + ActivityType type, { + String? assetId, + String? comment, + }); + Future delete(String id); + Future getStats(String albumId, {String? assetId}); +} diff --git a/mobile/lib/interfaces/asset.interface.dart b/mobile/lib/interfaces/asset.interface.dart index 2574e52112a9a..98f4c7687cdfe 100644 --- a/mobile/lib/interfaces/asset.interface.dart +++ b/mobile/lib/interfaces/asset.interface.dart @@ -7,4 +7,16 @@ abstract interface class IAssetRepository { Future> getAllByRemoteId(Iterable ids); Future> getByAlbum(Album album, {User? notOwnedBy}); Future deleteById(List ids); + Future> getAll({ + required int ownerId, + bool? remote, + int limit = 100, + }); + + Future> getMatches({ + required List assets, + required int ownerId, + bool? remote, + int limit = 100, + }); } diff --git a/mobile/lib/interfaces/asset_api.interface.dart b/mobile/lib/interfaces/asset_api.interface.dart new file mode 100644 index 0000000000000..201c85cea7324 --- /dev/null +++ b/mobile/lib/interfaces/asset_api.interface.dart @@ -0,0 +1,16 @@ +import 'package:immich_mobile/entities/asset.entity.dart'; + +abstract interface class IAssetApiRepository { + // Future get(String id); + + // Future> getAll(); + + // Future create(Asset asset); + + Future update( + String id, { + String? description, + }); + + // Future delete(String id); +} diff --git a/mobile/lib/interfaces/exif_info.interface.dart b/mobile/lib/interfaces/exif_info.interface.dart new file mode 100644 index 0000000000000..fa8ca08f9d55f --- /dev/null +++ b/mobile/lib/interfaces/exif_info.interface.dart @@ -0,0 +1,9 @@ +import 'package:immich_mobile/entities/exif_info.entity.dart'; + +abstract interface class IExifInfoRepository { + Future get(int id); + + Future update(ExifInfo exifInfo); + + Future delete(int id); +} diff --git a/mobile/lib/models/activities/activity.model.dart b/mobile/lib/models/activities/activity.model.dart index 6adb80dca9233..4702753f41cdd 100644 --- a/mobile/lib/models/activities/activity.model.dart +++ b/mobile/lib/models/activities/activity.model.dart @@ -1,5 +1,4 @@ import 'package:immich_mobile/entities/user.entity.dart'; -import 'package:openapi/api.dart'; enum ActivityType { comment, like } @@ -38,16 +37,6 @@ class Activity { ); } - Activity.fromDto(ActivityResponseDto dto) - : id = dto.id, - assetId = dto.assetId, - comment = dto.comment, - createdAt = dto.createdAt, - type = dto.type == ReactionType.comment - ? ActivityType.comment - : ActivityType.like, - user = User.fromSimpleUserDto(dto.user); - @override String toString() { return 'Activity(id: $id, assetId: $assetId, comment: $comment, createdAt: $createdAt, type: $type, user: $user)'; @@ -75,3 +64,9 @@ class Activity { user.hashCode; } } + +class ActivityStats { + final int comments; + + const ActivityStats({required this.comments}); +} diff --git a/mobile/lib/providers/activity_service.provider.dart b/mobile/lib/providers/activity_service.provider.dart index dcfaac883fd7c..6bd139c56504e 100644 --- a/mobile/lib/providers/activity_service.provider.dart +++ b/mobile/lib/providers/activity_service.provider.dart @@ -1,9 +1,9 @@ +import 'package:immich_mobile/repositories/activity_api.repository.dart'; import 'package:immich_mobile/services/activity.service.dart'; -import 'package:immich_mobile/providers/api.provider.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'activity_service.provider.g.dart'; @riverpod ActivityService activityService(ActivityServiceRef ref) => - ActivityService(ref.watch(apiServiceProvider)); + ActivityService(ref.watch(activityApiRepositoryProvider)); diff --git a/mobile/lib/providers/activity_statistics.provider.dart b/mobile/lib/providers/activity_statistics.provider.dart index afb43e8cba3d3..b1d2b4b9871f4 100644 --- a/mobile/lib/providers/activity_statistics.provider.dart +++ b/mobile/lib/providers/activity_statistics.provider.dart @@ -11,7 +11,7 @@ class ActivityStatistics extends _$ActivityStatistics { ref .watch(activityServiceProvider) .getStatistics(albumId, assetId: assetId) - .then((comments) => state = comments); + .then((stats) => state = stats.comments); return 0; } diff --git a/mobile/lib/repositories/activity_api.repository.dart b/mobile/lib/repositories/activity_api.repository.dart new file mode 100644 index 0000000000000..0b1b4d99f36df --- /dev/null +++ b/mobile/lib/repositories/activity_api.repository.dart @@ -0,0 +1,67 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/interfaces/activity_api.interface.dart'; +import 'package:immich_mobile/models/activities/activity.model.dart'; +import 'package:immich_mobile/providers/api.provider.dart'; +import 'package:immich_mobile/repositories/base_api.repository.dart'; +import 'package:openapi/api.dart'; + +final activityApiRepositoryProvider = Provider( + (ref) => ActivityApiRepository(ref.watch(apiServiceProvider).activitiesApi), +); + +class ActivityApiRepository extends BaseApiRepository + implements IActivityApiRepository { + final ActivitiesApi _api; + + ActivityApiRepository(this._api); + + @override + Future> getAll(String albumId, {String? assetId}) async { + final response = + await checkNull(_api.getActivities(albumId, assetId: assetId)); + return response.map(_toActivity).toList(); + } + + @override + Future create( + String albumId, + ActivityType type, { + String? assetId, + String? comment, + }) async { + final dto = ActivityCreateDto( + albumId: albumId, + type: type == ActivityType.comment + ? ReactionType.comment + : ReactionType.like, + assetId: assetId, + comment: comment, + ); + final response = await checkNull(_api.createActivity(dto)); + return _toActivity(response); + } + + @override + Future delete(String id) { + return checkNull(_api.deleteActivity(id)); + } + + @override + Future getStats(String albumId, {String? assetId}) async { + final response = + await checkNull(_api.getActivityStatistics(albumId, assetId: assetId)); + return ActivityStats(comments: response.comments); + } + + static Activity _toActivity(ActivityResponseDto dto) => Activity( + id: dto.id, + createdAt: dto.createdAt, + type: dto.type == ReactionType.comment + ? ActivityType.comment + : ActivityType.like, + user: User.fromSimpleUserDto(dto.user), + assetId: dto.assetId, + comment: dto.comment, + ); +} diff --git a/mobile/lib/repositories/album_api.repository.dart b/mobile/lib/repositories/album_api.repository.dart index 6b7865f8e4573..0e27e44684a67 100644 --- a/mobile/lib/repositories/album_api.repository.dart +++ b/mobile/lib/repositories/album_api.repository.dart @@ -1,30 +1,31 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/constants/errors.dart'; import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/interfaces/album_api.interface.dart'; import 'package:immich_mobile/providers/api.provider.dart'; +import 'package:immich_mobile/repositories/base_api.repository.dart'; import 'package:openapi/api.dart'; final albumApiRepositoryProvider = Provider( (ref) => AlbumApiRepository(ref.watch(apiServiceProvider).albumsApi), ); -class AlbumApiRepository implements IAlbumApiRepository { +class AlbumApiRepository extends BaseApiRepository + implements IAlbumApiRepository { final AlbumsApi _api; AlbumApiRepository(this._api); @override Future get(String id) async { - final dto = await _checkNull(_api.getAlbumInfo(id)); + final dto = await checkNull(_api.getAlbumInfo(id)); return _toAlbum(dto); } @override Future> getAll({bool? shared}) async { - final dtos = await _checkNull(_api.getAllAlbums(shared: shared)); + final dtos = await checkNull(_api.getAllAlbums(shared: shared)); return dtos.map(_toAlbum).toList().cast(); } @@ -37,7 +38,7 @@ class AlbumApiRepository implements IAlbumApiRepository { final users = sharedUserIds.map( (id) => AlbumUserCreateDto(userId: id, role: AlbumUserRole.editor), ); - final responseDto = await _checkNull( + final responseDto = await checkNull( _api.createAlbum( CreateAlbumDto( albumName: name, @@ -57,7 +58,7 @@ class AlbumApiRepository implements IAlbumApiRepository { String? description, bool? activityEnabled, }) async { - final response = await _checkNull( + final response = await checkNull( _api.updateAlbumInfo( albumId, UpdateAlbumDto( @@ -81,7 +82,7 @@ class AlbumApiRepository implements IAlbumApiRepository { String albumId, Iterable assetIds, ) async { - final response = await _checkNull( + final response = await checkNull( _api.addAssetsToAlbum( albumId, BulkIdsDto(ids: assetIds.toList()), @@ -106,7 +107,7 @@ class AlbumApiRepository implements IAlbumApiRepository { String albumId, Iterable assetIds, ) async { - final response = await _checkNull( + final response = await checkNull( _api.removeAssetFromAlbum( albumId, BulkIdsDto(ids: assetIds.toList()), @@ -127,7 +128,7 @@ class AlbumApiRepository implements IAlbumApiRepository { Future addUsers(String albumId, Iterable userIds) async { final albumUsers = userIds.map((userId) => AlbumUserAddDto(userId: userId)).toList(); - final response = await _checkNull( + final response = await checkNull( _api.addUsersToAlbum( albumId, AddUsersDto(albumUsers: albumUsers), @@ -141,12 +142,6 @@ class AlbumApiRepository implements IAlbumApiRepository { return _api.removeUserFromAlbum(albumId, userId); } - static Future _checkNull(Future future) async { - final response = await future; - if (response == null) throw NoResponseDtoError(); - return response; - } - static Album _toAlbum(AlbumResponseDto dto) { final Album album = Album( remoteId: dto.id, diff --git a/mobile/lib/repositories/asset.repository.dart b/mobile/lib/repositories/asset.repository.dart index 8ec028f7288de..c6012af3717eb 100644 --- a/mobile/lib/repositories/asset.repository.dart +++ b/mobile/lib/repositories/asset.repository.dart @@ -35,4 +35,84 @@ class AssetRepository implements IAssetRepository { @override Future> getAllByRemoteId(Iterable ids) => _db.assets.getAllByRemoteId(ids); + + @override + Future> getAll({ + required int ownerId, + bool? remote, + int limit = 100, + }) { + if (remote == null) { + return _db.assets + .where() + .ownerIdEqualToAnyChecksum(ownerId) + .limit(limit) + .findAll(); + } + final QueryBuilder query; + if (remote) { + query = _db.assets + .where() + .localIdIsNull() + .filter() + .remoteIdIsNotNull() + .ownerIdEqualTo(ownerId); + } else { + query = _db.assets + .where() + .remoteIdIsNull() + .filter() + .localIdIsNotNull() + .ownerIdEqualTo(ownerId); + } + + return query.limit(limit).findAll(); + } + + @override + Future> getMatches({ + required List assets, + required int ownerId, + bool? remote, + int limit = 100, + }) { + final QueryBuilder query; + if (remote == null) { + query = _db.assets.filter().remoteIdIsNotNull().or().localIdIsNotNull(); + } else if (remote) { + query = _db.assets.where().localIdIsNull().filter().remoteIdIsNotNull(); + } else { + query = _db.assets.where().remoteIdIsNull().filter().localIdIsNotNull(); + } + return _getMatchesImpl(query, ownerId, assets, limit); + } } + +Future> _getMatchesImpl( + QueryBuilder query, + int ownerId, + List assets, + int limit, +) => + query + .ownerIdEqualTo(ownerId) + .anyOf( + assets, + (q, Asset a) => q + .fileNameEqualTo(a.fileName) + .and() + .durationInSecondsEqualTo(a.durationInSeconds) + .and() + .fileCreatedAtBetween( + a.fileCreatedAt.subtract(const Duration(hours: 12)), + a.fileCreatedAt.add(const Duration(hours: 12)), + ) + .and() + .not() + .checksumEqualTo(a.checksum), + ) + .sortByFileName() + .thenByFileCreatedAt() + .thenByFileModifiedAt() + .limit(limit) + .findAll(); diff --git a/mobile/lib/repositories/asset_api.repository.dart b/mobile/lib/repositories/asset_api.repository.dart new file mode 100644 index 0000000000000..3ad0e1cba0d19 --- /dev/null +++ b/mobile/lib/repositories/asset_api.repository.dart @@ -0,0 +1,25 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/interfaces/asset_api.interface.dart'; +import 'package:immich_mobile/providers/api.provider.dart'; +import 'package:immich_mobile/repositories/base_api.repository.dart'; +import 'package:openapi/api.dart'; + +final assetApiRepositoryProvider = Provider( + (ref) => AssetApiRepository(ref.watch(apiServiceProvider).assetsApi), +); + +class AssetApiRepository extends BaseApiRepository + implements IAssetApiRepository { + final AssetsApi _api; + + AssetApiRepository(this._api); + + @override + Future update(String id, {String? description}) async { + final response = await checkNull( + _api.updateAsset(id, UpdateAssetDto(description: description)), + ); + return Asset.remote(response); + } +} diff --git a/mobile/lib/repositories/base_api.repository.dart b/mobile/lib/repositories/base_api.repository.dart new file mode 100644 index 0000000000000..418cba84f886c --- /dev/null +++ b/mobile/lib/repositories/base_api.repository.dart @@ -0,0 +1,11 @@ +import 'package:flutter/foundation.dart'; +import 'package:immich_mobile/constants/errors.dart'; + +abstract class BaseApiRepository { + @protected + Future checkNull(Future future) async { + final response = await future; + if (response == null) throw NoResponseDtoError(); + return response; + } +} diff --git a/mobile/lib/repositories/exif_info.repository.dart b/mobile/lib/repositories/exif_info.repository.dart new file mode 100644 index 0000000000000..a165e98bdbfe3 --- /dev/null +++ b/mobile/lib/repositories/exif_info.repository.dart @@ -0,0 +1,28 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/exif_info.entity.dart'; +import 'package:immich_mobile/interfaces/exif_info.interface.dart'; +import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:isar/isar.dart'; + +final exifInfoRepositoryProvider = + Provider((ref) => ExifInfoRepository(ref.watch(dbProvider))); + +class ExifInfoRepository implements IExifInfoRepository { + final Isar _db; + + ExifInfoRepository( + this._db, + ); + + @override + Future delete(int id) => _db.exifInfos.delete(id); + + @override + Future get(int id) => _db.exifInfos.get(id); + + @override + Future update(ExifInfo exifInfo) async { + await _db.writeTxn(() => _db.exifInfos.put(exifInfo)); + return exifInfo; + } +} diff --git a/mobile/lib/services/activity.service.dart b/mobile/lib/services/activity.service.dart index 58af26e204663..5496041416558 100644 --- a/mobile/lib/services/activity.service.dart +++ b/mobile/lib/services/activity.service.dart @@ -1,41 +1,31 @@ -import 'package:immich_mobile/constants/errors.dart'; +import 'package:immich_mobile/interfaces/activity_api.interface.dart'; import 'package:immich_mobile/mixins/error_logger.mixin.dart'; import 'package:immich_mobile/models/activities/activity.model.dart'; -import 'package:immich_mobile/services/api.service.dart'; import 'package:logging/logging.dart'; -import 'package:openapi/api.dart'; class ActivityService with ErrorLoggerMixin { - final ApiService _apiService; + final IActivityApiRepository _activityApiRepository; @override final Logger logger = Logger("ActivityService"); - ActivityService(this._apiService); + ActivityService(this._activityApiRepository); Future> getAllActivities( String albumId, { String? assetId, }) async { return logError( - () async { - final list = await _apiService.activitiesApi - .getActivities(albumId, assetId: assetId); - return list != null ? list.map(Activity.fromDto).toList() : []; - }, + () => _activityApiRepository.getAll(albumId, assetId: assetId), defaultValue: [], errorMessage: "Failed to get all activities for album $albumId", ); } - Future getStatistics(String albumId, {String? assetId}) async { + Future getStatistics(String albumId, {String? assetId}) async { return logError( - () async { - final dto = await _apiService.activitiesApi - .getActivityStatistics(albumId, assetId: assetId); - return dto?.comments ?? 0; - }, - defaultValue: 0, + () => _activityApiRepository.getStats(albumId, assetId: assetId), + defaultValue: const ActivityStats(comments: 0), errorMessage: "Failed to statistics for album $albumId", ); } @@ -43,7 +33,7 @@ class ActivityService with ErrorLoggerMixin { Future removeActivity(String id) async { return logError( () async { - await _apiService.activitiesApi.deleteActivity(id); + await _activityApiRepository.delete(id); return true; }, defaultValue: false, @@ -58,22 +48,12 @@ class ActivityService with ErrorLoggerMixin { String? comment, }) async { return guardError( - () async { - final dto = await _apiService.activitiesApi.createActivity( - ActivityCreateDto( - albumId: albumId, - type: type == ActivityType.comment - ? ReactionType.comment - : ReactionType.like, - assetId: assetId, - comment: comment, - ), - ); - if (dto != null) { - return Activity.fromDto(dto); - } - throw NoResponseDtoError(); - }, + () => _activityApiRepository.create( + albumId, + type, + assetId: assetId, + comment: comment, + ), errorMessage: "Failed to create $type for album $albumId", ); } diff --git a/mobile/lib/services/asset.service.dart b/mobile/lib/services/asset.service.dart index 90c46ae90a6d9..262040026e6a2 100644 --- a/mobile/lib/services/asset.service.dart +++ b/mobile/lib/services/asset.service.dart @@ -9,9 +9,13 @@ import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/etag.entity.dart'; import 'package:immich_mobile/entities/exif_info.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/interfaces/asset_api.interface.dart'; +import 'package:immich_mobile/interfaces/exif_info.interface.dart'; import 'package:immich_mobile/models/backup/backup_candidate.model.dart'; import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:immich_mobile/repositories/asset_api.repository.dart'; +import 'package:immich_mobile/repositories/exif_info.repository.dart'; import 'package:immich_mobile/services/album.service.dart'; import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/backup.service.dart'; @@ -24,6 +28,8 @@ import 'package:openapi/api.dart'; final assetServiceProvider = Provider( (ref) => AssetService( + ref.watch(assetApiRepositoryProvider), + ref.watch(exifInfoRepositoryProvider), ref.watch(apiServiceProvider), ref.watch(syncServiceProvider), ref.watch(userServiceProvider), @@ -34,6 +40,8 @@ final assetServiceProvider = Provider( ); class AssetService { + final IAssetApiRepository _assetApiRepository; + final IExifInfoRepository _exifInfoRepository; final ApiService _apiService; final SyncService _syncService; final UserService _userService; @@ -43,6 +51,8 @@ class AssetService { final Isar _db; AssetService( + this._assetApiRepository, + this._exifInfoRepository, this._apiService, this._syncService, this._userService, @@ -342,4 +352,46 @@ class AssetService { log.severe("Error while syncing uploaded asset to albums", error, stack); } } + + Future setDescription( + Asset asset, + String newDescription, + ) async { + final remoteAssetId = asset.remoteId; + final localExifId = asset.exifInfo?.id; + + // Guard [remoteAssetId] and [localExifId] null + if (remoteAssetId == null || localExifId == null) { + return; + } + + final result = await _assetApiRepository.update( + remoteAssetId, + description: newDescription, + ); + + final description = result.exifInfo?.description; + + if (description != null) { + var exifInfo = await _exifInfoRepository.get(localExifId); + + if (exifInfo != null) { + exifInfo.description = description; + await _exifInfoRepository.update(exifInfo); + } + } + } + + Future getDescription(Asset asset) async { + final localExifId = asset.exifInfo?.id; + + // Guard [remoteAssetId] and [localExifId] null + if (localExifId == null) { + return ""; + } + + final exifInfo = await _exifInfoRepository.get(localExifId); + + return exifInfo?.description ?? ""; + } } diff --git a/mobile/lib/services/asset_description.service.dart b/mobile/lib/services/asset_description.service.dart deleted file mode 100644 index 196e29dc6a97d..0000000000000 --- a/mobile/lib/services/asset_description.service.dart +++ /dev/null @@ -1,66 +0,0 @@ -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:immich_mobile/entities/exif_info.entity.dart'; -import 'package:immich_mobile/providers/api.provider.dart'; -import 'package:immich_mobile/providers/db.provider.dart'; -import 'package:immich_mobile/services/api.service.dart'; -import 'package:isar/isar.dart'; -import 'package:openapi/api.dart'; - -class AssetDescriptionService { - AssetDescriptionService(this._db, this._api); - - final Isar _db; - final ApiService _api; - - Future setDescription( - Asset asset, - String newDescription, - ) async { - final remoteAssetId = asset.remoteId; - final localExifId = asset.exifInfo?.id; - - // Guard [remoteAssetId] and [localExifId] null - if (remoteAssetId == null || localExifId == null) { - return; - } - - final result = await _api.assetsApi.updateAsset( - remoteAssetId, - UpdateAssetDto(description: newDescription), - ); - - final description = result?.exifInfo?.description; - - if (description != null) { - var exifInfo = await _db.exifInfos.get(localExifId); - - if (exifInfo != null) { - exifInfo.description = description; - await _db.writeTxn( - () => _db.exifInfos.put(exifInfo), - ); - } - } - } - - String getAssetDescription(Asset asset) { - final localExifId = asset.exifInfo?.id; - - // Guard [remoteAssetId] and [localExifId] null - if (localExifId == null) { - return ""; - } - - final exifInfo = _db.exifInfos.getSync(localExifId); - - return exifInfo?.description ?? ""; - } -} - -final assetDescriptionServiceProvider = Provider( - (ref) => AssetDescriptionService( - ref.watch(dbProvider), - ref.watch(apiServiceProvider), - ), -); diff --git a/mobile/lib/services/backup_verification.service.dart b/mobile/lib/services/backup_verification.service.dart index 66a61d29142cb..da9d8da1649e4 100644 --- a/mobile/lib/services/backup_verification.service.dart +++ b/mobile/lib/services/backup_verification.service.dart @@ -8,41 +8,46 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/exif_info.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/interfaces/asset.interface.dart'; +import 'package:immich_mobile/interfaces/exif_info.interface.dart'; import 'package:immich_mobile/interfaces/file_media.interface.dart'; -import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:immich_mobile/repositories/asset.repository.dart'; +import 'package:immich_mobile/repositories/exif_info.repository.dart'; import 'package:immich_mobile/repositories/file_media.repository.dart'; import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/utils/diff.dart'; -import 'package:isar/isar.dart'; /// Finds duplicates originating from missing EXIF information class BackupVerificationService { - final Isar _db; final IFileMediaRepository _fileMediaRepository; + final IAssetRepository _assetRepository; + final IExifInfoRepository _exifInfoRepository; - BackupVerificationService(this._db, this._fileMediaRepository); + BackupVerificationService( + this._fileMediaRepository, + this._assetRepository, + this._exifInfoRepository, + ); /// Returns at most [limit] assets that were backed up without exif Future> findWronglyBackedUpAssets({int limit = 100}) async { final owner = Store.get(StoreKey.currentUser).isarId; - final List onlyLocal = await _db.assets - .where() - .remoteIdIsNull() - .filter() - .ownerIdEqualTo(owner) - .localIdIsNotNull() - .findAll(); - final List remoteMatches = await _getMatches( - _db.assets.where().localIdIsNull().filter().remoteIdIsNotNull(), - owner, - onlyLocal, - limit, + final List onlyLocal = await _assetRepository.getAll( + ownerId: owner, + remote: false, + limit: limit, ); - final List localMatches = await _getMatches( - _db.assets.where().remoteIdIsNull().filter().localIdIsNotNull(), - owner, - remoteMatches, - limit, + final List remoteMatches = await _assetRepository.getMatches( + assets: onlyLocal, + ownerId: owner, + remote: true, + limit: limit, + ); + final List localMatches = await _assetRepository.getMatches( + assets: remoteMatches, + ownerId: owner, + remote: false, + limit: limit, ); final List deleteCandidates = [], originals = []; @@ -52,7 +57,7 @@ class BackupVerificationService { localMatches, compare: (a, b) => a.fileName.compareTo(b.fileName), both: (a, b) async { - a.exifInfo = await _db.exifInfos.get(a.id); + a.exifInfo = await _exifInfoRepository.get(a.id); deleteCandidates.add(a); originals.add(b); return false; @@ -192,35 +197,6 @@ class BackupVerificationService { return bytes.buffer.asUint64List(start); } - static Future> _getMatches( - QueryBuilder query, - int ownerId, - List assets, - int limit, - ) => - query - .ownerIdEqualTo(ownerId) - .anyOf( - assets, - (q, Asset a) => q - .fileNameEqualTo(a.fileName) - .and() - .durationInSecondsEqualTo(a.durationInSeconds) - .and() - .fileCreatedAtBetween( - a.fileCreatedAt.subtract(const Duration(hours: 12)), - a.fileCreatedAt.add(const Duration(hours: 12)), - ) - .and() - .not() - .checksumEqualTo(a.checksum), - ) - .sortByFileName() - .thenByFileCreatedAt() - .thenByFileModifiedAt() - .limit(limit) - .findAll(); - static bool _sameExceptTimeZone(DateTime a, DateTime b) { final ms = a.isAfter(b) ? a.millisecondsSinceEpoch - b.millisecondsSinceEpoch @@ -233,7 +209,8 @@ class BackupVerificationService { final backupVerificationServiceProvider = Provider( (ref) => BackupVerificationService( - ref.watch(dbProvider), ref.watch(fileMediaRepositoryProvider), + ref.watch(assetRepositoryProvider), + ref.watch(exifInfoRepositoryProvider), ), ); diff --git a/mobile/lib/widgets/asset_viewer/description_input.dart b/mobile/lib/widgets/asset_viewer/description_input.dart index 18ef394e2d266..3fdd40130a710 100644 --- a/mobile/lib/widgets/asset_viewer/description_input.dart +++ b/mobile/lib/widgets/asset_viewer/description_input.dart @@ -8,7 +8,7 @@ import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/providers/asset.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; -import 'package:immich_mobile/services/asset_description.service.dart'; +import 'package:immich_mobile/services/asset.service.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:logging/logging.dart'; @@ -29,14 +29,16 @@ class DescriptionInput extends HookConsumerWidget { final focusNode = useFocusNode(); final isFocus = useState(false); final isTextEmpty = useState(controller.text.isEmpty); - final descriptionProvider = ref.watch(assetDescriptionServiceProvider); + final assetService = ref.watch(assetServiceProvider); final owner = ref.watch(currentUserProvider); final hasError = useState(false); final assetWithExif = ref.watch(assetDetailProvider(asset)); useEffect( () { - controller.text = descriptionProvider.getAssetDescription(asset); + assetService + .getDescription(asset) + .then((value) => controller.text = value); return null; }, [assetWithExif.value], @@ -45,7 +47,7 @@ class DescriptionInput extends HookConsumerWidget { submitDescription(String description) async { hasError.value = false; try { - await descriptionProvider.setDescription( + await assetService.setDescription( asset, description, ); diff --git a/mobile/test/modules/activity/activity_statistics_provider_test.dart b/mobile/test/modules/activity/activity_statistics_provider_test.dart index 9edabcc0d0b66..0216528ddd31f 100644 --- a/mobile/test/modules/activity/activity_statistics_provider_test.dart +++ b/mobile/test/modules/activity/activity_statistics_provider_test.dart @@ -1,5 +1,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/models/activities/activity.model.dart'; import 'package:immich_mobile/providers/activity_service.provider.dart'; import 'package:immich_mobile/providers/activity_statistics.provider.dart'; import 'package:mocktail/mocktail.dart'; @@ -25,7 +26,7 @@ void main() { test('Returns the proper count family', () async { when( () => activityMock.getStatistics('test-album', assetId: 'test-asset'), - ).thenAnswer((_) async => 5); + ).thenAnswer((_) async => const ActivityStats(comments: 5)); // Read here to make the getStatistics call container.read(activityStatisticsProvider('test-album', 'test-asset')); @@ -50,7 +51,7 @@ void main() { test('Adds activity', () async { when( () => activityMock.getStatistics('test-album'), - ).thenAnswer((_) async => 10); + ).thenAnswer((_) async => const ActivityStats(comments: 10)); final provider = activityStatisticsProvider('test-album'); container.listen( @@ -71,7 +72,7 @@ void main() { test('Removes activity', () async { when( () => activityMock.getStatistics('new-album', assetId: 'test-asset'), - ).thenAnswer((_) async => 10); + ).thenAnswer((_) async => const ActivityStats(comments: 10)); final provider = activityStatisticsProvider('new-album', 'test-asset'); container.listen( From 202082f62ee6ad4f9a4a8fb19e2c3b486ec7bf9e Mon Sep 17 00:00:00 2001 From: Fynn Petersen-Frey <10599762+fyfrey@users.noreply.github.com> Date: Tue, 24 Sep 2024 14:50:21 +0200 Subject: [PATCH 081/123] refactor(mobile): use repositories in a number of services (#12891) * UserService * PartnerService * HashService * MemoryService * PersonService * SearchService * StackService --- mobile/analysis_options.yaml | 14 ++-- mobile/lib/constants/constants.dart | 1 + mobile/lib/interfaces/asset.interface.dart | 5 ++ .../lib/interfaces/asset_api.interface.dart | 2 + .../lib/interfaces/partner_api.interface.dart | 13 +++ .../lib/interfaces/person_api.interface.dart | 22 +++++ mobile/lib/interfaces/user.interface.dart | 2 + mobile/lib/interfaces/user_api.interface.dart | 11 +++ .../models/search/search_filter.model.dart | 6 +- .../lib/pages/common/gallery_viewer.page.dart | 4 +- .../lib/pages/search/search_input.page.dart | 4 +- .../activity_service.provider.g.dart | 2 +- .../activity_statistics.provider.g.dart | 2 +- .../suggested_shared_users.provider.dart | 2 +- .../providers/map/map_state.provider.g.dart | 2 +- .../lib/providers/search/people.provider.dart | 4 +- .../providers/search/people.provider.g.dart | 7 +- mobile/lib/repositories/asset.repository.dart | 25 ++++++ .../repositories/asset_api.repository.dart | 31 ++++++- .../repositories/partner_api.repository.dart | 51 ++++++++++++ .../repositories/person_api.repository.dart | 38 +++++++++ mobile/lib/repositories/user.repository.dart | 16 ++++ .../lib/repositories/user_api.repository.dart | 41 ++++++++++ mobile/lib/services/background.service.dart | 19 +++-- mobile/lib/services/hash.service.dart | 29 +++---- mobile/lib/services/memory.service.dart | 13 ++- mobile/lib/services/partner.service.dart | 62 ++++++-------- mobile/lib/services/person.service.dart | 77 +++++++----------- mobile/lib/services/person.service.g.dart | 2 +- mobile/lib/services/search.service.dart | 12 +-- mobile/lib/services/stack.service.dart | 15 ++-- mobile/lib/services/user.service.dart | 80 ++++++++----------- mobile/lib/utils/image_url_builder.dart | 4 +- .../widgets/asset_grid/thumbnail_image.dart | 4 +- .../search/search_filter/people_picker.dart | 8 +- 35 files changed, 416 insertions(+), 214 deletions(-) create mode 100644 mobile/lib/constants/constants.dart create mode 100644 mobile/lib/interfaces/partner_api.interface.dart create mode 100644 mobile/lib/interfaces/person_api.interface.dart create mode 100644 mobile/lib/interfaces/user_api.interface.dart create mode 100644 mobile/lib/repositories/partner_api.repository.dart create mode 100644 mobile/lib/repositories/person_api.repository.dart create mode 100644 mobile/lib/repositories/user_api.repository.dart diff --git a/mobile/analysis_options.yaml b/mobile/analysis_options.yaml index e996a54372b6e..6a7d7a6b4df89 100644 --- a/mobile/analysis_options.yaml +++ b/mobile/analysis_options.yaml @@ -69,14 +69,14 @@ custom_lint: - integration_test/test_utils/general_helper.dart - lib/main.dart - lib/routing/router.dart - - lib/utils/{db,image_url_builder,migration,renderlist_generator}.dart + - lib/utils/{db,migration,renderlist_generator}.dart - test/**.dart # refactor to make the providers and services testable - - lib/pages/common/{album_asset_selection,gallery_viewer}.page.dart + - lib/pages/common/album_asset_selection.page.dart - lib/providers/{archive,asset,authentication,db,favorite,partner,trash,user}.provider.dart - lib/providers/{album/album,album/shared_album,asset_viewer/asset_stack,asset_viewer/render_list,backup/backup,backup/manual_upload,search/all_motion_photos,search/recently_added_asset}.provider.dart - - lib/services/{asset,background,backup,hash,immich_logger,memory,partner,person,search,stack,sync,user}.service.dart - - lib/widgets/asset_grid/{asset_grid_data_structure,thumbnail_image}.dart + - lib/services/{asset,background,backup,immich_logger,sync}.service.dart + - lib/widgets/asset_grid/asset_grid_data_structure.dart - import_rule_openapi: message: openapi must only be used through ApiRepositories @@ -90,18 +90,16 @@ custom_lint: - test/modules/utils/openapi_patching_test.dart # filename is self-explanatory... # refactor - lib/models/map/map_marker.model.dart - - lib/models/search/search_filter.model.dart - lib/models/server_info/server_{config,disk_info,features,version}.model.dart - lib/models/shared_link/shared_link.model.dart - - lib/pages/search/search_input.page.dart - lib/providers/asset_viewer/asset_people.provider.dart - lib/providers/authentication.provider.dart - lib/providers/image/immich_remote_{image,thumbnail}_provider.dart - lib/providers/map/map_state.provider.dart - - lib/providers/search/{people,search,search_filter}.provider.dart + - lib/providers/search/{search,search_filter}.provider.dart - lib/providers/websocket.provider.dart - lib/routing/auth_guard.dart - - lib/services/{api,asset,backup,memory,oauth,partner,person,search,shared_link,stack,trash,user}.service.dart + - lib/services/{api,asset,backup,memory,oauth,search,shared_link,stack,trash}.service.dart - lib/widgets/album/album_thumbnail_listtile.dart - lib/widgets/forms/login/login_form.dart - lib/widgets/search/search_filter/{camera_picker,location_picker,people_picker}.dart diff --git a/mobile/lib/constants/constants.dart b/mobile/lib/constants/constants.dart new file mode 100644 index 0000000000000..8b74b1a66fb2f --- /dev/null +++ b/mobile/lib/constants/constants.dart @@ -0,0 +1 @@ +const int noDbId = -9223372036854775808; // from Isar diff --git a/mobile/lib/interfaces/asset.interface.dart b/mobile/lib/interfaces/asset.interface.dart index 98f4c7687cdfe..0d2dcfa1b5b35 100644 --- a/mobile/lib/interfaces/asset.interface.dart +++ b/mobile/lib/interfaces/asset.interface.dart @@ -1,5 +1,6 @@ import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/entities/device_asset.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; abstract interface class IAssetRepository { @@ -12,6 +13,7 @@ abstract interface class IAssetRepository { bool? remote, int limit = 100, }); + Future> updateAll(List assets); Future> getMatches({ required List assets, @@ -19,4 +21,7 @@ abstract interface class IAssetRepository { bool? remote, int limit = 100, }); + + Future> getDeviceAssetsById(List ids); + Future upsertDeviceAssets(List deviceAssets); } diff --git a/mobile/lib/interfaces/asset_api.interface.dart b/mobile/lib/interfaces/asset_api.interface.dart index 201c85cea7324..fe3320c9bb101 100644 --- a/mobile/lib/interfaces/asset_api.interface.dart +++ b/mobile/lib/interfaces/asset_api.interface.dart @@ -13,4 +13,6 @@ abstract interface class IAssetApiRepository { }); // Future delete(String id); + + Future> search({List personIds = const []}); } diff --git a/mobile/lib/interfaces/partner_api.interface.dart b/mobile/lib/interfaces/partner_api.interface.dart new file mode 100644 index 0000000000000..bca1baf66d251 --- /dev/null +++ b/mobile/lib/interfaces/partner_api.interface.dart @@ -0,0 +1,13 @@ +import 'package:immich_mobile/entities/user.entity.dart'; + +abstract interface class IPartnerApiRepository { + Future> getAll(Direction direction); + Future create(String id); + Future update(String id, {required bool inTimeline}); + Future delete(String id); +} + +enum Direction { + sharedWithMe, + sharedByMe, +} diff --git a/mobile/lib/interfaces/person_api.interface.dart b/mobile/lib/interfaces/person_api.interface.dart new file mode 100644 index 0000000000000..b2fa28df8cc19 --- /dev/null +++ b/mobile/lib/interfaces/person_api.interface.dart @@ -0,0 +1,22 @@ +abstract interface class IPersonApiRepository { + Future> getAll(); + Future update(String id, {String? name}); +} + +class Person { + Person({ + required this.id, + required this.isHidden, + required this.name, + required this.thumbnailPath, + this.birthDate, + this.updatedAt, + }); + + final String id; + final DateTime? birthDate; + final bool isHidden; + final String name; + final String thumbnailPath; + final DateTime? updatedAt; +} diff --git a/mobile/lib/interfaces/user.interface.dart b/mobile/lib/interfaces/user.interface.dart index 4e847ea0229e7..828a7b2398a50 100644 --- a/mobile/lib/interfaces/user.interface.dart +++ b/mobile/lib/interfaces/user.interface.dart @@ -3,4 +3,6 @@ import 'package:immich_mobile/entities/user.entity.dart'; abstract interface class IUserRepository { Future> getByIds(List ids); Future get(String id); + Future> getAll({bool self = true}); + Future update(User user); } diff --git a/mobile/lib/interfaces/user_api.interface.dart b/mobile/lib/interfaces/user_api.interface.dart new file mode 100644 index 0000000000000..67ac3c08831be --- /dev/null +++ b/mobile/lib/interfaces/user_api.interface.dart @@ -0,0 +1,11 @@ +import 'dart:typed_data'; + +import 'package:immich_mobile/entities/user.entity.dart'; + +abstract interface class IUserApiRepository { + Future> getAll(); + Future<({String profileImagePath})> createProfileImage({ + required String name, + required Uint8List data, + }); +} diff --git a/mobile/lib/models/search/search_filter.model.dart b/mobile/lib/models/search/search_filter.model.dart index 6a7c612b1563c..297a819b6a335 100644 --- a/mobile/lib/models/search/search_filter.model.dart +++ b/mobile/lib/models/search/search_filter.model.dart @@ -2,7 +2,7 @@ import 'dart:convert'; import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:openapi/api.dart'; +import 'package:immich_mobile/interfaces/person_api.interface.dart'; class SearchLocationFilter { String? country; @@ -235,7 +235,7 @@ class SearchDisplayFilters { class SearchFilter { String? context; String? filename; - Set people; + Set people; SearchLocationFilter location; SearchCameraFilter camera; SearchDateFilter date; @@ -258,7 +258,7 @@ class SearchFilter { SearchFilter copyWith({ String? context, String? filename, - Set? people, + Set? people, SearchLocationFilter? location, SearchCameraFilter? camera, SearchDateFilter? date, diff --git a/mobile/lib/pages/common/gallery_viewer.page.dart b/mobile/lib/pages/common/gallery_viewer.page.dart index d8ea7cd89b47f..1434d1cca5f59 100644 --- a/mobile/lib/pages/common/gallery_viewer.page.dart +++ b/mobile/lib/pages/common/gallery_viewer.page.dart @@ -8,6 +8,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart' hide Store; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/pages/common/video_viewer.page.dart'; @@ -30,7 +31,6 @@ import 'package:immich_mobile/widgets/photo_view/photo_view_gallery.dart'; import 'package:immich_mobile/widgets/photo_view/src/photo_view_computed_scale.dart'; import 'package:immich_mobile/widgets/photo_view/src/photo_view_scale_state.dart'; import 'package:immich_mobile/widgets/photo_view/src/utils/photo_view_hero_attributes.dart'; -import 'package:isar/isar.dart'; @RoutePage() // ignore: must_be_immutable @@ -73,7 +73,7 @@ class GalleryViewerPage extends HookConsumerWidget { : []; final stackElements = showStack ? [currentAsset, ...stack] : []; // Assets from response DTOs do not have an isar id, querying which would give us the default autoIncrement id - final isFromDto = currentAsset.id == Isar.autoIncrement; + final isFromDto = currentAsset.id == noDbId; Asset asset = stackIndex.value == -1 ? currentAsset diff --git a/mobile/lib/pages/search/search_input.page.dart b/mobile/lib/pages/search/search_input.page.dart index acabc75aa4950..2ca2a379180dd 100644 --- a/mobile/lib/pages/search/search_input.page.dart +++ b/mobile/lib/pages/search/search_input.page.dart @@ -8,6 +8,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; +import 'package:immich_mobile/interfaces/person_api.interface.dart'; import 'package:immich_mobile/models/search/search_filter.model.dart'; import 'package:immich_mobile/providers/search/paginated_search.provider.dart'; import 'package:immich_mobile/widgets/asset_grid/multiselect_grid.dart'; @@ -19,7 +20,6 @@ import 'package:immich_mobile/widgets/search/search_filter/media_type_picker.dar import 'package:immich_mobile/widgets/search/search_filter/people_picker.dart'; import 'package:immich_mobile/widgets/search/search_filter/search_filter_chip.dart'; import 'package:immich_mobile/widgets/search/search_filter/search_filter_utils.dart'; -import 'package:openapi/api.dart'; @RoutePage() class SearchInputPage extends HookConsumerWidget { @@ -110,7 +110,7 @@ class SearchInputPage extends HookConsumerWidget { } showPeoplePicker() { - handleOnSelect(Set value) { + handleOnSelect(Set value) { filter.value = filter.value.copyWith( people: value, ); diff --git a/mobile/lib/providers/activity_service.provider.g.dart b/mobile/lib/providers/activity_service.provider.g.dart index 8e5ef43260119..d42b2a39e45f7 100644 --- a/mobile/lib/providers/activity_service.provider.g.dart +++ b/mobile/lib/providers/activity_service.provider.g.dart @@ -6,7 +6,7 @@ part of 'activity_service.provider.dart'; // RiverpodGenerator // ************************************************************************** -String _$activityServiceHash() => r'5dd4955d14f5bf01c00d7f8750d07e7ace7cc4b0'; +String _$activityServiceHash() => r'23a3ee7db71676d2719daa64217a683cc5c7eab0'; /// See also [activityService]. @ProviderFor(activityService) diff --git a/mobile/lib/providers/activity_statistics.provider.g.dart b/mobile/lib/providers/activity_statistics.provider.g.dart index 79856c525b77c..16a3c0e81b374 100644 --- a/mobile/lib/providers/activity_statistics.provider.g.dart +++ b/mobile/lib/providers/activity_statistics.provider.g.dart @@ -7,7 +7,7 @@ part of 'activity_statistics.provider.dart'; // ************************************************************************** String _$activityStatisticsHash() => - r'a5f7bbee1891c33b72919a34e632ca9ef9cd8dbf'; + r'1f43f0bcb11c754ca3cb586a13570db25023b9a8'; /// Copied from Dart SDK class _SystemHash { diff --git a/mobile/lib/providers/album/suggested_shared_users.provider.dart b/mobile/lib/providers/album/suggested_shared_users.provider.dart index 77518f47d0c04..fe8a1fccce861 100644 --- a/mobile/lib/providers/album/suggested_shared_users.provider.dart +++ b/mobile/lib/providers/album/suggested_shared_users.provider.dart @@ -5,5 +5,5 @@ import 'package:immich_mobile/services/user.service.dart'; final otherUsersProvider = FutureProvider.autoDispose>((ref) { UserService userService = ref.watch(userServiceProvider); - return userService.getUsersInDb(); + return userService.getUsers(); }); diff --git a/mobile/lib/providers/map/map_state.provider.g.dart b/mobile/lib/providers/map/map_state.provider.g.dart index eff7b4b68e60f..23a570d1c8789 100644 --- a/mobile/lib/providers/map/map_state.provider.g.dart +++ b/mobile/lib/providers/map/map_state.provider.g.dart @@ -6,7 +6,7 @@ part of 'map_state.provider.dart'; // RiverpodGenerator // ************************************************************************** -String _$mapStateNotifierHash() => r'31fafe17aa85c48379a22ed3db3cc94af59ce5b8'; +String _$mapStateNotifierHash() => r'22e4e571bd0730dbc34b109255a62b920e9c7d66'; /// See also [MapStateNotifier]. @ProviderFor(MapStateNotifier) diff --git a/mobile/lib/providers/search/people.provider.dart b/mobile/lib/providers/search/people.provider.dart index e2c243354b536..7c956f0a37b52 100644 --- a/mobile/lib/providers/search/people.provider.dart +++ b/mobile/lib/providers/search/people.provider.dart @@ -1,14 +1,14 @@ +import 'package:immich_mobile/interfaces/person_api.interface.dart'; import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart'; import 'package:immich_mobile/services/person.service.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; -import 'package:openapi/api.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'people.provider.g.dart'; @riverpod -Future> getAllPeople( +Future> getAllPeople( GetAllPeopleRef ref, ) async { final PersonService personService = ref.read(personServiceProvider); diff --git a/mobile/lib/providers/search/people.provider.g.dart b/mobile/lib/providers/search/people.provider.g.dart index db2edfb9567aa..c5ff6287cd7a8 100644 --- a/mobile/lib/providers/search/people.provider.g.dart +++ b/mobile/lib/providers/search/people.provider.g.dart @@ -6,12 +6,11 @@ part of 'people.provider.dart'; // RiverpodGenerator // ************************************************************************** -String _$getAllPeopleHash() => r'4eff6666be5a74710d1e8587e01d8154310d85bd'; +String _$getAllPeopleHash() => r'3417b7e0c211382d4480a415e352139995d57b6d'; /// See also [getAllPeople]. @ProviderFor(getAllPeople) -final getAllPeopleProvider = - AutoDisposeFutureProvider>.internal( +final getAllPeopleProvider = AutoDisposeFutureProvider>.internal( getAllPeople, name: r'getAllPeopleProvider', debugGetCreateSourceHash: @@ -20,7 +19,7 @@ final getAllPeopleProvider = allTransitiveDependencies: null, ); -typedef GetAllPeopleRef = AutoDisposeFutureProviderRef>; +typedef GetAllPeopleRef = AutoDisposeFutureProviderRef>; String _$personAssetsHash() => r'3dfecb67a54d07e4208bcb9581b2625acd2e1832'; /// Copied from Dart SDK diff --git a/mobile/lib/repositories/asset.repository.dart b/mobile/lib/repositories/asset.repository.dart index c6012af3717eb..087344302a417 100644 --- a/mobile/lib/repositories/asset.repository.dart +++ b/mobile/lib/repositories/asset.repository.dart @@ -1,6 +1,11 @@ +import 'dart:io'; + import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/album.entity.dart'; +import 'package:immich_mobile/entities/android_device_asset.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/entities/device_asset.entity.dart'; +import 'package:immich_mobile/entities/ios_device_asset.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/interfaces/asset.interface.dart'; import 'package:immich_mobile/providers/db.provider.dart'; @@ -69,6 +74,12 @@ class AssetRepository implements IAssetRepository { return query.limit(limit).findAll(); } + @override + Future> updateAll(List assets) async { + await _db.writeTxn(() => _db.assets.putAll(assets)); + return assets; + } + @override Future> getMatches({ required List assets, @@ -86,6 +97,20 @@ class AssetRepository implements IAssetRepository { } return _getMatchesImpl(query, ownerId, assets, limit); } + + @override + Future> getDeviceAssetsById(List ids) => + Platform.isAndroid + ? _db.androidDeviceAssets.getAll(ids.cast()) + : _db.iOSDeviceAssets.getAllById(ids.cast()); + + @override + Future upsertDeviceAssets(List deviceAssets) => + _db.writeTxn( + () => Platform.isAndroid + ? _db.androidDeviceAssets.putAll(deviceAssets.cast()) + : _db.iOSDeviceAssets.putAll(deviceAssets.cast()), + ); } Future> _getMatchesImpl( diff --git a/mobile/lib/repositories/asset_api.repository.dart b/mobile/lib/repositories/asset_api.repository.dart index 3ad0e1cba0d19..eb796f6c6b5d2 100644 --- a/mobile/lib/repositories/asset_api.repository.dart +++ b/mobile/lib/repositories/asset_api.repository.dart @@ -6,14 +6,18 @@ import 'package:immich_mobile/repositories/base_api.repository.dart'; import 'package:openapi/api.dart'; final assetApiRepositoryProvider = Provider( - (ref) => AssetApiRepository(ref.watch(apiServiceProvider).assetsApi), + (ref) => AssetApiRepository( + ref.watch(apiServiceProvider).assetsApi, + ref.watch(apiServiceProvider).searchApi, + ), ); class AssetApiRepository extends BaseApiRepository implements IAssetApiRepository { final AssetsApi _api; + final SearchApi _searchApi; - AssetApiRepository(this._api); + AssetApiRepository(this._api, this._searchApi); @override Future update(String id, {String? description}) async { @@ -22,4 +26,27 @@ class AssetApiRepository extends BaseApiRepository ); return Asset.remote(response); } + + @override + Future> search({List personIds = const []}) async { + // TODO this always fetches all assets, change API and usage to actually do pagination + final List result = []; + bool hasNext = true; + int currentPage = 1; + while (hasNext) { + final response = await checkNull( + _searchApi.searchMetadata( + MetadataSearchDto( + personIds: personIds, + page: currentPage, + size: 1000, + ), + ), + ); + result.addAll(response.assets.items.map(Asset.remote)); + hasNext = response.assets.nextPage != null; + currentPage++; + } + return result; + } } diff --git a/mobile/lib/repositories/partner_api.repository.dart b/mobile/lib/repositories/partner_api.repository.dart new file mode 100644 index 0000000000000..3419a2bc77244 --- /dev/null +++ b/mobile/lib/repositories/partner_api.repository.dart @@ -0,0 +1,51 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/interfaces/partner_api.interface.dart'; +import 'package:immich_mobile/providers/api.provider.dart'; +import 'package:immich_mobile/repositories/base_api.repository.dart'; +import 'package:openapi/api.dart'; + +final partnerApiRepositoryProvider = Provider( + (ref) => PartnerApiRepository( + ref.watch(apiServiceProvider).partnersApi, + ), +); + +class PartnerApiRepository extends BaseApiRepository + implements IPartnerApiRepository { + final PartnersApi _api; + + PartnerApiRepository(this._api); + + @override + Future> getAll(Direction direction) async { + final response = await checkNull( + _api.getPartners( + direction == Direction.sharedByMe + ? PartnerDirection.by + : PartnerDirection.with_, + ), + ); + return response.map(User.fromPartnerDto).toList(); + } + + @override + Future create(String id) async { + final dto = await checkNull(_api.createPartner(id)); + return User.fromPartnerDto(dto); + } + + @override + Future delete(String id) => checkNull(_api.removePartner(id)); + + @override + Future update(String id, {required bool inTimeline}) async { + final dto = await checkNull( + _api.updatePartner( + id, + UpdatePartnerDto(inTimeline: inTimeline), + ), + ); + return User.fromPartnerDto(dto); + } +} diff --git a/mobile/lib/repositories/person_api.repository.dart b/mobile/lib/repositories/person_api.repository.dart new file mode 100644 index 0000000000000..8071c33dc2cea --- /dev/null +++ b/mobile/lib/repositories/person_api.repository.dart @@ -0,0 +1,38 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/interfaces/person_api.interface.dart'; +import 'package:immich_mobile/providers/api.provider.dart'; +import 'package:immich_mobile/repositories/base_api.repository.dart'; +import 'package:openapi/api.dart'; + +final personApiRepositoryProvider = Provider( + (ref) => PersonApiRepository(ref.watch(apiServiceProvider).peopleApi), +); + +class PersonApiRepository extends BaseApiRepository + implements IPersonApiRepository { + final PeopleApi _api; + + PersonApiRepository(this._api); + + @override + Future> getAll() async { + final dto = await checkNull(_api.getAllPeople()); + return dto.people.map(_toPerson).toList(); + } + + @override + Future update(String id, {String? name}) async { + final dto = await checkNull( + _api.updatePerson(id, PersonUpdateDto(name: name)), + ); + return _toPerson(dto); + } + + static Person _toPerson(PersonResponseDto dto) => Person( + birthDate: dto.birthDate, + id: dto.id, + isHidden: dto.isHidden, + name: dto.name, + thumbnailPath: dto.thumbnailPath, + ); +} diff --git a/mobile/lib/repositories/user.repository.dart b/mobile/lib/repositories/user.repository.dart index b05af9a57f89c..796b1f421b863 100644 --- a/mobile/lib/repositories/user.repository.dart +++ b/mobile/lib/repositories/user.repository.dart @@ -1,4 +1,5 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/interfaces/user.interface.dart'; import 'package:immich_mobile/providers/db.provider.dart'; @@ -20,4 +21,19 @@ class UserRepository implements IUserRepository { @override Future get(String id) => _db.users.getById(id); + + @override + Future> getAll({bool self = true}) { + if (self) { + return _db.users.where().findAll(); + } + final int userId = Store.get(StoreKey.currentUser).isarId; + return _db.users.where().isarIdNotEqualTo(userId).findAll(); + } + + @override + Future update(User user) async { + await _db.writeTxn(() => _db.users.put(user)); + return user; + } } diff --git a/mobile/lib/repositories/user_api.repository.dart b/mobile/lib/repositories/user_api.repository.dart new file mode 100644 index 0000000000000..ffc50ae4c3e6c --- /dev/null +++ b/mobile/lib/repositories/user_api.repository.dart @@ -0,0 +1,41 @@ +import 'dart:typed_data'; + +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:http/http.dart'; +import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/interfaces/user_api.interface.dart'; +import 'package:immich_mobile/providers/api.provider.dart'; +import 'package:immich_mobile/repositories/base_api.repository.dart'; +import 'package:openapi/api.dart'; + +final userApiRepositoryProvider = Provider( + (ref) => UserApiRepository( + ref.watch(apiServiceProvider).usersApi, + ), +); + +class UserApiRepository extends BaseApiRepository + implements IUserApiRepository { + final UsersApi _api; + + UserApiRepository(this._api); + + @override + Future> getAll() async { + final dto = await checkNull(_api.searchUsers()); + return dto.map(User.fromSimpleUserDto).toList(); + } + + @override + Future<({String profileImagePath})> createProfileImage({ + required String name, + required Uint8List data, + }) async { + final response = await checkNull( + _api.createProfileImage( + MultipartFile.fromBytes('file', data, filename: name), + ), + ); + return (profileImagePath: response.profileImagePath); + } +} diff --git a/mobile/lib/services/background.service.dart b/mobile/lib/services/background.service.dart index 09030a621bc9d..d06bc86d4871b 100644 --- a/mobile/lib/services/background.service.dart +++ b/mobile/lib/services/background.service.dart @@ -18,7 +18,9 @@ import 'package:immich_mobile/repositories/asset.repository.dart'; import 'package:immich_mobile/repositories/backup.repository.dart'; import 'package:immich_mobile/repositories/album_media.repository.dart'; import 'package:immich_mobile/repositories/file_media.repository.dart'; +import 'package:immich_mobile/repositories/partner_api.repository.dart'; import 'package:immich_mobile/repositories/user.repository.dart'; +import 'package:immich_mobile/repositories/user_api.repository.dart'; import 'package:immich_mobile/services/album.service.dart'; import 'package:immich_mobile/services/entity.service.dart'; import 'package:immich_mobile/services/hash.service.dart'; @@ -30,7 +32,6 @@ import 'package:immich_mobile/services/backup.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/services/api.service.dart'; -import 'package:immich_mobile/services/partner.service.dart'; import 'package:immich_mobile/services/sync.service.dart'; import 'package:immich_mobile/services/user.service.dart'; import 'package:immich_mobile/utils/backup_progress.dart'; @@ -362,16 +363,20 @@ class BackgroundService { apiService.setAccessToken(Store.get(StoreKey.accessToken)); AppSettingsService settingService = AppSettingsService(); AppSettingsService settingsService = AppSettingsService(); - PartnerService partnerService = PartnerService(apiService, db); AlbumRepository albumRepository = AlbumRepository(db); AssetRepository assetRepository = AssetRepository(db); BackupRepository backupAlbumRepository = BackupRepository(db); AlbumMediaRepository albumMediaRepository = AlbumMediaRepository(); FileMediaRepository fileMediaRepository = FileMediaRepository(); UserRepository userRepository = UserRepository(db); + UserApiRepository userApiRepository = + UserApiRepository(apiService.usersApi); AlbumApiRepository albumApiRepository = AlbumApiRepository(apiService.albumsApi); - HashService hashService = HashService(db, this, albumMediaRepository); + PartnerApiRepository partnerApiRepository = + PartnerApiRepository(apiService.partnersApi); + HashService hashService = + HashService(assetRepository, this, albumMediaRepository); EntityService entityService = EntityService(assetRepository, userRepository); SyncService syncSerive = SyncService( @@ -381,8 +386,12 @@ class BackgroundService { albumMediaRepository, albumApiRepository, ); - UserService userService = - UserService(apiService, db, syncSerive, partnerService); + UserService userService = UserService( + partnerApiRepository, + userApiRepository, + userRepository, + syncSerive, + ); AlbumService albumService = AlbumService( userService, syncSerive, diff --git a/mobile/lib/services/hash.service.dart b/mobile/lib/services/hash.service.dart index 94d680972fa1a..3827e421e6108 100644 --- a/mobile/lib/services/hash.service.dart +++ b/mobile/lib/services/hash.service.dart @@ -4,20 +4,24 @@ import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/interfaces/album_media.interface.dart'; +import 'package:immich_mobile/interfaces/asset.interface.dart'; import 'package:immich_mobile/repositories/album_media.repository.dart'; +import 'package:immich_mobile/repositories/asset.repository.dart'; import 'package:immich_mobile/services/background.service.dart'; import 'package:immich_mobile/entities/android_device_asset.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/device_asset.entity.dart'; import 'package:immich_mobile/entities/ios_device_asset.entity.dart'; -import 'package:immich_mobile/providers/db.provider.dart'; import 'package:immich_mobile/extensions/string_extensions.dart'; -import 'package:isar/isar.dart'; import 'package:logging/logging.dart'; class HashService { - HashService(this._db, this._backgroundService, this._albumMediaRepository); - final Isar _db; + HashService( + this._assetRepository, + this._backgroundService, + this._albumMediaRepository, + ); + final IAssetRepository _assetRepository; final BackgroundService _backgroundService; final IAlbumMediaRepository _albumMediaRepository; final _log = Logger('HashService'); @@ -55,7 +59,8 @@ class HashService { final ids = assets .map(Platform.isAndroid ? (a) => a.localId!.toInt() : (a) => a.localId!) .toList(); - final List hashes = await _lookupHashes(ids); + final List hashes = + await _assetRepository.getDeviceAssetsById(ids); final List toAdd = []; final List toHash = []; @@ -106,12 +111,6 @@ class HashService { return _getHashedAssets(assets, hashes); } - /// Lookup hashes of assets by their local ID - Future> _lookupHashes(List ids) => - Platform.isAndroid - ? _db.androidDeviceAssets.getAll(ids.cast()) - : _db.iOSDeviceAssets.getAllById(ids.cast()); - /// Processes a batch of files and saves any successfully hashed /// values to the DB table. Future _processBatch( @@ -131,11 +130,7 @@ class HashService { final validHashes = anyNull ? toAdd.where((e) => e.hash.length == 20).toList(growable: false) : toAdd; - await _db.writeTxn( - () => Platform.isAndroid - ? _db.androidDeviceAssets.putAll(validHashes.cast()) - : _db.iOSDeviceAssets.putAll(validHashes.cast()), - ); + await _assetRepository.upsertDeviceAssets(validHashes); _log.fine("Hashed ${validHashes.length}/${toHash.length} assets"); } @@ -168,7 +163,7 @@ class HashService { final hashServiceProvider = Provider( (ref) => HashService( - ref.watch(dbProvider), + ref.watch(assetRepositoryProvider), ref.watch(backgroundServiceProvider), ref.watch(albumMediaRepositoryProvider), ), diff --git a/mobile/lib/services/memory.service.dart b/mobile/lib/services/memory.service.dart index ea07f7c019ea8..b95899df678ec 100644 --- a/mobile/lib/services/memory.service.dart +++ b/mobile/lib/services/memory.service.dart @@ -1,18 +1,17 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/interfaces/asset.interface.dart'; import 'package:immich_mobile/models/memories/memory.model.dart'; import 'package:immich_mobile/providers/api.provider.dart'; -import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:immich_mobile/repositories/asset.repository.dart'; import 'package:immich_mobile/services/api.service.dart'; -import 'package:isar/isar.dart'; import 'package:logging/logging.dart'; import 'package:openapi/api.dart'; final memoryServiceProvider = StateProvider((ref) { return MemoryService( ref.watch(apiServiceProvider), - ref.watch(dbProvider), + ref.watch(assetRepositoryProvider), ); }); @@ -20,9 +19,9 @@ class MemoryService { final log = Logger("MemoryService"); final ApiService _apiService; - final Isar _db; + final IAssetRepository _assetRepository; - MemoryService(this._apiService, this._db); + MemoryService(this._apiService, this._assetRepository); Future?> getMemoryLane() async { try { @@ -39,7 +38,7 @@ class MemoryService { List memories = []; for (final MemoryLaneResponseDto(:yearsAgo, :assets) in data) { final dbAssets = - await _db.assets.getAllByRemoteId(assets.map((e) => e.id)); + await _assetRepository.getAllByRemoteId(assets.map((e) => e.id)); if (dbAssets.isNotEmpty) { final String title = yearsAgo <= 1 ? 'memories_year_ago'.tr() diff --git a/mobile/lib/services/partner.service.dart b/mobile/lib/services/partner.service.dart index 8cd2fe424f190..67d7f4e1d1f56 100644 --- a/mobile/lib/services/partner.service.dart +++ b/mobile/lib/services/partner.service.dart @@ -1,43 +1,33 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/user.entity.dart'; -import 'package:immich_mobile/providers/api.provider.dart'; -import 'package:immich_mobile/providers/db.provider.dart'; -import 'package:immich_mobile/services/api.service.dart'; -import 'package:isar/isar.dart'; +import 'package:immich_mobile/interfaces/partner_api.interface.dart'; +import 'package:immich_mobile/interfaces/user.interface.dart'; +import 'package:immich_mobile/repositories/partner_api.repository.dart'; +import 'package:immich_mobile/repositories/user.repository.dart'; import 'package:logging/logging.dart'; -import 'package:openapi/api.dart'; final partnerServiceProvider = Provider( (ref) => PartnerService( - ref.watch(apiServiceProvider), - ref.watch(dbProvider), + ref.watch(partnerApiRepositoryProvider), + ref.watch(userRepositoryProvider), ), ); class PartnerService { - final ApiService _apiService; - final Isar _db; + final IPartnerApiRepository _partnerApiRepository; + final IUserRepository _userRepository; final Logger _log = Logger("PartnerService"); - PartnerService(this._apiService, this._db); - - Future?> getPartners(PartnerDirection direction) async { - try { - final userDtos = await _apiService.partnersApi.getPartners(direction); - if (userDtos != null) { - return userDtos.map((u) => User.fromPartnerDto(u)).toList(); - } - } catch (e) { - _log.warning("Failed to get partners for direction $direction", e); - } - return null; - } + PartnerService( + this._partnerApiRepository, + this._userRepository, + ); Future removePartner(User partner) async { try { - await _apiService.partnersApi.removePartner(partner.id); + await _partnerApiRepository.delete(partner.id); partner.isPartnerSharedBy = false; - await _db.writeTxn(() => _db.users.put(partner)); + await _userRepository.update(partner); } catch (e) { _log.warning("Failed to remove partner ${partner.id}", e); return false; @@ -47,12 +37,10 @@ class PartnerService { Future addPartner(User partner) async { try { - final dto = await _apiService.partnersApi.createPartner(partner.id); - if (dto != null) { - partner.isPartnerSharedBy = true; - await _db.writeTxn(() => _db.users.put(partner)); - return true; - } + await _partnerApiRepository.create(partner.id); + partner.isPartnerSharedBy = true; + await _userRepository.update(partner); + return true; } catch (e) { _log.warning("Failed to add partner ${partner.id}", e); } @@ -61,13 +49,13 @@ class PartnerService { Future updatePartner(User partner, {required bool inTimeline}) async { try { - final dto = await _apiService.partnersApi - .updatePartner(partner.id, UpdatePartnerDto(inTimeline: inTimeline)); - if (dto != null) { - partner.inTimeline = dto.inTimeline ?? partner.inTimeline; - await _db.writeTxn(() => _db.users.put(partner)); - return true; - } + final dto = await _partnerApiRepository.update( + partner.id, + inTimeline: inTimeline, + ); + partner.inTimeline = dto.inTimeline; + await _userRepository.update(partner); + return true; } catch (e) { _log.warning("Failed to update partner ${partner.id}", e); } diff --git a/mobile/lib/services/person.service.dart b/mobile/lib/services/person.service.dart index ddb61f5e48a40..5b325acdc591b 100644 --- a/mobile/lib/services/person.service.dart +++ b/mobile/lib/services/person.service.dart @@ -1,29 +1,37 @@ import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:immich_mobile/providers/api.provider.dart'; -import 'package:immich_mobile/providers/db.provider.dart'; -import 'package:immich_mobile/services/api.service.dart'; -import 'package:isar/isar.dart'; +import 'package:immich_mobile/interfaces/asset.interface.dart'; +import 'package:immich_mobile/interfaces/asset_api.interface.dart'; +import 'package:immich_mobile/interfaces/person_api.interface.dart'; +import 'package:immich_mobile/repositories/asset.repository.dart'; +import 'package:immich_mobile/repositories/asset_api.repository.dart'; +import 'package:immich_mobile/repositories/person_api.repository.dart'; import 'package:logging/logging.dart'; -import 'package:openapi/api.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'person.service.g.dart'; @riverpod -PersonService personService(PersonServiceRef ref) => - PersonService(ref.read(apiServiceProvider), ref.read(dbProvider)); +PersonService personService(PersonServiceRef ref) => PersonService( + ref.watch(personApiRepositoryProvider), + ref.watch(assetApiRepositoryProvider), + ref.read(assetRepositoryProvider), + ); class PersonService { final Logger _log = Logger("PersonService"); - final ApiService _apiService; - final Isar _db; + final IPersonApiRepository _personApiRepository; + final IAssetApiRepository _assetApiRepository; + final IAssetRepository _assetRepository; - PersonService(this._apiService, this._db); + PersonService( + this._personApiRepository, + this._assetApiRepository, + this._assetRepository, + ); - Future> getAllPeople() async { + Future> getAllPeople() async { try { - final peopleResponseDto = await _apiService.peopleApi.getAllPeople(); - return peopleResponseDto?.people ?? []; + return await _personApiRepository.getAll(); } catch (error, stack) { _log.severe("Error while fetching curated people", error, stack); return []; @@ -31,50 +39,19 @@ class PersonService { } Future> getPersonAssets(String id) async { - List result = []; - var hasNext = true; - var currentPage = 1; - try { - while (hasNext) { - final response = await _apiService.searchApi.searchMetadata( - MetadataSearchDto( - personIds: [id], - page: currentPage, - size: 1000, - ), - ); - - if (response == null) { - break; - } - - if (response.assets.nextPage == null) { - hasNext = false; - } - - final assets = response.assets.items; - final mapAssets = - await _db.assets.getAllByRemoteId(assets.map((e) => e.id)); - result.addAll(mapAssets); - - currentPage++; - } + final assets = await _assetApiRepository.search(personIds: [id]); + return await _assetRepository + .getAllByRemoteId(assets.map((a) => a.remoteId!)); } catch (error, stack) { _log.severe("Error while fetching person assets", error, stack); } - - return result; + return []; } - Future updateName(String id, String name) async { + Future updateName(String id, String name) async { try { - return await _apiService.peopleApi.updatePerson( - id, - PersonUpdateDto( - name: name, - ), - ); + return await _personApiRepository.update(id, name: name); } catch (error, stack) { _log.severe("Error while updating person name", error, stack); } diff --git a/mobile/lib/services/person.service.g.dart b/mobile/lib/services/person.service.g.dart index 01a5ed8f30943..9a24069fbffa5 100644 --- a/mobile/lib/services/person.service.g.dart +++ b/mobile/lib/services/person.service.g.dart @@ -6,7 +6,7 @@ part of 'person.service.dart'; // RiverpodGenerator // ************************************************************************** -String _$personServiceHash() => r'54e6df4b8eea744f6de009f8315c9fe6230f6798'; +String _$personServiceHash() => r'32f28cb5a3de0553c17447e33a0efde7409a43ed'; /// See also [personService]. @ProviderFor(personService) diff --git a/mobile/lib/services/search.service.dart b/mobile/lib/services/search.service.dart index cf3905e5ca240..336fe450108d3 100644 --- a/mobile/lib/services/search.service.dart +++ b/mobile/lib/services/search.service.dart @@ -1,27 +1,27 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/interfaces/asset.interface.dart'; import 'package:immich_mobile/models/search/search_filter.model.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/providers/api.provider.dart'; -import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:immich_mobile/repositories/asset.repository.dart'; import 'package:immich_mobile/services/api.service.dart'; -import 'package:isar/isar.dart'; import 'package:logging/logging.dart'; import 'package:openapi/api.dart'; final searchServiceProvider = Provider( (ref) => SearchService( ref.watch(apiServiceProvider), - ref.watch(dbProvider), + ref.watch(assetRepositoryProvider), ), ); class SearchService { final ApiService _apiService; - final Isar _db; + final IAssetRepository _assetRepository; final _log = Logger("SearchService"); - SearchService(this._apiService, this._db); + SearchService(this._apiService, this._assetRepository); Future?> getSearchSuggestions( SearchSuggestionType type, { @@ -103,7 +103,7 @@ class SearchService { return null; } - return _db.assets + return _assetRepository .getAllByRemoteId(response.assets.items.map((e) => e.id)); } catch (error, stackTrace) { _log.severe("Failed to search for assets", error, stackTrace); diff --git a/mobile/lib/services/stack.service.dart b/mobile/lib/services/stack.service.dart index 75074101c2ff8..8bff21fef61a6 100644 --- a/mobile/lib/services/stack.service.dart +++ b/mobile/lib/services/stack.service.dart @@ -1,17 +1,17 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/interfaces/asset.interface.dart'; import 'package:immich_mobile/providers/api.provider.dart'; -import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:immich_mobile/repositories/asset.repository.dart'; import 'package:immich_mobile/services/api.service.dart'; -import 'package:isar/isar.dart'; import 'package:openapi/api.dart'; class StackService { - StackService(this._api, this._db); + StackService(this._api, this._assetRepository); final ApiService _api; - final Isar _db; + final IAssetRepository _assetRepository; Future getStack(String stackId) async { try { @@ -61,10 +61,7 @@ class StackService { removeAssets.add(asset); } - - _db.writeTxn(() async { - await _db.assets.putAll(removeAssets); - }); + await _assetRepository.updateAll(removeAssets); } catch (error) { debugPrint("Error while deleting stack: $error"); } @@ -74,6 +71,6 @@ class StackService { final stackServiceProvider = Provider( (ref) => StackService( ref.watch(apiServiceProvider), - ref.watch(dbProvider), + ref.watch(assetRepositoryProvider), ), ); diff --git a/mobile/lib/services/user.service.dart b/mobile/lib/services/user.service.dart index 9631141c416c9..4c2b3cbbd00fb 100644 --- a/mobile/lib/services/user.service.dart +++ b/mobile/lib/services/user.service.dart @@ -1,68 +1,48 @@ import 'package:collection/collection.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:http/http.dart'; import 'package:image_picker/image_picker.dart'; -import 'package:immich_mobile/services/partner.service.dart'; -import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/interfaces/partner_api.interface.dart'; +import 'package:immich_mobile/interfaces/user.interface.dart'; +import 'package:immich_mobile/interfaces/user_api.interface.dart'; +import 'package:immich_mobile/repositories/partner_api.repository.dart'; +import 'package:immich_mobile/repositories/user.repository.dart'; +import 'package:immich_mobile/repositories/user_api.repository.dart'; import 'package:immich_mobile/entities/user.entity.dart'; -import 'package:immich_mobile/providers/api.provider.dart'; -import 'package:immich_mobile/providers/db.provider.dart'; -import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/sync.service.dart'; import 'package:immich_mobile/utils/diff.dart'; -import 'package:isar/isar.dart'; import 'package:logging/logging.dart'; -import 'package:openapi/api.dart'; final userServiceProvider = Provider( (ref) => UserService( - ref.watch(apiServiceProvider), - ref.watch(dbProvider), + ref.watch(partnerApiRepositoryProvider), + ref.watch(userApiRepositoryProvider), + ref.watch(userRepositoryProvider), ref.watch(syncServiceProvider), - ref.watch(partnerServiceProvider), ), ); class UserService { - final ApiService _apiService; - final Isar _db; + final IPartnerApiRepository _partnerApiRepository; + final IUserApiRepository _userApiRepository; + final IUserRepository _userRepository; final SyncService _syncService; - final PartnerService _partnerService; final Logger _log = Logger("UserService"); UserService( - this._apiService, - this._db, + this._partnerApiRepository, + this._userApiRepository, + this._userRepository, this._syncService, - this._partnerService, ); - Future?> _getAllUsers() async { - try { - final dto = await _apiService.usersApi.searchUsers(); - return dto?.map(User.fromSimpleUserDto).toList(); - } catch (e) { - _log.warning("Failed get all users", e); - return null; - } - } + Future> getUsers({bool self = false}) => + _userRepository.getAll(self: self); - Future> getUsersInDb({bool self = false}) async { - if (self) { - return _db.users.where().findAll(); - } - final int userId = Store.get(StoreKey.currentUser).isarId; - return _db.users.where().isarIdNotEqualTo(userId).findAll(); - } - - Future uploadProfileImage(XFile image) async { + Future<({String profileImagePath})?> uploadProfileImage(XFile image) async { try { - return await _apiService.usersApi.createProfileImage( - MultipartFile.fromBytes( - 'file', - await image.readAsBytes(), - filename: image.name, - ), + return await _userApiRepository.createProfileImage( + name: image.name, + data: await image.readAsBytes(), ); } catch (e) { _log.warning("Failed to upload profile image", e); @@ -71,13 +51,19 @@ class UserService { } Future?> getUsersFromServer() async { - final List? users = await _getAllUsers(); - final List? sharedBy = - await _partnerService.getPartners(PartnerDirection.by); - final List? sharedWith = - await _partnerService.getPartners(PartnerDirection.with_); + List? users; + try { + users = await _userApiRepository.getAll(); + } catch (e) { + _log.warning("Failed to fetch users", e); + users = null; + } + final List sharedBy = + await _partnerApiRepository.getAll(Direction.sharedByMe); + final List sharedWith = + await _partnerApiRepository.getAll(Direction.sharedWithMe); - if (users == null || sharedBy == null || sharedWith == null) { + if (users == null) { _log.warning("Failed to refresh users"); return null; } diff --git a/mobile/lib/utils/image_url_builder.dart b/mobile/lib/utils/image_url_builder.dart index e7a1b9e39eefe..9fc7b13eed1c6 100644 --- a/mobile/lib/utils/image_url_builder.dart +++ b/mobile/lib/utils/image_url_builder.dart @@ -1,7 +1,7 @@ +import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; -import 'package:isar/isar.dart'; import 'package:openapi/api.dart'; String getThumbnailUrl( @@ -61,7 +61,7 @@ String getOriginalUrlForRemoteId(final String id) { String getImageCacheKey(final Asset asset) { // Assets from response DTOs do not have an isar id, querying which would give us the default autoIncrement id - final isFromDto = asset.id == Isar.autoIncrement; + final isFromDto = asset.id == noDbId; return '${isFromDto ? asset.remoteId : asset.id}_fullStage'; } diff --git a/mobile/lib/widgets/asset_grid/thumbnail_image.dart b/mobile/lib/widgets/asset_grid/thumbnail_image.dart index 8e818f64fb7cc..6cadef763d730 100644 --- a/mobile/lib/widgets/asset_grid/thumbnail_image.dart +++ b/mobile/lib/widgets/asset_grid/thumbnail_image.dart @@ -1,11 +1,11 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/widgets/common/immich_thumbnail.dart'; import 'package:immich_mobile/utils/storage_indicator.dart'; -import 'package:isar/isar.dart'; class ThumbnailImage extends ConsumerWidget { /// The asset to show the thumbnail image for @@ -46,7 +46,7 @@ class ThumbnailImage extends ConsumerWidget { ? context.primaryColor.darken(amount: 0.6) : context.primaryColor.lighten(amount: 0.8); // Assets from response DTOs do not have an isar id, querying which would give us the default autoIncrement id - final isFromDto = asset.id == Isar.autoIncrement; + final isFromDto = asset.id == noDbId; Widget buildSelectionIcon(Asset asset) { if (isSelected) { diff --git a/mobile/lib/widgets/search/search_filter/people_picker.dart b/mobile/lib/widgets/search/search_filter/people_picker.dart index d79ae5bd95d3b..dfc435c807158 100644 --- a/mobile/lib/widgets/search/search_filter/people_picker.dart +++ b/mobile/lib/widgets/search/search_filter/people_picker.dart @@ -3,23 +3,23 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/interfaces/person_api.interface.dart'; import 'package:immich_mobile/providers/search/people.provider.dart'; import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/utils/image_url_builder.dart'; -import 'package:openapi/api.dart'; class PeoplePicker extends HookConsumerWidget { const PeoplePicker({super.key, required this.onSelect, this.filter}); - final Function(Set) onSelect; - final Set? filter; + final Function(Set) onSelect; + final Set? filter; @override Widget build(BuildContext context, WidgetRef ref) { var imageSize = 45.0; final people = ref.watch(getAllPeopleProvider); final headers = ApiService.getRequestHeaders(); - final selectedPeople = useState>(filter ?? {}); + final selectedPeople = useState>(filter ?? {}); return people.widgetWhen( onData: (people) { From f031c096870e75e8a31ca357935fd8a24273613f Mon Sep 17 00:00:00 2001 From: JonOcto <22536384+JonOcto@users.noreply.github.com> Date: Wed, 25 Sep 2024 00:18:07 +1000 Subject: [PATCH 082/123] fix(docs): typo in remote-access.md (#12895) Fixed typo in remote-access.md Fixed spelling of "tutorial". --- docs/docs/guides/remote-access.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/guides/remote-access.md b/docs/docs/guides/remote-access.md index 1ea068c3a0a79..6f401dfc5a5c3 100644 --- a/docs/docs/guides/remote-access.md +++ b/docs/docs/guides/remote-access.md @@ -27,7 +27,7 @@ You may use a VPN service to open an encrypted connection to your Immich instanc If you are unable to open a port on your router for Wireguard or OpenVPN to your server, [Tailscale](https://tailscale.com/) is a good option. Tailscale mediates a peer-to-peer wireguard tunnel between your server and remote device, even if one or both of them are behind a [NAT firewall](https://en.wikipedia.org/wiki/Network_address_translation). -:::tip Video toturial +:::tip Video tutorial You can learn how to set up Tailscale together with Immich with the [tutorial video](https://www.youtube.com/watch?v=Vt4PDUXB_fg) they created. ::: From b85d8943e7ce65f826e2c56d8a23922994dc22fa Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 24 Sep 2024 10:36:25 -0400 Subject: [PATCH 083/123] chore(deps): update base-image to v20240924 (major) (#12893) chore(deps): update base-image to v20240924 Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- server/Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/Dockerfile b/server/Dockerfile index 64dcab758b5b2..66965c0edb73e 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -1,5 +1,5 @@ # dev build -FROM ghcr.io/immich-app/base-server-dev:20240917@sha256:3d92952d37cd68f5bf641aa80e5cc034e0d11f3774147f5db8db93138cfa5b3b AS dev +FROM ghcr.io/immich-app/base-server-dev:20240924@sha256:fff4358d435065a626c64a4c015cbfce6ee714b05fabe39aa0d83d8cff3951f2 AS dev RUN apt-get install --no-install-recommends -yqq tini WORKDIR /usr/src/app @@ -41,7 +41,7 @@ RUN npm run build # prod build -FROM ghcr.io/immich-app/base-server-prod:20240917@sha256:67a40250f03812fe1e6f6b6345a3c7b71b3a9f24c65ed4862e82be8b3e53d23a +FROM ghcr.io/immich-app/base-server-prod:20240924@sha256:af3089fe48d7ff162594bd7edfffa56ba4e7014ad10ad69c4ebfd428e39b06ff WORKDIR /usr/src/app ENV NODE_ENV=production \ From af8f3774d0f6dc582c4a8449e315b18d629d68cf Mon Sep 17 00:00:00 2001 From: Matthew Momjian <50788000+mmomjian@users.noreply.github.com> Date: Tue, 24 Sep 2024 10:38:13 -0400 Subject: [PATCH 084/123] docs: details for windows users how to change docker volume (#12551) * details for windows users * Update requirements.md --- docs/docs/install/docker-compose.mdx | 1 + docs/docs/install/environment-variables.md | 28 ++++++------------- docs/docs/install/requirements.md | 32 ++++++++++++++++++++-- 3 files changed, 38 insertions(+), 23 deletions(-) diff --git a/docs/docs/install/docker-compose.mdx b/docs/docs/install/docker-compose.mdx index a3bd703a01c8c..b73d51b4d240a 100644 --- a/docs/docs/install/docker-compose.mdx +++ b/docs/docs/install/docker-compose.mdx @@ -58,6 +58,7 @@ Optionally, you can enable hardware acceleration for machine learning and transc - Populate `UPLOAD_LOCATION` with your preferred location for storing backup assets. - Consider changing `DB_PASSWORD` to a custom value. Postgres is not publically exposed, so this password is only used for local authentication. To avoid issues with Docker parsing this value, it is best to use only the characters `A-Za-z0-9`. +- Set your timezone by uncommenting the `TZ=` line. ### Step 3 - Start the containers diff --git a/docs/docs/install/environment-variables.md b/docs/docs/install/environment-variables.md index a0cf71e044724..3944f6755b65a 100644 --- a/docs/docs/install/environment-variables.md +++ b/docs/docs/install/environment-variables.md @@ -27,23 +27,14 @@ If this should not work, try running `docker compose up -d --force-recreate`. These environment variables are used by the `docker-compose.yml` file and do **NOT** affect the containers directly. ::: -### Supported filesystems - -The Immich Postgres database (`DB_DATA_LOCATION`) must be located on a filesystem that supports user/group -ownership and permissions (EXT2/3/4, ZFS, APFS, BTRFS, XFS, etc.). It will not work on any filesystem formatted in NTFS or ex/FAT/32. -It will not work in WSL (Windows Subsystem for Linux) when using a mounted host directory (commonly under `/mnt`). -If this is an issue, you can change the bind mount to a Docker volume instead. - -Regardless of filesystem, it is not recommended to use a network share for your database location due to performance and possible data loss issues. - ## General | Variable | Description | Default | Containers | Workers | | :---------------------------------- | :---------------------------------------------------------------------------------------- | :--------------------------: | :----------------------- | :----------------- | -| `TZ` | Timezone | | server | microservices | +| `TZ` | Timezone | \*1 | server | microservices | | `IMMICH_ENV` | Environment (production, development) | `production` | server, machine learning | api, microservices | | `IMMICH_LOG_LEVEL` | Log Level (verbose, debug, log, warn, error) | `log` | server, machine learning | api, microservices | -| `IMMICH_MEDIA_LOCATION` | Media Location inside the container ⚠️**You probably shouldn't set this**\*1⚠️ | `./upload`\*2 | server | api, microservices | +| `IMMICH_MEDIA_LOCATION` | Media Location inside the container ⚠️**You probably shouldn't set this**\*2⚠️ | `./upload`\*3 | server | api, microservices | | `IMMICH_CONFIG_FILE` | Path to config file | | server | api, microservices | | `NO_COLOR` | Set to `true` to disable color-coded log output | `false` | server, machine learning | | | `CPU_CORES` | Amount of cores available to the immich server | auto-detected cpu core count | server | | @@ -52,16 +43,13 @@ Regardless of filesystem, it is not recommended to use a network share for your | `IMMICH_PROCESS_INVALID_IMAGES` | When `true`, generate thumbnails for invalid images | | server | microservices | | `IMMICH_TRUSTED_PROXIES` | List of comma separated IPs set as trusted proxies | | server | api | -\*1: This path is where the Immich code looks for the files, which is internal to the docker container. Setting it to a path on your host will certainly break things, you should use the `UPLOAD_LOCATION` variable instead. - -\*2: With the default `WORKDIR` of `/usr/src/app`, this path will resolve to `/usr/src/app/upload`. -It only need to be set if the Immich deployment method is changing. - -:::tip -`TZ` should be set to a `TZ identifier` from [this list][tz-list]. For example, `TZ="Etc/UTC"`. - +\*1: `TZ` should be set to a `TZ identifier` from [this list][tz-list]. For example, `TZ="Etc/UTC"`. `TZ` is used by `exiftool` as a fallback in case the timezone cannot be determined from the image metadata. It is also used for logfile timestamps and cron job execution. -::: + +\*2: This path is where the Immich code looks for the files, which is internal to the docker container. Setting it to a path on your host will certainly break things, you should use the `UPLOAD_LOCATION` variable instead. + +\*3: With the default `WORKDIR` of `/usr/src/app`, this path will resolve to `/usr/src/app/upload`. +It only need to be set if the Immich deployment method is changing. ## Workers diff --git a/docs/docs/install/requirements.md b/docs/docs/install/requirements.md index 88d85c7bee8cc..b96705203aa8f 100644 --- a/docs/docs/install/requirements.md +++ b/docs/docs/install/requirements.md @@ -23,7 +23,33 @@ Immich requires the command `docker compose` - the similarly named `docker-compo - **RAM**: Minimum 4GB, recommended 6GB. - **CPU**: Minimum 2 cores, recommended 4 cores. - **Storage**: Recommended Unix-compatible filesystem (EXT4, ZFS, APFS, etc.) with support for user/group ownership and permissions. - - This can present an issue for Windows users. See [here](/docs/install/environment-variables#supported-filesystems) - for more details and alternatives. + - This can present an issue for Windows users. See below for details and an alternative setup. - The generation of thumbnails and transcoded video can increase the size of the photo library by 10-20% on average. - - Network shares are supported for the storage of image and video assets only. + - Network shares are supported for the storage of image and video assets only. It is not recommended to use a network share for your database location due to performance and possible data loss issues. + +### Special requirements for Windows users + +
+Database storage on Windows systems + +The Immich Postgres database (`DB_DATA_LOCATION`) must be located on a filesystem that supports user/group +ownership and permissions (EXT2/3/4, ZFS, APFS, BTRFS, XFS, etc.). It will not work on any filesystem formatted in NTFS or ex/FAT/32. +It will not work in WSL (Windows Subsystem for Linux) when using a mounted host directory (commonly under `/mnt`). +If this is an issue, you can change the bind mount to a Docker volume instead as follows: + +Make the following change to `.env`: + +```diff +- DB_DATA_LOCATION=./postgres ++ DB_DATA_LOCATION=pgdata +``` + +Add the following line to the bottom of `docker-compose.yml`: + +```diff +volumes: + model-cache: ++ pgdata: +``` + +
From b45fce8ddf773f9e4033d4819de18ea85603209b Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Tue, 24 Sep 2024 17:13:37 +0200 Subject: [PATCH 085/123] fix: album title state weirdness (#12874) --- web/src/lib/components/album-page/album-title.svelte | 7 ++++--- .../[[photos=photos]]/[[assetId=id]]/+page.svelte | 7 ++++++- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/web/src/lib/components/album-page/album-title.svelte b/web/src/lib/components/album-page/album-title.svelte index 22c26aa10c5a6..1e69ecf1a3c2d 100644 --- a/web/src/lib/components/album-page/album-title.svelte +++ b/web/src/lib/components/album-page/album-title.svelte @@ -7,6 +7,7 @@ export let id: string; export let albumName: string; export let isOwned: boolean; + export let onUpdate: (albumName: string) => void; $: newAlbumName = albumName; @@ -16,17 +17,17 @@ } try { - await updateAlbumInfo({ + ({ albumName } = await updateAlbumInfo({ id, updateAlbumDto: { albumName: newAlbumName, }, - }); + })); + onUpdate(albumName); } catch (error) { handleError(error, $t('errors.unable_to_save_album')); return; } - albumName = newAlbumName; }; diff --git a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte index cbdb38192e082..b11bf9b8aa9ef 100644 --- a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -589,7 +589,12 @@ {#if viewMode !== ViewMode.SELECT_THUMBNAIL}
- + (album.albumName = albumName)} + /> {#if album.assetCount > 0} From 05d8c4c132b08052293ec6e45b8a1ebe7a2eb8e6 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Tue, 24 Sep 2024 17:53:57 -0400 Subject: [PATCH 086/123] fix: do not use trashed assets as album covers (#12905) --- server/src/queries/album.repository.sql | 27 ++++++++++--------- server/src/repositories/album.repository.ts | 30 +++++++++------------ 2 files changed, 26 insertions(+), 31 deletions(-) diff --git a/server/src/queries/album.repository.sql b/server/src/queries/album.repository.sql index cc052e9de63b4..c4f6fbdd3218b 100644 --- a/server/src/queries/album.repository.sql +++ b/server/src/queries/album.repository.sql @@ -483,16 +483,13 @@ UPDATE "albums" SET "albumThumbnailAssetId" = ( SELECT - "albums_assets2"."assetsId" + "album_assets"."assetsId" FROM - "assets" "assets", - "albums_assets_assets" "albums_assets2" + "albums_assets_assets" "album_assets" + INNER JOIN "assets" "assets" ON "album_assets"."assetsId" = "assets"."id" + AND "assets"."deletedAt" IS NULL WHERE - ( - "albums_assets2"."assetsId" = "assets"."id" - AND "albums_assets2"."albumsId" = "albums"."id" - ) - AND ("assets"."deletedAt" IS NULL) + "album_assets"."albumsId" = "albums"."id" ORDER BY "assets"."fileCreatedAt" DESC LIMIT @@ -505,17 +502,21 @@ WHERE SELECT 1 FROM - "albums_assets_assets" "albums_assets" + "albums_assets_assets" "album_assets" + INNER JOIN "assets" "assets" ON "album_assets"."assetsId" = "assets"."id" + AND "assets"."deletedAt" IS NULL WHERE - "albums"."id" = "albums_assets"."albumsId" + "album_assets"."albumsId" = "albums"."id" ) OR "albums"."albumThumbnailAssetId" IS NOT NULL AND NOT EXISTS ( SELECT 1 FROM - "albums_assets_assets" "albums_assets" + "albums_assets_assets" "album_assets" + INNER JOIN "assets" "assets" ON "album_assets"."assetsId" = "assets"."id" + AND "assets"."deletedAt" IS NULL WHERE - "albums"."id" = "albums_assets"."albumsId" - AND "albums"."albumThumbnailAssetId" = "albums_assets"."assetsId" + "album_assets"."albumsId" = "albums"."id" + AND "albums"."albumThumbnailAssetId" = "album_assets"."assetsId" ) diff --git a/server/src/repositories/album.repository.ts b/server/src/repositories/album.repository.ts index 4101d78c8ed3c..f7b4cb44aa976 100644 --- a/server/src/repositories/album.repository.ts +++ b/server/src/repositories/album.repository.ts @@ -277,32 +277,26 @@ export class AlbumRepository implements IAlbumRepository { @GenerateSql() async updateThumbnails(): Promise { // Subquery for getting a new thumbnail. - const newThumbnail = this.assetRepository - .createQueryBuilder('assets') - .select('albums_assets2.assetsId') - .addFrom('albums_assets_assets', 'albums_assets2') - .where('albums_assets2.assetsId = assets.id') - .andWhere('albums_assets2.albumsId = "albums"."id"') // Reference to albums.id outside this query - .orderBy('assets.fileCreatedAt', 'DESC') - .limit(1); - // Using dataSource, because there is no direct access to albums_assets_assets. - const albumHasAssets = this.dataSource - .createQueryBuilder() - .select('1') - .from('albums_assets_assets', 'albums_assets') - .where('"albums"."id" = "albums_assets"."albumsId"'); + const builder = this.dataSource + .createQueryBuilder('albums_assets_assets', 'album_assets') + .innerJoin('assets', 'assets', '"album_assets"."assetsId" = "assets"."id"') + .where('"album_assets"."albumsId" = "albums"."id"'); - const albumContainsThumbnail = albumHasAssets + const newThumbnail = builder .clone() - .andWhere('"albums"."albumThumbnailAssetId" = "albums_assets"."assetsId"'); + .select('"album_assets"."assetsId"') + .orderBy('"assets"."fileCreatedAt"', 'DESC') + .limit(1); + const hasAssets = builder.clone().select('1'); + const hasInvalidAsset = hasAssets.clone().andWhere('"albums"."albumThumbnailAssetId" = "album_assets"."assetsId"'); const updateAlbums = this.repository .createQueryBuilder('albums') .update(AlbumEntity) .set({ albumThumbnailAssetId: () => `(${newThumbnail.getQuery()})` }) - .where(`"albums"."albumThumbnailAssetId" IS NULL AND EXISTS (${albumHasAssets.getQuery()})`) - .orWhere(`"albums"."albumThumbnailAssetId" IS NOT NULL AND NOT EXISTS (${albumContainsThumbnail.getQuery()})`); + .where(`"albums"."albumThumbnailAssetId" IS NULL AND EXISTS (${hasAssets.getQuery()})`) + .orWhere(`"albums"."albumThumbnailAssetId" IS NOT NULL AND NOT EXISTS (${hasInvalidAsset.getQuery()})`); const result = await updateAlbums.execute(); From 06f1376de38682a11fe18fb305ca579c7f54b804 Mon Sep 17 00:00:00 2001 From: Cary Keesler <44330591+carykees98@users.noreply.github.com> Date: Wed, 25 Sep 2024 08:59:35 -0400 Subject: [PATCH 087/123] fix(web): Updated web README.md (#12899) Updated web README.md --- web/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/README.md b/web/README.md index e9693ceb01410..603c7ad64e720 100644 --- a/web/README.md +++ b/web/README.md @@ -2,4 +2,4 @@ This project uses the [SvelteKit](https://kit.svelte.dev/) web framework. Please refer to [the SvelteKit docs](https://kit.svelte.dev/docs) for information on getting started as a contributor to this project. In particular, it will help you navigate the project's code if you understand the basics of [SvelteKit routing](https://kit.svelte.dev/docs/routing). -When developing locally, you will run a SvelteKit Node.js server. When this project is deployed to production, it is built as a SPA and deployed as part of [../server](the server project). +When developing locally, you will run a SvelteKit Node.js server. When this project is deployed to production, it is built as a SPA and deployed as part of [the server project](../server). From 46fe60693e309cf57eea72c397b3ecf1ba523783 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 25 Sep 2024 09:56:02 -0400 Subject: [PATCH 088/123] chore(deps): update dependency @types/react to v18.3.8 (#12918) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- server/package-lock.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/server/package-lock.json b/server/package-lock.json index ba9f33dc1e425..65e9df8d9eb90 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -5506,9 +5506,9 @@ "dev": true }, "node_modules/@types/react": { - "version": "18.3.7", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.7.tgz", - "integrity": "sha512-KUnDCJF5+AiZd8owLIeVHqmW9yM4sqmDVf2JRJiBMFkGvkoZ4/WyV2lL4zVsoinmRS/W3FeEdZLEWFRofnT2FQ==", + "version": "18.3.8", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.8.tgz", + "integrity": "sha512-syBUrW3/XpnW4WJ41Pft+I+aPoDVbrBVQGEnbD7NijDGlVC+8gV/XKRY+7vMDlfPpbwYt0l1vd/Sj8bJGMbs9Q==", "dev": true, "dependencies": { "@types/prop-types": "*", @@ -18805,9 +18805,9 @@ "dev": true }, "@types/react": { - "version": "18.3.7", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.7.tgz", - "integrity": "sha512-KUnDCJF5+AiZd8owLIeVHqmW9yM4sqmDVf2JRJiBMFkGvkoZ4/WyV2lL4zVsoinmRS/W3FeEdZLEWFRofnT2FQ==", + "version": "18.3.8", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.8.tgz", + "integrity": "sha512-syBUrW3/XpnW4WJ41Pft+I+aPoDVbrBVQGEnbD7NijDGlVC+8gV/XKRY+7vMDlfPpbwYt0l1vd/Sj8bJGMbs9Q==", "dev": true, "requires": { "@types/prop-types": "*", From 8d515adac517c4871b33fba48cf37e25580e96e3 Mon Sep 17 00:00:00 2001 From: Ben <45583362+ben-basten@users.noreply.github.com> Date: Wed, 25 Sep 2024 12:04:53 -0400 Subject: [PATCH 089/123] feat(web): fixed combobox positioning (#12848) * fix(web): modal sticky bottom scrolling * chore: minor styling tweaks * wip: add portal so modals show on Safari in detail panel * feat: fixed position dropdown menu * chore: refactoring and cleanup * feat: zooming and virtual keyboard working for iPadOS/Safari * Revert "feat: zooming and virtual keyboard working for iPadOS/Safari" This reverts commit cac29bac0df9112cec1d4c66af82dd343081e08a. * wip: minor code cleanup * wip: recover from visual viewport changes * wip: ease in a little more visualviewport magic * wip: code cleanup * fix: only show dropdown above when viewport is zoomed out * fix: code review suggestions for code style Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> * fix: better variable naming * chore: better documentation for the bottom breakpoint --------- Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> --- .../asset-viewer/detail-panel-tags.svelte | 5 +- .../asset-viewer/detail-panel.svelte | 15 ++- .../shared-components/combobox.svelte | 116 +++++++++++++++++- .../full-screen-modal.svelte | 22 ++-- web/src/lib/i18n/en.json | 2 +- 5 files changed, 134 insertions(+), 26 deletions(-) diff --git a/web/src/lib/components/asset-viewer/detail-panel-tags.svelte b/web/src/lib/components/asset-viewer/detail-panel-tags.svelte index 434682f73ec27..449f61183fb33 100644 --- a/web/src/lib/components/asset-viewer/detail-panel-tags.svelte +++ b/web/src/lib/components/asset-viewer/detail-panel-tags.svelte @@ -1,6 +1,7 @@ +
{#if isOpen} @@ -228,7 +334,7 @@ role="option" aria-selected={selectedIndex === 0} aria-disabled={true} - class="text-left w-full px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-default aria-selected:bg-gray-100 aria-selected:dark:bg-gray-700" + class="text-left w-full px-4 py-2 hover:bg-gray-200 dark:hover:bg-gray-700 cursor-default aria-selected:bg-gray-200 aria-selected:dark:bg-gray-700" id={`${listboxId}-${0}`} on:click={() => closeDropdown()} > @@ -240,7 +346,7 @@
  • handleSelect(option)} role="option" diff --git a/web/src/lib/components/shared-components/full-screen-modal.svelte b/web/src/lib/components/shared-components/full-screen-modal.svelte index b5b21f0c23516..ececa25b1ef7f 100644 --- a/web/src/lib/components/shared-components/full-screen-modal.svelte +++ b/web/src/lib/components/shared-components/full-screen-modal.svelte @@ -68,28 +68,24 @@ use:focusTrap >
    -
    +
    - {#if isStickyBottom} -
    - -
    - {/if}
    + {#if isStickyBottom} +
    + +
    + {/if}
  • diff --git a/web/src/lib/i18n/en.json b/web/src/lib/i18n/en.json index aaa3c77e2bb48..534ac086360b1 100644 --- a/web/src/lib/i18n/en.json +++ b/web/src/lib/i18n/en.json @@ -1194,7 +1194,7 @@ "tag_assets": "Tag assets", "tag_created": "Created tag: {tag}", "tag_feature_description": "Browsing photos and videos grouped by logical tag topics", - "tag_not_found_question": "Cannot find a tag? Create one here", + "tag_not_found_question": "Cannot find a tag? Create a new tag.", "tag_updated": "Updated tag: {tag}", "tagged_assets": "Tagged {count, plural, one {# asset} other {# assets}}", "tags": "Tags", From 005528ab5ec6514e2b93a52a5dbe43481821b733 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Wed, 25 Sep 2024 12:05:03 -0400 Subject: [PATCH 090/123] fix(server): http error parsing on endpoints without a default response (#12927) --- mobile/openapi/README.md | 1 + mobile/openapi/lib/api.dart | 1 + mobile/openapi/lib/api/notifications_api.dart | 10 +- mobile/openapi/lib/api_client.dart | 2 + .../lib/model/test_email_response_dto.dart | 99 +++++++++++++++++++ open-api/immich-openapi-specs.json | 18 ++++ open-api/typescript-sdk/src/fetch-client.ts | 8 +- .../controllers/notification.controller.ts | 3 +- server/src/dtos/notification.dto.ts | 3 + .../src/services/notification.service.spec.ts | 5 - server/src/services/notification.service.ts | 12 +-- .../notification.repository.mock.ts | 2 +- web/src/lib/utils/handle-error.ts | 16 ++- 13 files changed, 162 insertions(+), 18 deletions(-) create mode 100644 mobile/openapi/lib/model/test_email_response_dto.dart create mode 100644 server/src/dtos/notification.dto.ts diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 285514e11cd54..b6b0897e8f5e0 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -448,6 +448,7 @@ Class | Method | HTTP request | Description - [TagUpsertDto](doc//TagUpsertDto.md) - [TagsResponse](doc//TagsResponse.md) - [TagsUpdate](doc//TagsUpdate.md) + - [TestEmailResponseDto](doc//TestEmailResponseDto.md) - [TimeBucketResponseDto](doc//TimeBucketResponseDto.md) - [TimeBucketSize](doc//TimeBucketSize.md) - [ToneMapping](doc//ToneMapping.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index fc0224a8c2072..d08b6fc52138b 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -260,6 +260,7 @@ part 'model/tag_update_dto.dart'; part 'model/tag_upsert_dto.dart'; part 'model/tags_response.dart'; part 'model/tags_update.dart'; +part 'model/test_email_response_dto.dart'; part 'model/time_bucket_response_dto.dart'; part 'model/time_bucket_size.dart'; part 'model/tone_mapping.dart'; diff --git a/mobile/openapi/lib/api/notifications_api.dart b/mobile/openapi/lib/api/notifications_api.dart index a3506b9bc1a7f..0681d582479ad 100644 --- a/mobile/openapi/lib/api/notifications_api.dart +++ b/mobile/openapi/lib/api/notifications_api.dart @@ -48,10 +48,18 @@ class NotificationsApi { /// Parameters: /// /// * [SystemConfigSmtpDto] systemConfigSmtpDto (required): - Future sendTestEmail(SystemConfigSmtpDto systemConfigSmtpDto,) async { + Future sendTestEmail(SystemConfigSmtpDto systemConfigSmtpDto,) async { final response = await sendTestEmailWithHttpInfo(systemConfigSmtpDto,); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'TestEmailResponseDto',) as TestEmailResponseDto; + + } + return null; } } diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 828c0b9ed925c..c62d1c5b2e2b2 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -574,6 +574,8 @@ class ApiClient { return TagsResponse.fromJson(value); case 'TagsUpdate': return TagsUpdate.fromJson(value); + case 'TestEmailResponseDto': + return TestEmailResponseDto.fromJson(value); case 'TimeBucketResponseDto': return TimeBucketResponseDto.fromJson(value); case 'TimeBucketSize': diff --git a/mobile/openapi/lib/model/test_email_response_dto.dart b/mobile/openapi/lib/model/test_email_response_dto.dart new file mode 100644 index 0000000000000..33e6c042d8d09 --- /dev/null +++ b/mobile/openapi/lib/model/test_email_response_dto.dart @@ -0,0 +1,99 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class TestEmailResponseDto { + /// Returns a new [TestEmailResponseDto] instance. + TestEmailResponseDto({ + required this.messageId, + }); + + String messageId; + + @override + bool operator ==(Object other) => identical(this, other) || other is TestEmailResponseDto && + other.messageId == messageId; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (messageId.hashCode); + + @override + String toString() => 'TestEmailResponseDto[messageId=$messageId]'; + + Map toJson() { + final json = {}; + json[r'messageId'] = this.messageId; + return json; + } + + /// Returns a new [TestEmailResponseDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static TestEmailResponseDto? fromJson(dynamic value) { + upgradeDto(value, "TestEmailResponseDto"); + if (value is Map) { + final json = value.cast(); + + return TestEmailResponseDto( + messageId: mapValueOfType(json, r'messageId')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = TestEmailResponseDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = TestEmailResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of TestEmailResponseDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = TestEmailResponseDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'messageId', + }; +} + diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 99ea313063fce..1a070f126b9b9 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -3491,6 +3491,13 @@ }, "responses": { "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestEmailResponseDto" + } + } + }, "description": "" } }, @@ -12348,6 +12355,17 @@ }, "type": "object" }, + "TestEmailResponseDto": { + "properties": { + "messageId": { + "type": "string" + } + }, + "required": [ + "messageId" + ], + "type": "object" + }, "TimeBucketResponseDto": { "properties": { "count": { diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index d1b88afabb043..f2f946f2626e2 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -656,6 +656,9 @@ export type SystemConfigSmtpDto = { replyTo: string; transport: SystemConfigSmtpTransportDto; }; +export type TestEmailResponseDto = { + messageId: string; +}; export type OAuthConfigDto = { redirectUri: string; }; @@ -2220,7 +2223,10 @@ export function addMemoryAssets({ id, bulkIdsDto }: { export function sendTestEmail({ systemConfigSmtpDto }: { systemConfigSmtpDto: SystemConfigSmtpDto; }, opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchText("/notifications/test-email", oazapfts.json({ + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: TestEmailResponseDto; + }>("/notifications/test-email", oazapfts.json({ ...opts, method: "POST", body: systemConfigSmtpDto diff --git a/server/src/controllers/notification.controller.ts b/server/src/controllers/notification.controller.ts index 2772e93b5d816..3dd72dd73a91d 100644 --- a/server/src/controllers/notification.controller.ts +++ b/server/src/controllers/notification.controller.ts @@ -1,6 +1,7 @@ import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { AuthDto } from 'src/dtos/auth.dto'; +import { TestEmailResponseDto } from 'src/dtos/notification.dto'; import { SystemConfigSmtpDto } from 'src/dtos/system-config.dto'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { NotificationService } from 'src/services/notification.service'; @@ -13,7 +14,7 @@ export class NotificationController { @Post('test-email') @HttpCode(HttpStatus.OK) @Authenticated({ admin: true }) - sendTestEmail(@Auth() auth: AuthDto, @Body() dto: SystemConfigSmtpDto) { + sendTestEmail(@Auth() auth: AuthDto, @Body() dto: SystemConfigSmtpDto): Promise { return this.service.sendTestEmail(auth.user.id, dto); } } diff --git a/server/src/dtos/notification.dto.ts b/server/src/dtos/notification.dto.ts new file mode 100644 index 0000000000000..34b3923580830 --- /dev/null +++ b/server/src/dtos/notification.dto.ts @@ -0,0 +1,3 @@ +export class TestEmailResponseDto { + messageId!: string; +} diff --git a/server/src/services/notification.service.spec.ts b/server/src/services/notification.service.spec.ts index 9ef1310bfbaca..a0b9436f75435 100644 --- a/server/src/services/notification.service.spec.ts +++ b/server/src/services/notification.service.spec.ts @@ -616,11 +616,6 @@ describe(NotificationService.name, () => { await expect(sut.handleSendEmail({ html: '', subject: '', text: '', to: '' })).resolves.toBe(JobStatus.SKIPPED); }); - it('should fail if email could not be sent', async () => { - systemMock.get.mockResolvedValue({ notifications: { smtp: { enabled: true } } }); - await expect(sut.handleSendEmail({ html: '', subject: '', text: '', to: '' })).resolves.toBe(JobStatus.FAILED); - }); - it('should send mail successfully', async () => { systemMock.get.mockResolvedValue({ notifications: { smtp: { enabled: true, from: 'test@immich.app' } } }); notificationMock.sendEmail.mockResolvedValue({ messageId: '', response: '' }); diff --git a/server/src/services/notification.service.ts b/server/src/services/notification.service.ts index 4eef49c631511..bdb23ce700ab2 100644 --- a/server/src/services/notification.service.ts +++ b/server/src/services/notification.service.ts @@ -1,4 +1,4 @@ -import { HttpException, HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { BadRequestException, Inject, Injectable } from '@nestjs/common'; import { DEFAULT_EXTERNAL_DOMAIN } from 'src/constants'; import { SystemConfigCore } from 'src/cores/system-config.core'; import { OnEmit } from 'src/decorators'; @@ -140,7 +140,7 @@ export class NotificationService { try { await this.notificationRepository.verifySmtp(dto.transport); } catch (error) { - throw new HttpException('Failed to verify SMTP configuration', HttpStatus.BAD_REQUEST, { cause: error }); + throw new BadRequestException('Failed to verify SMTP configuration', { cause: error }); } const { server } = await this.configCore.getConfig({ withCache: false }); @@ -152,7 +152,7 @@ export class NotificationService { }, }); - await this.notificationRepository.sendEmail({ + const { messageId } = await this.notificationRepository.sendEmail({ to: user.email, subject: 'Test email from Immich', html, @@ -161,6 +161,8 @@ export class NotificationService { replyTo: dto.replyTo || dto.from, smtp: dto.transport, }); + + return { messageId }; } async handleUserSignup({ id, tempPassword }: INotifySignupJob) { @@ -312,10 +314,6 @@ export class NotificationService { imageAttachments: data.imageAttachments, }); - if (!response) { - return JobStatus.FAILED; - } - this.logger.log(`Sent mail with id: ${response.messageId} status: ${response.response}`); return JobStatus.SUCCESS; diff --git a/server/test/repositories/notification.repository.mock.ts b/server/test/repositories/notification.repository.mock.ts index 71975b429c98e..16862dc3d762b 100644 --- a/server/test/repositories/notification.repository.mock.ts +++ b/server/test/repositories/notification.repository.mock.ts @@ -4,7 +4,7 @@ import { Mocked } from 'vitest'; export const newNotificationRepositoryMock = (): Mocked => { return { renderEmail: vitest.fn(), - sendEmail: vitest.fn(), + sendEmail: vitest.fn().mockResolvedValue({ messageId: 'message-1' }), verifySmtp: vitest.fn(), }; }; diff --git a/web/src/lib/utils/handle-error.ts b/web/src/lib/utils/handle-error.ts index 9ca5bc8773e34..a7e9a4340c081 100644 --- a/web/src/lib/utils/handle-error.ts +++ b/web/src/lib/utils/handle-error.ts @@ -2,9 +2,21 @@ import { isHttpError } from '@immich/sdk'; import { notificationController, NotificationType } from '../components/shared-components/notification/notification'; export function getServerErrorMessage(error: unknown) { - if (isHttpError(error)) { - return error.data?.message || error.message; + if (!isHttpError(error)) { + return; } + + // errors for endpoints without return types aren't parsed as json + let data = error.data; + if (typeof data === 'string') { + try { + data = JSON.parse(data); + } catch { + // Not a JSON string + } + } + + return data?.message || error.message; } export function handleError(error: unknown, message: string) { From 35e03c1d6fffc01703b4803100acda9267c3af7d Mon Sep 17 00:00:00 2001 From: "Weblate (bot)" Date: Wed, 25 Sep 2024 18:19:10 +0200 Subject: [PATCH 091/123] chore(web): update translations (#12737) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: -J- Co-authored-by: Albert Stoynov Co-authored-by: Benjamin Gynther Co-authored-by: Bezruchenko Simon Co-authored-by: CanbiZ Co-authored-by: David Abner Ciuhan Co-authored-by: Dean Cvjetanović Co-authored-by: Denis Pacquier Co-authored-by: Fjuro Co-authored-by: Florian Ostertag Co-authored-by: Hary Co-authored-by: Hurricane-32 Co-authored-by: Indrek Haav Co-authored-by: João Gonçalves Co-authored-by: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Co-authored-by: Miki Mrvos Co-authored-by: Mārtiņš Bruņenieks Co-authored-by: Petri Hämäläinen Co-authored-by: Shawn Co-authored-by: Xo Co-authored-by: btpv Co-authored-by: chapvic Co-authored-by: dvbthien Co-authored-by: fmis13 Co-authored-by: gallegonovato Co-authored-by: phewi Co-authored-by: pyccl Co-authored-by: pyorot Co-authored-by: rrole Co-authored-by: 李奕寯 --- web/src/lib/i18n/bg.json | 14 +- web/src/lib/i18n/ca.json | 15 +- web/src/lib/i18n/cs.json | 7 + web/src/lib/i18n/de.json | 7 + web/src/lib/i18n/es.json | 7 + web/src/lib/i18n/et.json | 137 ++- web/src/lib/i18n/fi.json | 439 +++++++--- web/src/lib/i18n/fr.json | 31 +- web/src/lib/i18n/he.json | 7 + web/src/lib/i18n/hr.json | 275 ++++-- web/src/lib/i18n/hu.json | 137 ++- web/src/lib/i18n/id.json | 7 + web/src/lib/i18n/lv.json | 133 +-- web/src/lib/i18n/nl.json | 8 + web/src/lib/i18n/pt.json | 1238 ++++++++++++++------------- web/src/lib/i18n/ro.json | 86 +- web/src/lib/i18n/ru.json | 7 + web/src/lib/i18n/sr_Cyrl.json | 7 + web/src/lib/i18n/sr_Latn.json | 9 +- web/src/lib/i18n/uk.json | 7 + web/src/lib/i18n/vi.json | 7 + web/src/lib/i18n/zh_Hant.json | 40 +- web/src/lib/i18n/zh_SIMPLIFIED.json | 18 +- 23 files changed, 1653 insertions(+), 990 deletions(-) diff --git a/web/src/lib/i18n/bg.json b/web/src/lib/i18n/bg.json index 29ac04eda8b1e..f069bec6b37b4 100644 --- a/web/src/lib/i18n/bg.json +++ b/web/src/lib/i18n/bg.json @@ -12,19 +12,19 @@ "add_a_description": "Добави описание", "add_a_location": "Добави местоположение", "add_a_name": "Добави име", - "add_a_title": "Добави заглавие", + "add_a_title": "Добавете заглавие", "add_exclusion_pattern": "Добави модел за изключване", "add_import_path": "Добави път за импортиране", - "add_location": "Добави местоположение", - "add_more_users": "Добави още потребители", - "add_partner": "Добави партньор", + "add_location": "Добавете местоположение", + "add_more_users": "Добавете още потребители", + "add_partner": "Добавете партньор", "add_path": "Добави път", - "add_photos": "Добави снимки", + "add_photos": "Добавете снимки", "add_to": "Добави към...", "add_to_album": "Добави към албум", "add_to_shared_album": "Добави към споделен албум", - "added_to_archive": "Добавено в архива", - "added_to_favorites": "Добавено към любими", + "added_to_archive": "Добавено към архива", + "added_to_favorites": "Добавени към любимите ви", "added_to_favorites_count": "Добавени {count, number} към любими", "admin": { "add_exclusion_pattern_description": "Добави модели за изключване. Поддържа се \"globbing\" с помощта на *, ** и ?. За да игнорирате всички файлове в директория с име \"Raw\", използвайте \"**/Raw/**\". За да игнорирате всички файлове, завършващи на \".tif\", използвайте \"**/*.tif\". За да игнорирате абсолютен път, използвайте \"/path/to/ignore/**\".", diff --git a/web/src/lib/i18n/ca.json b/web/src/lib/i18n/ca.json index e9c695f79a74e..518c0abadf992 100644 --- a/web/src/lib/i18n/ca.json +++ b/web/src/lib/i18n/ca.json @@ -8,7 +8,7 @@ "active": "Actiu", "activity": "Activitat", "activity_changed": "L'activitat està {enabled, select, true {activada} other {desactivada}}", - "add": "Afig", + "add": "Afegir", "add_a_description": "Afegiu una descripció", "add_a_location": "Afegiu una ubicació", "add_a_name": "Afegir un nom", @@ -41,6 +41,7 @@ "confirm_email_below": "Per a confirmar, escriviu \"{email}\" a sota", "confirm_reprocess_all_faces": "Esteu segur que voleu reprocessar totes les cares? Això també esborrarà la gent que heu anomenat.", "confirm_user_password_reset": "Esteu segur que voleu reinicialitzar la contrasenya de l'usuari {user}?", + "create_job": "Crear tasca", "crontab_guru": "Crontab Guru", "disable_login": "Deshabiliteu l'inici de sessió", "disabled": "Deshabilitat", @@ -70,6 +71,7 @@ "image_thumbnail_resolution": "Resolució de la miniatura", "image_thumbnail_resolution_description": "S'empra per a veure grups de fotos (cronologia, vista d'àlbum, etc.). L'alta resolució pot preservar més detalls però triguen més en codificar-se, tenen fitxers més pesats i poden reduir la reactivitat de l'aplicació.", "job_concurrency": "{job} concurrència", + "job_created": "Tasca creada", "job_not_concurrency_safe": "Aquesta tasca no és segura per a la conconcurrència.", "job_settings": "Configuració de les tasques", "job_settings_description": "Gestiona la concurrència de tasques", @@ -198,6 +200,7 @@ "password_settings": "Inici de sessió amb contrasenya", "password_settings_description": "Gestiona la configuració de l'inici de sessió amb contrasenya", "paths_validated_successfully": "Tots els camins han estat validats amb èxit", + "person_cleanup_job": "Neteja de persona", "quota_size_gib": "Tamany de la quota (GiB)", "refreshing_all_libraries": "Actualitzant totes les biblioteques", "registration": "Registre d'administrador", @@ -211,6 +214,7 @@ "reset_settings_to_recent_saved": "Restablir la configuració guardada més recent", "scanning_library_for_changed_files": "Escanejant llibreria per trobar fitxers modificats", "scanning_library_for_new_files": "Escanejant llibreria per trobar fitxers nous", + "search_jobs": "Tasques de cerca...", "send_welcome_email": "Enviar correu electrònic de benvinguda", "server_external_domain_settings": "Domini extern", "server_external_domain_settings_description": "Domini per enllaços públics compartits, incloent http(s)://", @@ -238,6 +242,7 @@ "storage_template_settings_description": "Gestiona l'estructura de les carpetes i el nom del fitxers dels elements pujats", "storage_template_user_label": "{label} és l'etiqueta d'emmagatzematge de l'usuari", "system_settings": "Configuració del sistema", + "tag_cleanup_job": "Neteja d'etiqueta", "theme_custom_css_settings": "CSS personalitzat", "theme_custom_css_settings_description": "Els Fulls d'Estil en Cascada permeten personalitzar el disseny d'Immich.", "theme_settings": "Configuració del tema", @@ -312,6 +317,7 @@ "trash_settings_description": "Gestiona la configuració de la paperera", "untracked_files": "Fitxers sense seguiment", "untracked_files_description": "L'aplicació no fa un seguiment d'aquests fitxers. Poden ser el resultat de moviments fallits, càrregues interrompudes o deixades enrere a causa d'un error", + "user_cleanup_job": "Neteja d'usuari", "user_delete_delay": "El compte i els recursos de {user} es programaran per a la supressió permanent en {delay, plural, one {# dia} other {# dies}}.", "user_delete_delay_settings": "Retard de la supressió", "user_delete_delay_settings_description": "Nombre de dies després de la supressió per eliminar permanentment el compte i els elements d'un usuari. El treball de supressió d'usuaris s'executa a mitjanit per comprovar si hi ha usuaris preparats per eliminar. Els canvis en aquesta configuració s'avaluaran en la propera execució.", @@ -925,7 +931,7 @@ "offline_paths_description": "Aquests resultats poden ser deguts a la supressió manual de fitxers que no formen part d'una biblioteca externa.", "ok": "D'acord", "oldest_first": "El més vell primer", - "onboarding": "Onboarding", + "onboarding": "Incorporació", "onboarding_privacy_description": "Les següents funcions (opcionals) depenen de serveis externs i poden desactivarse en qualsevol moment de dels ajustos.", "onboarding_theme_description": "Trieu un tema de color per a la vostra instància. Podeu canviar-ho més endavant a la vostra configuració.", "onboarding_welcome_description": "Configurem la vostra instància amb alguns paràmetres habituals.", @@ -1113,7 +1119,7 @@ "search_albums": "Buscar àlbums", "search_by_context": "Buscar per context", "search_by_filename": "Cerca per nom de fitxer o extensió", - "search_by_filename_example": "i.e. IMG_1234.JPG or PNG", + "search_by_filename_example": "per exemple IMG_1234.JPG o PNG", "search_camera_make": "Buscar per fabricant de càmara...", "search_camera_model": "Buscar per model de càmera...", "search_city": "Buscar per ciutat...", @@ -1124,6 +1130,7 @@ "search_options": "Opcions de cerca", "search_people": "Buscar persones", "search_places": "Buscar llocs", + "search_settings": "Configuració de cerca", "search_state": "Buscar per regió...", "search_tags": "Cercant etiquetes...", "search_timezone": "Buscar per fus horari...", @@ -1240,7 +1247,7 @@ "tag_feature_description": "Exploreu fotos i vídeos agrupats per temes d'etiquetes lògiques", "tag_not_found_question": "No trobeu una etiqueta? Creeu-ne una aquí", "tag_updated": "Etiqueta actualizada: {tag}", - "tagged_assets": "{count, plural, one {#Etiquetat} other {#Etiquetats}} {count, plural, one {# actiu} other {# actius}}", + "tagged_assets": "{count, plural, one {#Etiquetat} other {#Etiquetats}} {count, plural, one {# actiu} other {# actius}}", "tags": "Etiquetes", "template": "Plantilla", "theme": "Tema", diff --git a/web/src/lib/i18n/cs.json b/web/src/lib/i18n/cs.json index c2d7bce0e5664..8c262f890bb2a 100644 --- a/web/src/lib/i18n/cs.json +++ b/web/src/lib/i18n/cs.json @@ -41,6 +41,7 @@ "confirm_email_below": "Pro potvrzení zadejte níže \"{email}\"", "confirm_reprocess_all_faces": "Opravdu chcete znovu zpracovat všechny obličeje? Tím se vymažou i pojmenované osoby.", "confirm_user_password_reset": "Opravdu chcete obnovit heslo uživatele {user}?", + "create_job": "Vytvořit úlohu", "crontab_guru": "Crontab Guru", "disable_login": "Zakázat přihlášení", "disabled": "Zakázáno", @@ -70,6 +71,7 @@ "image_thumbnail_resolution": "Rozlišení miniatur", "image_thumbnail_resolution_description": "Používá se při prohlížení skupin fotografií (hlavní časová osa, zobrazení alba atd.). Vyšší rozlišení může zachovat více detailů, ale trvá déle, než se zakóduje, má větší velikost souboru a může snížit odezvu aplikace.", "job_concurrency": "Souběžnost {job}", + "job_created": "Úloha vytvořena", "job_not_concurrency_safe": "Tato úloha není bezpečená pro souběh.", "job_settings": "Úlohy", "job_settings_description": "Správa souběžnosti úloh", @@ -198,6 +200,7 @@ "password_settings": "Přihlášení heslem", "password_settings_description": "Správa nastavení přihlašování pomocí hesla", "paths_validated_successfully": "Všechny cesty byly úspěšně ověřeny", + "person_cleanup_job": "Promazání osob", "quota_size_gib": "Velikost kvóty (GiB)", "refreshing_all_libraries": "Obnovení všech knihoven", "registration": "Registrace správce", @@ -211,6 +214,7 @@ "reset_settings_to_recent_saved": "Obnovit poslední uložené nastavení", "scanning_library_for_changed_files": "Hledání změněných souborů v knihovně", "scanning_library_for_new_files": "Hledání nových souborů v knihovně", + "search_jobs": "Hledat úlohy...", "send_welcome_email": "Odeslat uvítací e-mail", "server_external_domain_settings": "Externí doména", "server_external_domain_settings_description": "Doména pro veřejně sdílené odkazy, včetně http(s)://", @@ -238,6 +242,7 @@ "storage_template_settings_description": "Správa struktury složek a názvů nahraných souborů", "storage_template_user_label": "{label} je štítek úložiště uživatele", "system_settings": "Systémová nastavení", + "tag_cleanup_job": "Promazání značek", "theme_custom_css_settings": "Vlastní CSS", "theme_custom_css_settings_description": "Kaskádové styly umožňují přizpůsobit design aplikace Immich.", "theme_settings": "Motivy", @@ -312,6 +317,7 @@ "trash_settings_description": "Správa nastavení koše", "untracked_files": "Neznámé soubory", "untracked_files_description": "Tyto soubory nejsou aplikaci známy. Mohou být výsledkem neúspěšných přesunů, přerušeného nahrávání nebo mohou zůstat pozadu kvůli chybě", + "user_cleanup_job": "Promazání uživatelů", "user_delete_delay": "Účet a položky uživatele {user} budou trvale smazány za {delay, plural, one {# den} few {# dny} other {# dní}}.", "user_delete_delay_settings": "Odložení odstranění", "user_delete_delay_settings_description": "Počet dní po odstranění, po kterých bude odstraněn účet a položky uživatele. Úloha odstraňování uživatelů se spouští o půlnoci a kontroluje uživatele, kteří jsou připraveni k odstranění. Změny tohoto nastavení se vyhodnotí při dalším spuštění.", @@ -1142,6 +1148,7 @@ "search_options": "Možnosti vyhledávání", "search_people": "Vyhledat lidi", "search_places": "Vyhledat místa", + "search_settings": "Hledat nastavení", "search_state": "Vyhledat stát...", "search_tags": "Vyhledávat značky...", "search_timezone": "Vyhledat časové pásmo...", diff --git a/web/src/lib/i18n/de.json b/web/src/lib/i18n/de.json index 352006ef6eb88..3ef036b7b01a0 100644 --- a/web/src/lib/i18n/de.json +++ b/web/src/lib/i18n/de.json @@ -41,6 +41,7 @@ "confirm_email_below": "Bestätige, indem du \"{email}\" unten eingibst", "confirm_reprocess_all_faces": "Bist du sicher, dass du alle Gesichter erneut verarbeiten möchtest? Dies löscht auch alle bereits benannten Personen.", "confirm_user_password_reset": "Bist du sicher, dass du das Passwort für {user} zurücksetzen möchtest?", + "create_job": "Job erstellen", "crontab_guru": "Crontab Guru", "disable_login": "Login deaktvieren", "disabled": "Deaktiviert", @@ -70,6 +71,7 @@ "image_thumbnail_resolution": "Miniaturansichts-Auflösung", "image_thumbnail_resolution_description": "Dies wird bei der Anzeige von Bildergruppen („Zeitleiste“, „Albumansicht“ usw.) verwendet. Höhere Auflösungen können mehr Details beibehalten, benötigen aber mehr Zeit für die Kodierung, haben größere Dateigrößen und können die Reaktionsfähigkeit der App beeinträchtigen.", "job_concurrency": "{job} - (Anzahl gleichzeitiger Prozesse)", + "job_created": "Job erstellt", "job_not_concurrency_safe": "Dieser Job ist nicht parallelisierungssicher.", "job_settings": "Job-Einstellungen", "job_settings_description": "Gleichzeitige Job-Prozessen verwalten", @@ -198,6 +200,7 @@ "password_settings": "Passwort Login", "password_settings_description": "Passwort-Anmeldeeinstellungen verwalten", "paths_validated_successfully": "Alle Pfade wurden erfolgreich validiert", + "person_cleanup_job": "Personen aufräumen", "quota_size_gib": "Kontingent (GiB)", "refreshing_all_libraries": "Alle Bibliotheken aktualisieren", "registration": "Admin-Registrierung", @@ -211,6 +214,7 @@ "reset_settings_to_recent_saved": "Einstellungen auf die zuletzt gespeicherten Einstellungen zurücksetzen", "scanning_library_for_changed_files": "Untersuche Bibliothek auf geänderte Dateien", "scanning_library_for_new_files": "Untersuche Bibliothek auf neue Dateien", + "search_jobs": "Jobs suchen...", "send_welcome_email": "Begrüssungsmail senden", "server_external_domain_settings": "Externe Domain", "server_external_domain_settings_description": "Domäne für öffentlich freigegebene Links, einschließlich http(s)://", @@ -238,6 +242,7 @@ "storage_template_settings_description": "Die Ordnerstruktur und den Dateinamen der hochgeladenen Datei verwalten", "storage_template_user_label": "{label} is das Speicher-Label des Benutzers", "system_settings": "Systemeinstellungen", + "tag_cleanup_job": "Tags aufräumen", "theme_custom_css_settings": "Benutzerdefiniertes CSS", "theme_custom_css_settings_description": "Mit Cascading Style Sheets (CSS) kann das Design von Immich angepasst werden.", "theme_settings": "Theme-Einstellungen", @@ -312,6 +317,7 @@ "trash_settings_description": "Papierkorb-Einstellungen verwalten", "untracked_files": "Unverfolgte Dateien", "untracked_files_description": "Diese Dateien werden nicht von der Application getrackt. Sie können das Ergebnis fehlgeschlagener Verschiebungen, unterbrochener Uploads oder aufgrund eines Fehlers sein", + "user_cleanup_job": "Benutzer aufräumen", "user_delete_delay": "Das Konto und die Dateien von {user} werden in {delay, plural, one {einem Tag} other {# Tagen}} für eine permanente Löschung geplant.", "user_delete_delay_settings": "Verzögerung für das Löschen von Benutzern", "user_delete_delay_settings_description": "Gibt die Anzahl der Tage bis zur endgültigen Löschung eines Kontos und seiner Dateien an. Der Benutzerlöschauftrag wird täglich um Mitternacht ausgeführt, um zu überprüfen, ob Nutzer zur Löschung bereit sind. Änderungen an dieser Einstellung werden erst bei der nächsten Ausführung berücksichtigt.", @@ -1141,6 +1147,7 @@ "search_options": "Suchoptionen", "search_people": "Suche nach Personen", "search_places": "Suche nach Orten", + "search_settings": "Suche nach Einstellungen", "search_state": "Suche nach Bundesland / Provinz...", "search_tags": "Sache nach Tags...", "search_timezone": "Suche nach Zeitzone...", diff --git a/web/src/lib/i18n/es.json b/web/src/lib/i18n/es.json index 013631919286a..0c77b9cfe1246 100644 --- a/web/src/lib/i18n/es.json +++ b/web/src/lib/i18n/es.json @@ -41,6 +41,7 @@ "confirm_email_below": "Para confirmar, escribe \"{email}\" debajo", "confirm_reprocess_all_faces": "¿Estás seguro de que quieres volver a procesar todas las caras? Esto también eliminará las personas a las que le hayas asignado nombre.", "confirm_user_password_reset": "¿Estás seguro de que quieres resetear la contraseña de {user}?", + "create_job": "Crear trabajo", "crontab_guru": "Crontab Guru", "disable_login": "Deshabilitar inicio de sesión", "disabled": "Deshabilitado", @@ -70,6 +71,7 @@ "image_thumbnail_resolution": "Resolución de las miniaturas", "image_thumbnail_resolution_description": "Se utiliza para ver grupos de fotos (cronología, vista de álbum, etc.). Las resoluciones más altas pueden conservar más detalles, pero tardan más en codificarse, tienen archivos de mayor tamaño y pueden reducir la reactividad de la aplicación.", "job_concurrency": "{job}: Procesos simultáneos", + "job_created": "Trabajo creado", "job_not_concurrency_safe": "Esta tarea no es segura para la simultaneidad.", "job_settings": "Configuración tareas", "job_settings_description": "Administrar tareas simultáneas", @@ -198,6 +200,7 @@ "password_settings": "Contraseña de Acceso", "password_settings_description": "Administrar la configuración de inicio de sesión con contraseña", "paths_validated_successfully": "Todas las carpetas se han validado satisfactoriamente", + "person_cleanup_job": "Limpieza de personas", "quota_size_gib": "Tamaño de Quota (GiB)", "refreshing_all_libraries": "Actualizar todas las bibliotecas", "registration": "Registrar administrador", @@ -211,6 +214,7 @@ "reset_settings_to_recent_saved": "Restablecer la configuración a la configuración guardada recientemente", "scanning_library_for_changed_files": "Escanear archivos modificados en biblioteca", "scanning_library_for_new_files": "Escanear nuevos archivos en biblioteca", + "search_jobs": "Buscar trabajo...", "send_welcome_email": "Enviar correo de bienvenida", "server_external_domain_settings": "Dominio externo", "server_external_domain_settings_description": "Dominio para enlaces públicos compartidos, incluidos http(s)://", @@ -238,6 +242,7 @@ "storage_template_settings_description": "Administre la estructura de carpetas y el nombre de archivo del recurso cargado", "storage_template_user_label": "{label} es la etiqueta de almacenamiento del usuario", "system_settings": "Ajustes del Sistema", + "tag_cleanup_job": "Limpieza de etiquetas", "theme_custom_css_settings": "CSS Personalizado", "theme_custom_css_settings_description": "Las Hojas de Estilo (CSS) permiten personalizar el diseño de Immich.", "theme_settings": "Ajustes Tema", @@ -312,6 +317,7 @@ "trash_settings_description": "Administrar la configuración de la papelera", "untracked_files": "Archivos sin seguimiento", "untracked_files_description": "La aplicación no rastrea estos archivos. Puede ser el resultado de movimientos fallidos, cargas interrumpidas o sin procesar debido a un error", + "user_cleanup_job": "Limpieza de usuarios", "user_delete_delay": "La cuenta {user} y los archivos se programarán para su eliminación permanente en {delay, plural, one {# día} other {# días}}.", "user_delete_delay_settings": "Eliminar retardo", "user_delete_delay_settings_description": "Número de días después de la eliminación para eliminar permanentemente la cuenta y los activos de un usuario. El trabajo de eliminación de usuarios se ejecuta a medianoche para comprobar si hay usuarios que estén listos para su eliminación. Los cambios a esta configuración se evaluarán en la próxima ejecución.", @@ -1141,6 +1147,7 @@ "search_options": "Opciones de búsqueda", "search_people": "Buscar personas", "search_places": "Buscar lugar", + "search_settings": "Ajustes de la búsqueda", "search_state": "Buscar región/estado...", "search_tags": "Buscando etiquetas...", "search_timezone": "Buscar zona horaria...", diff --git a/web/src/lib/i18n/et.json b/web/src/lib/i18n/et.json index 49b60cd0529de..58a9ce024cbd5 100644 --- a/web/src/lib/i18n/et.json +++ b/web/src/lib/i18n/et.json @@ -41,20 +41,22 @@ "confirm_email_below": "Kinnitamiseks sisesta allpool \"{email}\"", "confirm_reprocess_all_faces": "Kas oled kindel, et soovid kõik näod uuesti töödelda? See eemaldab kõik nimega isikud.", "confirm_user_password_reset": "Kas oled kindel, et soovid kasutaja {user} parooli lähtestada?", + "create_job": "Lisa tööde", "disable_login": "Keela sisselogimine", - "duplicate_detection_job_description": "Rakenda üksustele masinõpet, et tuvastada sarnaseid pilte. Kasutab nutiotsingut", + "duplicate_detection_job_description": "Rakenda üksustele masinõpet, et leida sarnaseid pilte. Kasutab nutiotsingut", "exclusion_pattern_description": "Välistamismustrid võimaldavad ignoreerida faile ja kaustu kogu skaneerimisel. See on kasulik, kui sul on kaustu, mis sisaldavad faile, mida sa ei soovi importida, nagu RAW failid.", "external_library_created_at": "Väline kogu (lisatud {date})", "external_library_management": "Väliste kogude haldus", - "face_detection": "Näotuvastus", - "face_detection_description": "Otsi üksustest nägusid masinõppe abil. Videote puhul kasutatakse ainult pisipilti. \"Kõik\" töötleb kõik üksused uuesti. \"Puuduvad\" võtab ette üksused, mida pole veel töödeldud. Leitud näod suunatakse näotuvastusse, et grupeerida nad olemasolevateks või uuteks isikuteks.", - "facial_recognition_job_description": "Grupeeri leitud näod inimesteks. See samm käivitub siis, kui näotuvastus on lõppenud. \"Kõik\" grupeerib kõik näod uuesti. \"Puuduvad\" võtab ette näod, mida pole isikuga seostatud.", + "face_detection": "Näoavastus", + "face_detection_description": "Avasta üksustest nägusid masinõppe abil. Videote puhul kasutatakse ainult pisipilti. \"Kõik\" töötleb kõik üksused uuesti. \"Puuduvad\" võtab ette üksused, mida pole veel töödeldud. Avastatud näod suunatakse näotuvastusse, et grupeerida nad olemasolevateks või uuteks isikuteks.", + "facial_recognition_job_description": "Grupeeri avastatud näod inimesteks. See samm käivitub siis, kui näoavastus on lõppenud. \"Kõik\" grupeerib kõik näod uuesti. \"Puuduvad\" võtab ette näod, mida pole isikuga seostatud.", "failed_job_command": "Käsk {command} ebaõnnestus töötes: {job}", "force_delete_user_warning": "HOIATUS: See kustutab koheselt kasutaja ja kõik üksused. Seda ei saa tagasi võtta ja faile ei saa taastada.", "forcing_refresh_library_files": "Kogu kõigi failide sundvärskendamine", "image_format_description": "WebP failid on väiksemad kui JPEG, aga kodeerimine on aeglasem.", "image_prefer_embedded_preview": "Eelista manustatud eelvaadet", "image_prefer_embedded_preview_setting_description": "Kasuta pilditöötluse sisendina võimalusel RAW fotodesse manustatud eelvaateid. See võib mõnede piltide puhul anda tulemuseks täpsemad värvid, aga eelvaate kvaliteet sõltub konkreetsest kaamerast ning pildis võib olla rohkem tihendusmüra.", + "image_prefer_wide_gamut": "Eelista laia värvigammat", "image_prefer_wide_gamut_setting_description": "Kasuta pisipiltide jaoks Display P3. See säilitab paremini laia värviruumiga piltide erksuse, aga vanematel seadmetel ja vanemate brauseritega võivad pildid teistsugused välja näha. sRGB pildid säilitatakse värvinihete vältimiseks.", "image_preview_format": "Eelvaate formaat", "image_preview_resolution": "Eelvaate resolutsioon", @@ -67,9 +69,13 @@ "image_thumbnail_resolution": "Pisipildi resolutsioon", "image_thumbnail_resolution_description": "Kasutusel fotode mitmekaupa vaatamisel (ajajoon, albumi vaade, jne). Kõrgem resolutsioon säilitab rohkem detaile, aga kodeerimine võtab rohkem aega, tekitab suurema faili ning võib mõjutada rakenduse töökiirust.", "job_concurrency": "{job} samaaegsus", + "job_created": "Tööde lisatud", + "job_not_concurrency_safe": "Seda töödet pole ohutu samaaegselt käivitada.", "job_settings": "Tööte seaded", "job_settings_description": "Halda töödete samaaegsust", "job_status": "Tööte seisund", + "jobs_delayed": "{jobCount, plural, other {# edasi lükatud}}", + "jobs_failed": "{jobCount, plural, other {# ebaõnnestus}}", "library_created": "Lisatud kogu: {library}", "library_cron_expression": "Cron avaldis", "library_cron_expression_description": "Sea skaneerimise intervall cron formaadis. Rohkema info jaoks vaata nt. Crontab Guru", @@ -81,6 +87,7 @@ "library_scanning_enable_description": "Luba kogu perioodiline skaneerimine", "library_settings": "Väline kogu", "library_settings_description": "Halda välise kogu seadeid", + "library_tasks_description": "Soorita kogu toiminguid", "library_watching_enable_description": "Jälgi välises kogus failide muudatusi", "library_watching_settings": "Kogu jälgimine (EKSPERIMENTAALNE)", "library_watching_settings_description": "Jälgi automaatselt muutunud faile", @@ -89,23 +96,26 @@ "logging_settings": "Logimine", "machine_learning_clip_model": "CLIP mudel", "machine_learning_clip_model_description": "CLIP mudeli nimi, mis on loetletud siin. Pane tähele, et mudeli muutmisel pead kõigi piltide peal nutiotsingu tööte uuesti käivitama.", - "machine_learning_duplicate_detection": "Duplikaatide tuvastus", - "machine_learning_duplicate_detection_enabled": "Luba duplikaatide tuvastus", + "machine_learning_duplicate_detection": "Duplikaatide leidmine", + "machine_learning_duplicate_detection_enabled": "Luba duplikaatide leidmine", "machine_learning_duplicate_detection_enabled_description": "Kui keelatud, dedubleeritakse siiski täpselt identsed üksused.", "machine_learning_duplicate_detection_setting_description": "Kasuta CLIP-manuseid, et leida tõenäoliseid duplikaate", "machine_learning_enabled": "Luba masinõpe", "machine_learning_enabled_description": "Kui keelatud, lülitatakse kõik masinõppe funktsioonid välja, sõltumata allolevatest seadetest.", "machine_learning_facial_recognition": "Näotuvastus", - "machine_learning_facial_recognition_description": "Otsi, tuvasta ja grupeeri piltidel näod", + "machine_learning_facial_recognition_description": "Avasta, tuvasta ja grupeeri piltidel näod", "machine_learning_facial_recognition_model": "Näotuvastuse mudel", - "machine_learning_facial_recognition_model_description": "Mudelid on järjestatud suuruse järgi kahanevalt. Suuremad mudelid on aeglasemad ja kasutavad rohkem mälu, kuid annavad parema tulemuse. Mudeli muutmisel tuleb näotuvastuse tööde kõigi piltide peal uuesti käivitada.", + "machine_learning_facial_recognition_model_description": "Mudelid on järjestatud suuruse järgi kahanevalt. Suuremad mudelid on aeglasemad ja kasutavad rohkem mälu, kuid annavad parema tulemuse. Mudeli muutmisel tuleb näoavastuse tööde kõigi piltide peal uuesti käivitada.", "machine_learning_facial_recognition_setting": "Luba näotuvastus", - "machine_learning_max_detection_distance": "Maksimaalne tuvastuskaugus", - "machine_learning_max_detection_distance_description": "Maksimaalne kaugus kahe pildi vahel, mille puhul loetakse nad duplikaatideks, vahemikus 0.001-0.1. Kõrgemad väärtused tuvastavad rohkem duplikaate, aga võivad anda valepositiivseid.", + "machine_learning_facial_recognition_setting_description": "Kui keelatud, siis ei kodeerita pilte näotuvastuse jaoks ning isikute sektsioon Avasta lehel jääb tühjaks.", + "machine_learning_max_detection_distance": "Maksimaalne avastuskaugus", + "machine_learning_max_detection_distance_description": "Maksimaalne kaugus kahe pildi vahel, mille puhul loetakse nad duplikaatideks, vahemikus 0.001-0.1. Kõrgemad väärtused leiavad rohkem duplikaate, aga võib esineda valepositiivseid.", + "machine_learning_max_recognition_distance": "Maksimaalne tuvastuskaugus", "machine_learning_max_recognition_distance_description": "Maksimaalne kaugus kahe näo vahel, mida tuleks lugeda samaks isikuks, vahemikus 0-2. Selle vähendamine aitab vältida erinevate inimeste samaks isikuks märkimist ja tõstmine aitab vältida sama inimese kaheks erinevaks isikuks märkimist. Pane tähele, et kaht isikut ühendada on lihtsam kui üht isikut kaheks eraldada, seega võimalusel kasuta madalamat lävendit.", - "machine_learning_min_detection_score_description": "Minimaalne usaldusskoor näo tuvastamiseks, vahemikus 0-1. Madalamad väärtused leiavad rohkem nägusid, kuid võib esineda valepositiivseid.", - "machine_learning_min_recognized_faces": "Minimaalne leitud nägude arv", - "machine_learning_min_recognized_faces_description": "Minimaalne leitud nägude arv, mida saab isikuks grupeerida. Selle suurendamine teeb näotuvastuse täpsemaks, kuid suureneb tõenäosus, et nägu ei seostata ühegi isikuga.", + "machine_learning_min_detection_score": "Minimaalne avastusskoor", + "machine_learning_min_detection_score_description": "Minimaalne usaldusskoor näo avastamiseks, vahemikus 0-1. Madalamad väärtused leiavad rohkem nägusid, kuid võib esineda valepositiivseid.", + "machine_learning_min_recognized_faces": "Minimaalne tuvastatud nägude arv", + "machine_learning_min_recognized_faces_description": "Minimaalne tuvastatud nägude arv, mida saab isikuks grupeerida. Selle suurendamine teeb näotuvastuse täpsemaks, kuid suureneb tõenäosus, et nägu ei seostata ühegi isikuga.", "machine_learning_settings": "Masinõppe seaded", "machine_learning_settings_description": "Halda masinõppe funktsioone ja seadeid", "machine_learning_smart_search": "Nutiotsing", @@ -113,17 +123,30 @@ "machine_learning_smart_search_enabled": "Luba nutiotsing", "machine_learning_smart_search_enabled_description": "Kui keelatud, siis ei kodeerita pilte nutiotsingu jaoks.", "machine_learning_url_description": "Masinõppe serveri URL", + "manage_concurrency": "Halda samaaegsust", "manage_log_settings": "Halda logi seadeid", "map_dark_style": "Tume stiil", + "map_enable_description": "Luba kaardi funktsioonid", "map_gps_settings": "Kaardi ja GPS-i seaded", + "map_gps_settings_description": "Halda kaardi ja GPS-i (pöördgeokodeerimise) seadeid", + "map_implications": "Kaardifunktsioon kasutab välist kaarditeenust (tiles.immich.cloud)", "map_light_style": "Hele stiil", + "map_manage_reverse_geocoding_settings": "Halda pöördgeokodeerimise seadeid", + "map_reverse_geocoding": "Pöördgeokodeerimine", + "map_reverse_geocoding_enable_description": "Luba pöördgeokodeerimine", + "map_reverse_geocoding_settings": "Pöördgeokodeerimise seaded", "map_settings": "Kaart", "map_settings_description": "Halda kaardi seadeid", + "map_style_description": "Kaarditeema style.json URL", "metadata_extraction_job": "Metaandmete eraldamine", "metadata_extraction_job_description": "Eralda igast üksusest metaandmed, nagu GPS-koordinaadid, näod ja resolutsioon", + "metadata_faces_import_setting": "Luba nägude import", + "metadata_settings": "Metaandmete seaded", + "metadata_settings_description": "Halda metaandmete seadeid", "migration_job": "Migratsioon", "migration_job_description": "Migreeri üksuste ja nägude pisipildid uusimale kaustastruktuurile", "note_cannot_be_changed_later": "MÄRKUS: Seda ei saa hiljem muuta!", + "note_unlimited_quota": "Märkus: Piiramatu kvoodi jaoks sisesta 0", "notification_email_from_address": "Saatja aadress", "notification_email_from_address_description": "Saatja e-posti aadress, näiteks: \"Immich Photo Server \"", "notification_email_host_description": "E-posti serveri host (nt. smtp.immich.app)", @@ -140,17 +163,27 @@ "notification_enable_email_notifications": "Luba e-posti teel teavitused", "notification_settings": "Teavituse seaded", "notification_settings_description": "Halda teavituste seadeid, sh. e-posti teel", + "oauth_auto_launch": "Automaatne käivitamine", + "oauth_auto_launch_description": "Alusta OAuth autentimist automaatselt sisselogimise lehele jõudmisel", + "oauth_auto_register": "Automaatne registreerimine", + "oauth_auto_register_description": "Registreeri uued kasutajad automaatselt OAuth abil sisselogimisel", "oauth_button_text": "Nupu tekst", "oauth_client_id": "Kliendi ID", "oauth_client_secret": "Kliendi saladus", "oauth_enable_description": "Sisene OAuth abil", "oauth_issuer_url": "Väljastaja URL", + "oauth_mobile_redirect_uri": "Mobiilne ümbersuunamise URI", + "oauth_profile_signing_algorithm": "Profiili allkirjastamise algoritm", + "oauth_profile_signing_algorithm_description": "Algoritm, mida kasutatakse kasutajaprofiili allkirjastamiseks.", + "oauth_scope": "Skoop", "oauth_settings": "OAuth", "oauth_settings_description": "Halda OAuth sisselogimise seadeid", + "oauth_signing_algorithm": "Allkirjastamise algoritm", "password_enable_description": "Logi sisse e-posti aadressi ja parooliga", "password_settings": "Parooliga sisselogimine", "password_settings_description": "Halda parooliga sisselogimise seadeid", "paths_validated_successfully": "Kõik teed edukalt valideeritud", + "person_cleanup_job": "Isikute korrastamine", "quota_size_gib": "Kvoot (GiB)", "refreshing_all_libraries": "Kõikide kogude värskendamine", "registration_description": "Kuna sa oled süsteemis esimene kasutaja, määratakse sind administraatoriks, ning sa saad lisada täiendavaid kasutajaid.", @@ -171,6 +204,10 @@ "storage_template_migration_info": "Malli muudatused rakenduvad ainult uutele üksustele. Et rakendada malli tagasiulatuvalt olemasolevatele üksustele, käivita {job}.", "storage_template_settings_description": "Halda üleslaaditud üksuse kaustastruktuuri ja failinime", "system_settings": "Süsteemi seaded", + "tag_cleanup_job": "Siltide korrastamine", + "theme_custom_css_settings": "Kohandatud CSS", + "theme_custom_css_settings_description": "Cascading Style Sheets lubab Immich'i kujunduse kohandamist.", + "theme_settings": "Teema seaded", "theme_settings_description": "Halda Immich'i veebiliidese kohandamist", "thumbnail_generation_job": "Genereeri pisipildid", "thumbnail_generation_job_description": "Genereeri iga üksuse kohta suur, väike ja udustatud pisipilt ning iga isiku kohta pisipilt", @@ -229,13 +266,18 @@ "transcoding_video_codec_description": "VP9 on võimekas ja veebiga ühilduv, aga transkodeerimine võtab kauem aega. HEVC on sarnase jõudluse, aga mitte nii hea veebiga ühilduvusega. H.264 on laialt ühilduv ja transkodeerimine on kiire, aga tulemuseks on suuremad failid. AV1 on kõige võimekam koodek, aga pole vanematel seadmetel toetatud.", "trash_number_of_days": "Päevade arv", "trash_number_of_days_description": "Päevade arv, kui kaua hoida üksusi prügikastis enne nende lõplikku kustutamist", + "user_cleanup_job": "Kasutajate korrastamine", "user_delete_delay": "Kasutaja {user} konto ja üksuste lõplik kustutamine on planeeritud {delay, plural, one {# päeva} other {# päeva}} pärast.", "user_delete_delay_settings_description": "Päevade arv, pärast mida kustutatakse eemaldatud kasutaja konto ja üksused jäädavalt. Kasutajate kustutamise tööde käivitub keskööl, et otsida kustutamiseks valmis kasutajaid. Selle seadistuse muudatused rakenduvad järgmisel käivitumisel.", "user_delete_immediately": "Kasutaja {user} konto ja üksused suunatakse koheselt jäädavale kustutamisele.", "user_delete_immediately_checkbox": "Suuna kasutaja ja üksused jäädavale kustutamisele", + "user_management": "Kasutajate haldus", "user_password_has_been_reset": "Kasutaja parool on lähtestatud:", "user_password_reset_description": "Sisesta kasutajale ajutine parool ja teavita teda, et järgmisel sisselogimisel tuleb parool ära muuta.", "user_restore_description": "Kasutaja {user} konto taastatakse.", + "user_restore_scheduled_removal": "Taasta kasutaja - eemaldamine planeeritud {date, date, long}", + "user_settings": "Kasutajate seaded", + "user_settings_description": "Halda kasutajate seadeid", "user_successfully_removed": "Kasutaja {email} on eemaldatud.", "version_check_enabled_description": "Luba versioonikontroll", "version_check_implications": "Versioonikontroll vajab perioodilist ühendumist github.com-iga", @@ -270,11 +312,17 @@ "all_albums": "Kõik albumid", "all_people": "Kõik isikud", "all_videos": "Kõik videod", + "anti_clockwise": "Vastupäeva", + "api_key": "API võti", "api_key_description": "Seda väärtust kuvatakse ainult üks kord. Kopeeri see enne akna sulgemist.", + "api_key_empty": "Su API võtme nimi ei tohiks olla tühi", + "api_keys": "API võtmed", + "app_settings": "Rakenduse seaded", "archive": "Arhiiv", "archive_or_unarchive_photo": "Arhiveeri või taasta foto", "archive_size": "Arhiivi suurus", "archive_size_description": "Seadista arhiivi suurus allalaadimiseks (GiB)", + "are_these_the_same_person": "Kas need on sama isik?", "are_you_sure_to_do_this": "Kas oled kindel, et soovid seda teha?", "asset_added_to_album": "Lisatud albumisse", "asset_adding_to_album": "Albumisse lisamine...", @@ -307,13 +355,19 @@ "bulk_delete_duplicates_confirmation": "Kas oled kindel, et soovid {count, plural, one {# dubleeritud üksuse} other {# dubleeritud üksust}} masskustutada? Sellega jäetakse alles iga grupi suurim üksus ning duplikaadid kustutatakse jäädavalt. Seda tegevust ei saa tagasi võtta!", "bulk_keep_duplicates_confirmation": "Kas oled kindel, et soovid {count, plural, one {# dubleeritud üksuse} other {# dubleeritud üksust}} alles jätta? Sellega märgitakse kõik duplikaadigrupid lahendatuks ilma midagi kustutamata.", "bulk_trash_duplicates_confirmation": "Kas oled kindel, et soovid {count, plural, one {# dubleeritud üksuse} other {# dubleeritud üksust}} masskustutada? Sellega jäetakse alles iga grupi suurim üksus ning duplikaadid liigutatakse prügikasti.", + "buy": "Osta Immich", "camera": "Kaamera", "camera_brand": "Kaamera mark", "camera_model": "Kaamera mudel", "cancel": "Katkesta", + "cancel_search": "Katkesta otsing", "cannot_merge_people": "Ei saa isikuid ühendada", "cannot_undo_this_action": "Sa ei saa seda tagasi võtta!", "cannot_update_the_description": "Kirjelduse muutmine ebaõnnestus", + "change_date": "Muuda kuupäeva", + "change_expiration_time": "Muuda aegumisaega", + "change_location": "Muuda asukohta", + "change_name": "Muuda nime", "change_password": "Parooli muutmine", "change_password_description": "See on su esimene kord süsteemi siseneda, või on tehtud taotlus parooli muutmiseks. Palun sisesta allpool uus parool.", "change_your_password": "Muuda oma parooli", @@ -322,6 +376,10 @@ "check_logs": "Vaata logisid", "choose_matching_people_to_merge": "Vali kattuvad isikud, mida ühendada", "city": "Linn", + "clear": "Tühjenda", + "clear_all": "Tühjenda kõik", + "clear_all_recent_searches": "Tühjenda hiljutised otsingud", + "clear_value": "Tühjenda väärtus", "clockwise": "Päripäeva", "close": "Sulge", "color": "Värv", @@ -335,6 +393,7 @@ "confirm_delete_shared_link": "Kas oled kindel, et soovid selle jagatud lingi kustutada?", "confirm_password": "Kinnita parool", "context": "Kontekst", + "continue": "Jätka", "copied_image_to_clipboard": "Pilt kopeeritud lõikelauale.", "copied_to_clipboard": "Kopeeritud lõikelauale!", "copy_error": "Kopeeri viga", @@ -383,7 +442,9 @@ "delete_user": "Kustuta kasutaja", "deleted_shared_link": "Jagatud link kustutatud", "description": "Kirjeldus", + "details": "Üksikasjad", "direction": "Suund", + "disallow_edits": "Keela muutmine", "discover": "Avasta", "display_options": "Kuva valikud", "display_original_photos_setting_description": "Eelista üksuse vaatamisel pisipildile algset fotot, kui see on veebiga ühilduv. See võib mõjutada fotode kuvamise kiirust.", @@ -454,6 +515,7 @@ "import_path_already_exists": "See imporditee on juba olemas.", "incorrect_email_or_password": "Vale e-posti aadress või parool", "profile_picture_transparent_pixels": "Profiilipildis ei tohi olla läbipaistvaid piksleid. Palun suumi sisse ja/või liiguta pilti.", + "quota_higher_than_disk_size": "Määratud kvoot on suurem kui kettamaht", "unable_to_add_album_users": "Kasutajate lisamine albumisse ebaõnnestus", "unable_to_add_assets_to_shared_link": "Üksuste jagatud lingile lisamine ebaõnnestus", "unable_to_add_comment": "Kommentaari lisamine ebaõnnestus", @@ -463,6 +525,7 @@ "unable_to_add_remove_archive": "{archived, select, true {Üksuse arhiivist taastamine} other {Üksuse arhiveerimine}} ebaõnnestus", "unable_to_add_remove_favorites": "Üksuse {favorite, select, true {lemmikuks lisamine} other {lemmikutest eemaldamine}} ebaõnnestus", "unable_to_archive_unarchive": "{archived, select, true {Arhiveerimine} other {Arhiivist taastamine}} ebaõnnestus", + "unable_to_change_album_user_role": "Kasutaja rolli albumis muutmine ebaõnnestus", "unable_to_change_date": "Kuupäeva muutmine ebaõnnestus", "unable_to_change_favorite": "Üksuse lemmiku staatuse muutmine ebaõnnestus", "unable_to_change_location": "Asukoha muutmine ebaõnnestus", @@ -536,23 +599,32 @@ "expired": "Aegunud", "expires_date": "Aegub {date}", "explore": "Avasta", + "export": "Ekspordi", "export_as_json": "Ekspordi JSON-formaati", "extension": "Laiend", + "external": "Väline", + "external_libraries": "Välised kogud", "face_unassigned": "Seostamata", "favorite": "Lemmik", "favorites": "Lemmikud", "feature_photo_updated": "Esiletõstetud foto muudetud", + "features": "Funktsioonid", + "features_setting_description": "Halda rakenduse funktsioone", "file_name": "Failinimi", "file_name_or_extension": "Failinimi või -laiend", "filename": "Failinimi", "filetype": "Failitüüp", "filter_people": "Filtreeri isikuid", + "find_them_fast": "Leia teda kiiresti nime järgi otsides", "folders": "Kaustad", "folders_feature_description": "Kaustavaate abil failisüsteemis olevate fotode ja videote sirvimine", "force_re-scan_library_files": "Sundskaneeri kogu kõik failid uuesti", "forward": "Edasi", "general": "Üldine", + "get_help": "Küsi abi", + "getting_started": "Alustamine", "go_back": "Tagasi", + "go_to_search": "Otsingusse", "group_albums_by": "Grupeeri albumid...", "group_no": "Ära grupeeri", "group_owner": "Grupeeri omaniku kaupa", @@ -575,6 +647,7 @@ "immich_logo": "Immich'i logo", "immich_web_interface": "Immich'i veebiliides", "import_from_json": "Impordi JSON-formaadist", + "in_albums": "{count, plural, one {# albumis} other {# albumis}}", "in_archive": "Arhiivis", "info": "Info", "interval": { @@ -595,9 +668,13 @@ "latest_version": "Uusim versioon", "latitude": "Laiuskraad", "leave": "Lahku", + "let_others_respond": "Luba teistel vastata", "library": "Kogu", "library_options": "Kogu seaded", + "light": "Hele", + "link_options": "Lingi valikud", "list": "Loend", + "loading": "Laadimine", "loading_search_results_failed": "Otsitulemuste laadimine ebaõnnestus", "log_out": "Logi välja", "log_out_all_devices": "Logi kõigist seadmetest välja", @@ -607,15 +684,19 @@ "logout_all_device_confirmation": "Kas oled kindel, et soovid kõigist seadmetest välja logida?", "logout_this_device_confirmation": "Kas oled kindel, et soovid sellest seadmest välja logida?", "longitude": "Pikkuskraad", + "look": "Välimus", "make": "Mark", "manage_shared_links": "Halda jagatud linke", "manage_sharing_with_partners": "Halda partneritega jagamist", + "manage_the_app_settings": "Halda rakenduse seadeid", "manage_your_account": "Halda oma kontot", "manage_your_api_keys": "Halda oma API võtmeid", "manage_your_devices": "Halda oma autenditud seadmeid", "map": "Kaart", "map_settings": "Kaardi seaded", + "media_type": "Meedia tüüp", "memories": "Mälestused", + "memories_setting_description": "Halda, mida sa oma mälestustes näed", "memory": "Mälestus", "menu": "Menüü", "merge": "Ühenda", @@ -642,17 +723,24 @@ "next_memory": "Järgmine mälestus", "no": "Ei", "no_albums_message": "Lisa album fotode ja videote organiseerimiseks", + "no_albums_with_name_yet": "Paistab, et sul pole veel ühtegi selle nimega albumit.", + "no_albums_yet": "Paistab, et sul pole veel ühtegi albumit.", "no_archived_assets_message": "Arhiveeri fotod ja videod, et neid Fotod vaatest peita", "no_assets_message": "KLIKI ESIMESE FOTO ÜLESLAADIMISEKS", "no_duplicates_found": "Ühtegi duplikaati ei leitud.", "no_exif_info_available": "Exif info pole saadaval", + "no_explore_results_message": "Oma kogu avastamiseks laadi üles rohkem fotosid.", "no_favorites_message": "Lisa lemmikud, et oma parimaid fotosid ja videosid kiiresti leida", "no_libraries_message": "Lisa väline kogu oma fotode ja videote vaatamiseks", + "no_results": "Vasteid pole", + "no_results_description": "Proovi sünonüümi või üldisemat märksõna", "no_shared_albums_message": "Lisa album, et fotosid ja videosid teistega jagada", + "notes": "Märkused", "notification_toggle_setting_description": "Luba e-posti teel teavitused", "notifications": "Teavitused", "notifications_setting_description": "Halda teavitusi", "oauth": "OAuth", + "ok": "Ok", "oldest_first": "Vanemad eespool", "onboarding_theme_description": "Vali oma serverile värviteema. Saad seda hiljem seadetes muuta.", "onboarding_welcome_user": "Tere tulemast, {user}", @@ -660,11 +748,13 @@ "only_refreshes_modified_files": "Värskendab ainult muudetud failid", "open_in_map_view": "Ava kaardi vaates", "open_in_openstreetmap": "Ava OpenStreetMap", + "open_the_search_filters": "Ava otsingufiltrid", "options": "Valikud", "or": "või", "organize_your_library": "Korrasta oma kogu", "original": "originaal", "other_devices": "Muud seadmed", + "other_variables": "Muud muutujad", "owned": "Minu omad", "owner": "Omanik", "partner": "Partner", @@ -699,6 +789,8 @@ "permanently_deleted_asset": "Üksus jäädavalt kustutatud", "permanently_deleted_assets_count": "{count, plural, one {# üksus} other {# üksust}} jäädavalt kustutatud", "person": "Isik", + "person_hidden": "{name}{hidden, select, true { (peidetud)} other {}}", + "photo_shared_all_users": "Paistab, et oled oma fotosid kõigi kasutajatega jaganud, või pole ühtegi kasutajat, kellega jagada.", "photos": "Fotod", "photos_and_videos": "Fotod ja videod", "photos_count": "{count, plural, one {{count, number} foto} other {{count, number} fotot}}", @@ -739,6 +831,8 @@ "purchase_panel_info_1": "Immich'i arendamine nõuab palju aega ja vaeva ning meie täiskohaga insenerid töötavad selle nimel, et teha see nii heaks kui vähegi võimalik. Meie missiooniks on muuta avatud lähtekoodiga tarkvara ja eetilised äritavad arendajatele jätkusuutlikuks sissetulekuallikaks ning luua privaatsust austav ökosüsteem, mis pakub tõelisi alternatiive ekspluatatiivsetele pilveteenustele.", "purchase_panel_info_2": "Kuna oleme otsustanud maksumüüre mitte lisada, ei anna see ost sulle Immich'is lisavõimalusi. Me loodame Immich'i jätkuvaks arenduseks sinusuguste kasutajate toetusele.", "purchase_panel_title": "Toeta projekti", + "purchase_per_server": "Serveri kohta", + "purchase_per_user": "Kasutaja kohta", "purchase_remove_product_key": "Eemalda tootevõti", "purchase_remove_product_key_prompt": "Kas oled kindel, et soovid tootevõtme eemaldada?", "purchase_remove_server_product_key": "Eemalda serveri tootevõti", @@ -747,10 +841,12 @@ "purchase_server_description_2": "Toetaja staatus", "purchase_server_title": "Server", "purchase_settings_server_activated": "Serveri tootevõtit haldab administraator", + "reaction_options": "Reaktsiooni valikud", "read_changelog": "Vaata muudatuste ülevaadet", "reassigned_assets_to_existing_person": "{count, plural, one {# üksus} other {# üksust}} seostatud {name, select, null {olemasoleva isikuga} other {isikuga {name}}}", "reassigned_assets_to_new_person": "{count, plural, one {# üksus} other {# üksust}} seostatud uue isikuga", "reassing_hint": "Seosta valitud üksused olemasoleva isikuga", + "recent_searches": "Hiljutised otsingud", "refresh": "Värskenda", "refresh_encoded_videos": "Värskenda kodeeritud videod", "refresh_metadata": "Värskenda metaandmed", @@ -766,11 +862,14 @@ "remove_assets_title": "Eemalda üksused?", "remove_from_album": "Eemalda albumist", "remove_from_favorites": "Eemalda lemmikutest", + "remove_from_shared_link": "Eemalda jagatud lingist", "remove_user": "Eemalda kasutaja", "removed_api_key": "API võti eemaldatud: {name}", "removed_from_archive": "Arhiivist eemaldatud", "removed_from_favorites": "Lemmikutest eemaldatud", + "removed_from_favorites_count": "{count, plural, other {# eemaldatud}} lemmikutest", "removed_tagged_assets": "Silt eemaldatud {count, plural, one {# üksuselt} other {# üksuselt}}", + "rename": "Nimeta ümber", "require_password": "Nõua parooli", "require_user_to_change_password_on_first_login": "Nõua kasutajalt esmakordsel sisenemisel parooli muutmist", "reset": "Lähtesta", @@ -806,8 +905,10 @@ "search_for_existing_person": "Otsi olemasolevat isikut", "search_no_people": "Isikuid ei ole", "search_no_people_named": "Ei ole isikuid nimega \"{name}\"", + "search_options": "Otsingu valikud", "search_people": "Otsi inimesi", "search_places": "Otsi kohti", + "search_settings": "Otsingu seaded", "search_state": "Otsi osariiki...", "search_tags": "Otsi silte...", "search_timezone": "Otsi ajavööndit...", @@ -862,13 +963,18 @@ "show_metadata": "Kuva metaandmed", "show_or_hide_info": "Kuva või peida info", "show_password": "Kuva parooli", + "show_progress_bar": "Kuva edenemisriba", + "show_search_options": "Kuva otsingu valikud", "show_supporter_badge": "Toetaja märk", "show_supporter_badge_description": "Kuva toetaja märki", "sidebar": "Külgmenüü", + "sidebar_display_description": "Kuva külgmenüüs linki vaatele", "sign_out": "Logi välja", "sign_up": "Registreeru", "size": "Suurus", "skip_to_content": "Sisu juurde", + "skip_to_folders": "Kaustade juurde", + "skip_to_tags": "Siltide juurde", "slideshow": "Slaidiesitlus", "slideshow_settings": "Slaidiesitluse seaded", "sort_albums_by": "Järjesta albumid...", @@ -902,6 +1008,7 @@ "theme": "Teema", "theme_selection": "Teema valik", "theme_selection_description": "Sea automaatselt hele või tume teema vastavalt veebilehitseja eelistustele", + "time_based_memories": "Ajapõhised mälestused", "timezone": "Ajavöönd", "to_archive": "Arhiivi", "to_change_password": "Muuda parool", @@ -917,6 +1024,8 @@ "unnamed_album_delete_confirmation": "Kas oled kindel, et soovid selle albumi kustutada?", "unsaved_change": "Salvestamata muudatus", "updated_password": "Parool muudetud", + "upload": "Laadi üles", + "upload_concurrency": "Üleslaadimise samaaegsus", "upload_errors": "Üleslaadimine lõpetatud {count, plural, one {# veaga} other {# veaga}}, uute üksuste nägemiseks värskenda lehte.", "upload_skipped_duplicates": "{count, plural, one {# dubleeritud üksus} other {# dubleeritud üksust}} vahele jäetud", "upload_status_duplicates": "Duplikaadid", @@ -927,6 +1036,7 @@ "user": "Kasutaja", "user_id": "Kasutaja ID", "user_liked": "Kasutajale {user} meeldis {type, select, photo {see foto} video {see video} asset {see üksus} other {see}}", + "user_purchase_settings": "Osta", "user_purchase_settings_description": "Halda oma ostu", "username": "Kasutajanimi", "users": "Kasutajad", @@ -935,6 +1045,7 @@ "variables": "Muutujad", "version": "Versioon", "version_announcement_closing": "Sinu sõber, Alex", + "version_announcement_message": "Hei sõber, saadaval on rakenduse uus versioon. Palun võta aega, et lugeda väljalasketeadet ning veendu, et su docker-compose.yml ja .env failid on ajakohased, et vältida konfiguratsiooniprobleeme, eriti kui kasutad WatchTower'it või muud mehhanismi, mis rakendust automaatselt uuendab.", "video": "Video", "video_hover_setting": "Esita hõljutamisel video eelvaade", "video_hover_setting_description": "Esita video eelvaade, kui hiirt selle kohal hõljutada. Isegi kui keelatud, saab taasesituse alustada taasesitusnupu kohal hõljutades.", diff --git a/web/src/lib/i18n/fi.json b/web/src/lib/i18n/fi.json index 15a3dc0a265cb..a6c07c18e99d8 100644 --- a/web/src/lib/i18n/fi.json +++ b/web/src/lib/i18n/fi.json @@ -25,7 +25,7 @@ "add_to_shared_album": "Lisää jaettuun albumiin", "added_to_archive": "Arkistoitu", "added_to_favorites": "Lisätty suosikkeihin", - "added_to_favorites_count": "{count} lisätty suosikkeihin", + "added_to_favorites_count": "{count, number} lisätty suosikkeihin", "admin": { "add_exclusion_pattern_description": "Lisää mallit, jonka mukaan jätetään tiedostoja pois. Jokerimerkit *, ** ja ? ovat tuettuna. Jättääksesi pois kaikki tiedostot mistä tahansa löytyvästä kansiosta \"Raw\" käytä \"**/Raw/**\". Jättääksesi pois kaikki \". tif\" päätteiset tiedot, käytä \"**/*.tif\". Jättääksesi pois tarkan tiedostopolun, käytä \"/path/to/ignore/**\".", "authentication_settings": "Autentikointiasetukset", @@ -41,6 +41,7 @@ "confirm_email_below": "Kirjota \"{email}\" vahvistaaksesi", "confirm_reprocess_all_faces": "Haluatko varmasti käsitellä uudelleen kaikki kasvot? Tämä poistaa myös nimetyt henkilöt.", "confirm_user_password_reset": "Haluatko varmasti nollata käyttäjän {user} salasanan?", + "create_job": "Luo tehtävä", "crontab_guru": "Crontab Guru", "disable_login": "Poista kirjautuminen käytöstä", "disabled": "Ei käytössä", @@ -70,12 +71,13 @@ "image_thumbnail_resolution": "Pikkukuvien resoluutio", "image_thumbnail_resolution_description": "Käytetään katsottaessa useita kuvia kerralla (aikajana, albuminäkymä, jne.) Korkeampi resoluutio antaa enemmän yksityiskohtia, mutta niiden luonti kestää kauemmin, tiedostokoot ovat isompia ja voivat heikentää sovelluksen responsiivisuutta.", "job_concurrency": "{job} yhtäaikaisuus", + "job_created": "Tehtävä luotu", "job_not_concurrency_safe": "Tätä tehtävää ei ole turvallista ajaa yhtäaikaisesti.", "job_settings": "Tehtävän asetukset", "job_settings_description": "Hallitse tehtävän samanaikaisuusasetuksia", "job_status": "Tehtävän tila", - "jobs_delayed": "{jobCount} tehtävää viivästetty", - "jobs_failed": "{jobCount} epäonnistui", + "jobs_delayed": "{jobCount, plural, other {# viivästynyttä}}", + "jobs_failed": "{jobCount, plural, other {# epäonnistunutta}}", "library_created": "Kirjasto {library} luotu", "library_cron_expression": "Cron-lauseke", "library_cron_expression_description": "Anna skannaustiheys cron-formaatissa. Saadaksesi lisätietoja katso esimerkiksi Crontab Guru", @@ -135,13 +137,13 @@ "map_reverse_geocoding": "Käänteinen Geokoodaus", "map_reverse_geocoding_enable_description": "Ota käyttöön osoitteiden poiminta karttakoordinaateista", "map_reverse_geocoding_settings": "Käänteisen Geokoodauksen asetukset", - "map_settings": "Kartta-asetukset", + "map_settings": "Kartta", "map_settings_description": "Hallitse kartan asetuksia", "map_style_description": "style.json -karttateeman URL", "metadata_extraction_job": "Kerää metadata", - "metadata_extraction_job_description": "Poimi metatiedot aineistoista, kuten GPS ja resoluutio", + "metadata_extraction_job_description": "Poimi metatiedot aineistoista, kuten GPS, kasvot ja resoluutio", "metadata_faces_import_setting": "Ota käyttöön kasvojen tuonti", - "metadata_faces_import_setting_description": "Tuo kasvot kuvan EXIF -tiedoista ja kylkiäistiedostoista", + "metadata_faces_import_setting_description": "Tuo kasvot kuvan EXIF- ja kylkiäistiedostoista", "metadata_settings": "Metatietoasetukset", "metadata_settings_description": "Hallitse metatietoja", "migration_job": "Migrointi", @@ -178,9 +180,9 @@ "oauth_issuer_url": "Toimitsijan URL", "oauth_mobile_redirect_uri": "Mobiilin uudellenohjaus-URI", "oauth_mobile_redirect_uri_override": "Ohita mobiilin uudelleenohjaus-URI", - "oauth_mobile_redirect_uri_override_description": "Ota käyttöön kun 'app.immich:/' -ohjausta ei tueta.", + "oauth_mobile_redirect_uri_override_description": "Ota käyttöön kun OAuth tarjoaja ei salli mobiili URI:a, kuten '{callback}'", "oauth_profile_signing_algorithm": "Profiilin allekirjoitusalgoritmi", - "oauth_profile_signing_algorithm_description": "Algoritmi, jota käytetään käyttäjäprofiilin allekirjoituksessa", + "oauth_profile_signing_algorithm_description": "Algoritmi, jota käytetään käyttäjäprofiilin allekirjoittamiseen.", "oauth_scope": "Skooppi (Scope)", "oauth_settings": "OAuth", "oauth_settings_description": "Hallitse OAuth kirjautumisen asetuksia", @@ -198,6 +200,7 @@ "password_settings": "Kirjaudu salasanalla", "password_settings_description": "Hallitse salasanakirjautumisen asetuksia", "paths_validated_successfully": "Kaikki polut validoitu", + "person_cleanup_job": "Henkilöpuhdistus", "quota_size_gib": "Kiintiön koko (Gt)", "refreshing_all_libraries": "Virkistetään kaikki kirjastot", "registration": "Pääkäyttäjän rekisteröinti", @@ -211,6 +214,7 @@ "reset_settings_to_recent_saved": "Palauta aiemmin tallennetut asetukset", "scanning_library_for_changed_files": "Etsitään kirjaston muuttuneita tiedostoja", "scanning_library_for_new_files": "Etsitään uusia tiedostoja", + "search_jobs": "Etsi tehtäviä...", "send_welcome_email": "Lähetä tervetuloviesti", "server_external_domain_settings": "Ulkoinen osoite", "server_external_domain_settings_description": "Osoite julkisille linkeille, http(s):// mukaan lukien", @@ -238,6 +242,7 @@ "storage_template_settings_description": "Hallitse palvelimelle ladatun aineiston kansiorakennetta ja tiedostonimiä", "storage_template_user_label": "{label} on käyttäjän Tallennustilan Tunniste", "system_settings": "Järjestelmäasetukset", + "tag_cleanup_job": "Merkintäpuhdistus", "theme_custom_css_settings": "Mukautettu CSS", "theme_custom_css_settings_description": "Kustomoi Immichin ulkoasua Cascading Style Sheets:llä.", "theme_settings": "Teeman asetukset", @@ -265,7 +270,7 @@ "transcoding_codecs_learn_more": "Oppiaksesi lisää tässä käytetystä terminologiasta, tutustu FFmpeg- dokumentaatioon H.264 koodaaja, HEVC koodaaja sekä VP9 koodaaja.", "transcoding_constant_quality_mode": "Tasaisen laadun tyyppi", "transcoding_constant_quality_mode_description": "ICQ on parempi kuin CQP, mutta jotkut laitteistokiihdyttimet eivät tue sitä. Tätä asetusta käytetään oletuksena laatuun pohjautuvissa muunnoksissa, paitsi NVENC mikä ei tue ICQ:ta.", - "transcoding_constant_rate_factor": "", + "transcoding_constant_rate_factor": "Vakionopeustekijä", "transcoding_constant_rate_factor_description": "Videon laatu. Yleisimmät arvot ovat 23 H.264:lle, 28 HEVC:lle, 31 VP9:lle ja 35 AV1:lle. Matalampi arvo on parempi, mutta tekee isompia tiedostoja.", "transcoding_disabled_description": "Älä muunna videoita. Voi joissakin päätelaitteissa aiheuttaa videotoiston toimimattomuutta", "transcoding_hardware_acceleration": "Laitteistokiihdytys", @@ -283,7 +288,7 @@ "transcoding_preferred_hardware_device": "Ensisijainen laite", "transcoding_preferred_hardware_device_description": "On voimassa vain VAAPI ja QSV -määritteille. Asettaa laitteistokoodauksessa käytetyn DRI noodin.", "transcoding_preset_preset": "Esiasetus (-asetus)", - "transcoding_preset_preset_description": "Pakkausnopeus. Hitaampi tuottaa pienempiä tiedostoja ja parantaa laatua, kun kohdistetaan tiettyyn bittinopeuteen. VP9 ei huomioi korkeampaa kuin `faster`.", + "transcoding_preset_preset_description": "Pakkausnopeus. Hitaampi tuottaa pienempiä tiedostoja ja parantaa laatua, kun kohdistetaan tiettyyn bittinopeuteen. VP9 ei huomioi korkeampaa kuin 'faster'.", "transcoding_reference_frames": "Kehysviitteet", "transcoding_reference_frames_description": "Viittaavien kehysten määrä kun tiettyä kehystä pakataan. Korkeampi arvo parantaa pakkausta mutta hidastaa enkoodausta. 0 määrittää arvon automaattisesti.", "transcoding_required_description": "Vain videoille, jotka eivät ole hyväksytyssä muodossa", @@ -302,7 +307,7 @@ "transcoding_transcode_policy": "Transkoodauskäytäntö", "transcoding_transcode_policy_description": "Käytäntö miten video tulisi transkoodata. HDR videot transkoodataan aina, paitsi jos transkoodaus on poistettu käytöstä.", "transcoding_two_pass_encoding": "Two-pass enkoodaus", - "transcoding_two_pass_encoding_setting_description": "", + "transcoding_two_pass_encoding_setting_description": "Transkoodaa kahdessa vaiheessa tuottaaksesi paremmin koodattuja videoita. Kun maksimibittinopeus on käytössä (vaaditaan H.264- ja HEVC-koodaukselle), tämä tila käyttää bittinopeusaluetta, joka perustuu maksimibittinopeuteen ja ohittaa CRF. VP9 osalta CRF:ää voidaan käyttää, jos maksimibittinopeus on poistettu käytöstä.", "transcoding_video_codec": "Videokoodekki", "transcoding_video_codec_description": "VP9 on tehokkain ja web-yhteensopiva, mutta muuntaminen kestää kauemmin. HEVC suoriutuu yhtäläisesti, mutta ei ole ihan yhtä yhteensopiva. H.264 on hyvin yhteensopiva ja nopea muuntaa, mutta tuottaa paljon suurempia tiedostoja. AV1 on kaikkein tehokkain koodekki, mutta vanhemmat laitteet eivät sitä tue.", "trash_enabled_description": "Ota käyttöön roskakori", @@ -312,15 +317,22 @@ "trash_settings_description": "Hallitse roskakoriasetuksia", "untracked_files": "Tiedostot joita ei seurata", "untracked_files_description": "Nämä tiedostot eivät ole ohjelman hallitsemia. Ne voivat olla virheellisten siirtojen tai keskeytyneiden latausten tulosta, tai bugista johtuvia jälkeen jääneitä", + "user_cleanup_job": "Käyttäjien puhdistus", + "user_delete_delay": "Käyttäjän {user} tili ja aineistot aikataulutetaan poistettavaksi ajan kuluttua: {delay, plural, one {# day} other {# days}}.", "user_delete_delay_settings": "Poiston viive", "user_delete_delay_settings_description": "Montako päivää poistamisen jälkeen käyttäjä ja hänen aineistonsa poistetaan pysyvästi. Joka keskiyö käydään läpi poistetuiksi merkityt käyttäjät. Tämä muutos astuu voimaan seuraavalla ajokerralla.", + "user_delete_immediately": "{user}:n tili ja sen kohteet on ajastettu poistettavaksi heti.", + "user_delete_immediately_checkbox": "Aseta tili ja sen kohteet jonoon välitöntä poistoa varten", "user_management": "Käyttäjien hallinta", "user_password_has_been_reset": "Käyttäjän salasana on nollattu:", "user_password_reset_description": "Anna väliaikainen salasana ja ohjeista käyttäjää vaihtamaan se seuraavan kirjautumisen yhteydessä.", + "user_restore_description": "{user}:n tili palautetaan.", + "user_restore_scheduled_removal": "Palauta käyttäjä - Aikataulutettu poisto tapahtuu {date, date, long}", "user_settings": "Käyttäjäasetukset", "user_settings_description": "Hallitse käyttäjäasetuksia", "user_successfully_removed": "Käyttäjä {email} on poistettu.", - "version_check_enabled_description": "Ota käyttöön säännölliset uusien versioiden tarkistukset GitHubista", + "version_check_enabled_description": "Ota käyttöön versiotarkastus", + "version_check_implications": "Versiontarkistus vaatii säännöllisen yhteyden github.com:iin", "version_check_settings": "Versiotarkistus", "version_check_settings_description": "Ota käyttöön ilmoitukset, kun uusi versio on saatavilla", "video_conversion_job": "Transkoodaa videot", @@ -336,17 +348,21 @@ "album_added": "Albumi lisätty", "album_added_notification_setting_description": "Saa sähköpostia kun sinut lisätään jaettuun albumiin", "album_cover_updated": "Albumin kansikuva päivitetty", - "album_delete_confirmation": "Haluatko varmasti poistaa albumin {album}?\nJos albumi on jaettu, muut eivät pääse siihen enää.", + "album_delete_confirmation": "Haluatko varmasti poistaa albumin {album}?", + "album_delete_confirmation_description": "Jos albumi on jaettu, muut eivät pääse siihen enää.", "album_info_updated": "Albumin tiedot päivitetty", "album_leave": "Poistu albumista?", + "album_leave_confirmation": "Haluatko varmasti poistua albumista {album}?", "album_name": "Albumin nimi", "album_options": "Albumin asetukset", "album_remove_user": "Poista käyttäjä?", - "album_remove_user_confirmation": "Oletko varma että haluat poistaa {user}?", + "album_remove_user_confirmation": "Oletko varma että haluat poistaa {user}?", "album_share_no_users": "Näyttää että olet jakanut tämän albumin kaikkien kanssa, tai sinulla ei ole käyttäjiä joille jakaa.", "album_updated": "Albumi päivitetty", "album_updated_setting_description": "Saa sähköpostia kun jaetussa albumissa on uutta sisältöä", + "album_user_left": "Poistuttiin albumista {album}", "album_user_removed": "{user} poistettu", + "album_with_link_access": "Anna kenen tahansa nähdä linkin kautta tämän albumin valokuvat ja henkilöt.", "albums": "Albumit", "albums_count": "{count, plural, one {{count, number} albumi} other {{count, number} albumia}}", "all": "Kaikki", @@ -355,7 +371,12 @@ "all_videos": "Kaikki videot", "allow_dark_mode": "Salli tumma tila", "allow_edits": "Salli muutokset", + "allow_public_user_to_download": "Salli julkisten käyttäjien ladata tiedostoja", + "allow_public_user_to_upload": "Salli julkisten käyttäjien lähettää tiedostoja", + "anti_clockwise": "Vastapäivään", "api_key": "API-avain", + "api_key_description": "Tämä arvo näytetään vain kerran. Varmista, että olet kopioinut sen ennen kuin suljet ikkunan.", + "api_key_empty": "API-avaimesi ei pitäisi olla tyhjä", "api_keys": "API-avaimet", "app_settings": "Sovellusasetukset", "appears_in": "Esiintyy albumeissa", @@ -369,14 +390,20 @@ "are_you_sure_to_do_this": "Haluatko varmasti tehdä tämän?", "asset_added_to_album": "Lisätty albumiin", "asset_adding_to_album": "Lisätään albumiin...", + "asset_description_updated": "Kohteen kuvaus on päivitetty", + "asset_filename_is_offline": "Kohde {filename} on offline-tilassa", + "asset_has_unassigned_faces": "Kohteella on määrittämättömiä kasvoja", + "asset_hashing": "Hajautetaan...", "asset_offline": "Aineisto offline-tilassa", + "asset_offline_description": "Tämä kohde on offline-tilassa. Immich ei pääse tiedoston sijaintiin. Varmista, että kohde on saatavilla, ja skannaa sitten kirjasto uudelleen.", "asset_skipped": "Ohitettu", + "asset_skipped_in_trash": "Roskakorissa", "asset_uploaded": "Lähetetty", "asset_uploading": "Lähetetään…", "assets": "kohdetta", "assets_added_count": "Lisätty {count, plural, one {# kohde} other {# kohdetta}}", "assets_added_to_album_count": "Albumiin lisätty {count, plural, one {# kohde} other {# kohdetta}}", - "assets_added_to_name_count": "{name}:n lisätty {count, plural, one {# media} other {# mediaa}}", + "assets_added_to_name_count": "Lisätty {count, plural, one {# kohde} other {# kohdetta}} {hasName, select, true {{name}} other {uuteen albumiin}}", "assets_count": "{count, plural, one {# media} other {# mediaa}}", "assets_moved_to_trash": "Siirretty {count, plural, one {# aineisto} other {# aineistoa}} roskakoriin", "assets_moved_to_trash_count": "Siirretty {count, plural, one {# media} other {# mediaa}} roskakoriin", @@ -398,6 +425,7 @@ "bulk_delete_duplicates_confirmation": "Haluatko varmasti poistaa {count, plural, one {# kaksoiskappaleen} other {# kaksoiskappaleet}} kerralla? Tämä säilyttää kustakin mediasta kookkaimman ja poistaa loput pysyvästi. Et voi perua tätä!", "bulk_keep_duplicates_confirmation": "Haluatko varmasti säilyttää {count, plural, one {# kaksoiskappaleen} other {# kaksoiskappaleet}}? Tämä merkitsee kaikki kaksoiskappaleet ratkaistuiksi, eikä poista mitään.", "bulk_trash_duplicates_confirmation": "Haluatko varmasti siirtää {count, plural, one {# kaksoiskappaleen} other {# kaksoiskappaleet}} roskakoriin? Tämä säilyttää kustakin mediasta kookkaimman ja siirtää loput roskakoriin.", + "buy": "Osta lisenssi Immich:iin", "camera": "Kamera", "camera_brand": "Kameran merkki", "camera_model": "Kameran malli", @@ -415,7 +443,7 @@ "change_location": "Vaihda sijainti", "change_name": "Vaihda nimi", "change_name_successfully": "Nimi vaihdettu", - "change_password": "Vaihda salasana", + "change_password": "Vaihda Salasana", "change_password_description": "Tämä on joko ensimmäinen kertasi kun kirjaudut järjestelmään, tai salasanasi on pyydetty vaihtamaan. Määritä uusi salasana alle.", "change_your_password": "Vaihda salasanasi", "changed_visibility_successfully": "Näkyvyys vaihdettu", @@ -425,11 +453,14 @@ "city": "Kaupunki", "clear": "Tyhjennä", "clear_all": "Tyhjennä kaikki", + "clear_all_recent_searches": "Tyhjennä viimeisimmät haut", "clear_message": "Tyhjennä viesti", "clear_value": "Tyhjää arvo", + "clockwise": "Myötäpäivään", "close": "Sulje", "collapse": "Supista", "collapse_all": "Sulje kaikki", + "color": "Väri", "color_theme": "Väriteema", "comment_deleted": "Kommentti poistettu", "comment_options": "Kommentin valinnat", @@ -463,13 +494,15 @@ "create_new_person": "Luo uusi henkilö", "create_new_person_hint": "Määritä valitut mediat uudelle henkilölle", "create_new_user": "Luo uusi käyttäjä", + "create_tag": "Luo tunniste", + "create_tag_description": "Luo uusi tunniste. Sisäkkäisiä tunnisteita varten, syötä tunnisteen täydellinen polku kauttaviiva mukaanluettuna.", "create_user": "Luo käyttäjä", "created": "Luotu", "current_device": "Nykyinen laite", "custom_locale": "Muokatut maa-asetukset", "custom_locale_description": "Muotoile päivämäärät ja numerot perustuen alueen kieleen", "dark": "Tumma", - "date_after": "Päivä jälkeen", + "date_after": "Päivämäärän jälkeen", "date_and_time": "Päivämäärä ja aika", "date_before": "Päivä ennen", "date_of_birth_saved": "Syntymäaika tallennettu", @@ -486,6 +519,8 @@ "delete_library": "Poista kirjasto", "delete_link": "Poista linkki", "delete_shared_link": "Poista jaettu linkki", + "delete_tag": "Poista tunniste", + "delete_tag_confirmation_prompt": "Haluatko varmasti poistaa {tagName}-tunnisteen?", "delete_user": "Poista käyttäjä", "deleted_shared_link": "Jaettu linkki poistettu", "description": "Kuvaus", @@ -503,6 +538,8 @@ "do_not_show_again": "Älä näytä tätä enää", "done": "Valmis", "download": "Lataa", + "download_include_embedded_motion_videos": "Upotetut videot", + "download_include_embedded_motion_videos_description": "Sisällytä liikekuviin upotetut videot erillisinä tiedostoina", "download_settings": "Lataukset", "download_settings_description": "Hallitse aineiston lataukseen liittyviä asetuksia", "downloading": "Ladataan", @@ -532,10 +569,15 @@ "edit_location": "Muokkaa sijaintia", "edit_name": "Muokkaa nimeä", "edit_people": "Muokkaa henkilöitä", + "edit_tag": "Muokkaa tunnistetta", "edit_title": "Muokkaa otsikkoa", "edit_user": "Muokkaa käyttäjää", "edited": "Muokattu", - "editor": "", + "editor": "Editori", + "editor_close_without_save_prompt": "Muutoksia ei tallenneta", + "editor_close_without_save_title": "Suljetaanko editori?", + "editor_crop_tool_h2_aspect_ratios": "Kuvasuhteet", + "editor_crop_tool_h2_rotation": "Rotaatio", "email": "Sähköposti", "empty": "", "empty_album": "", @@ -563,6 +605,7 @@ "error_adding_users_to_album": "Käyttäjiä ei voitu lisätä albumiin", "error_deleting_shared_user": "Jaettua käyttäjää ei voitu poistaa", "error_downloading": "Tiedostoa {filename} ei voitu ladata", + "error_hiding_buy_button": "Virhe osta-painikkeen piilottamisessa", "error_removing_assets_from_album": "Medioiden poisto epäonnistui. Katso konsolista lisätietoja", "error_selecting_all_assets": "Kaikkia medioita ei voitu valita", "exclusion_pattern_already_exists": "Tämä poissulkemismalli on jo olemassa.", @@ -573,6 +616,8 @@ "failed_to_get_people": "Henkilöiden haku epäonnistui", "failed_to_load_asset": "Kohteen lataus epäonnistui", "failed_to_load_assets": "Kohteiden lataus epäonnistui", + "failed_to_load_people": "Henkilöiden lataus epäonnistui", + "failed_to_remove_product_key": "Tuoteavaimen poistaminen epäonnistui", "failed_to_stack_assets": "Medioiden pinoaminen epäonnistui", "failed_to_unstack_assets": "Medioiden pinoamisen purku epäonnistui", "import_path_already_exists": "Tämä tuontipolku on jo olemassa.", @@ -580,54 +625,90 @@ "paths_validation_failed": "{paths, plural, one {# polun} other {# polun}} validointi epäonnistui", "profile_picture_transparent_pixels": "Profiilikuvassa ei voi olla läpinäkyviä pikseleitä. Zoomaa lähemmäs ja/tai siirrä kuvaa.", "quota_higher_than_disk_size": "Asettamasi kiintiö on suurempi kuin levyn koko", - "unable_to_add_album_users": "", - "unable_to_add_comment": "", - "unable_to_add_partners": "", - "unable_to_change_album_user_role": "", - "unable_to_change_date": "", + "repair_unable_to_check_items": "Ei voida tarkistaa {count, select, one {kohdetta} other {kohteita}}", + "unable_to_add_album_users": "Käyttäjiä ei voi lisätä albumiin", + "unable_to_add_assets_to_shared_link": "Medioiden lisääminen jaettuun linkkiin epäonnistui", + "unable_to_add_comment": "Kommentin lisääminen epäonnistui", + "unable_to_add_exclusion_pattern": "Ei voida lisätä poissulkuohjetta", + "unable_to_add_import_path": "Tuontipolkua ei voitu lisätä", + "unable_to_add_partners": "Kumppaneita ei voitu lisätä", + "unable_to_add_remove_archive": "Ei voida {archived, select, true {poistaa kohdetta arkistosta} other {lisätä kohdetta arkistoon}}", + "unable_to_add_remove_favorites": "Ei voida {favorite, select, true {lisätä kohdetta suosikkeihin} other {poistaa kohdetta suosikeista}}", + "unable_to_archive_unarchive": "Ei voida {archived, select, true {arkistoida} other {poistaa arkistosta}}", + "unable_to_change_album_user_role": "Albumin käyttäjän roolia ei voitu muuttaa", + "unable_to_change_date": "Päivämäärää ei voitu muuttaa", + "unable_to_change_favorite": "Ei voida muuttaa suosikkia kohteelle", "unable_to_change_location": "Sijainnin muuttaminen epäonnistui", "unable_to_change_password": "Salasanan vaihto epäonnistui", + "unable_to_change_visibility": "Ei voida muuttaa näkyvyyttä {count, plural, one {# henkilölle} other {# henkilölle}}", "unable_to_check_item": "", "unable_to_check_items": "", + "unable_to_complete_oauth_login": "OAuth-kirjautumista ei voitu suorittaa loppuun", + "unable_to_connect": "Yhteyttä ei voitu muodostaa", "unable_to_connect_to_server": "Palvelimeen ei saatu yhteyttä", + "unable_to_copy_to_clipboard": "Leikepöydälle ei voitu kopioida, varmista että käytät sivua https-yhteyden kautta", "unable_to_create_admin_account": "Pääkäyttäjän luominen epäonnistui", - "unable_to_create_library": "", - "unable_to_create_user": "", - "unable_to_delete_album": "", - "unable_to_delete_asset": "", - "unable_to_delete_user": "", - "unable_to_empty_trash": "", - "unable_to_enter_fullscreen": "", - "unable_to_exit_fullscreen": "", - "unable_to_hide_person": "", - "unable_to_load_album": "", - "unable_to_load_asset_activity": "", - "unable_to_load_items": "", - "unable_to_load_liked_status": "", - "unable_to_play_video": "", - "unable_to_refresh_user": "", - "unable_to_remove_album_users": "", + "unable_to_create_api_key": "Uuden API-avaimen luominen epäonnistui", + "unable_to_create_library": "Kirjaston luominen epäonnistui", + "unable_to_create_user": "Käyttäjän luominen epäonnistui", + "unable_to_delete_album": "Albumin poistaminen epäonnistui", + "unable_to_delete_asset": "Kohteen poistaminen epäonnistui", + "unable_to_delete_assets": "Virhe kohteen poistamisessa", + "unable_to_delete_exclusion_pattern": "Ei voida poistaa poissulkuohjetta", + "unable_to_delete_import_path": "Tuontipolkua ei voitu poistaa", + "unable_to_delete_shared_link": "Jaetun linkin poistaminen epäonnistui", + "unable_to_delete_user": "Käyttäjän poistaminen epäonnistui", + "unable_to_download_files": "Tiedostojen lataaminen epäonnistui", + "unable_to_edit_exclusion_pattern": "Ei voida muokata poissulkuohjetta", + "unable_to_edit_import_path": "Tuontipolkua ei voitu muokata", + "unable_to_empty_trash": "Roskakorin tyhjentäminen epäonnistui", + "unable_to_enter_fullscreen": "Koko ruudun tilaan siirtyminen epäonnistui", + "unable_to_exit_fullscreen": "Koko ruudun tilasta poistuminen epäonnistui", + "unable_to_get_comments_number": "Kommenttien määrän hakeminen epäonnistui", + "unable_to_get_shared_link": "Jaetun linkin hakeminen epäonnistui", + "unable_to_hide_person": "Henkilön piilottaminen epäonnistui", + "unable_to_link_motion_video": "Liikekuvan linkitys epäonnistui", + "unable_to_link_oauth_account": "OAuth-tilin linkittäminen epäonnistui", + "unable_to_load_album": "Albumin lataaminen epäonnistui", + "unable_to_load_asset_activity": "Ei voitu ladata kohteen toimintaa", + "unable_to_load_items": "Kohteiden lataaminen epäonnistui", + "unable_to_load_liked_status": "Ei voitu ladata tykkäyksen tilaa", + "unable_to_log_out_all_devices": "Kaikkien laitteiden uloskirjautuminen epäonnistui", + "unable_to_log_out_device": "Laitteen uloskirjautuminen epäonnistui", + "unable_to_login_with_oauth": "OAuth-kirjautuminen epäonnistui", + "unable_to_play_video": "Videon toistaminen epäonnistui", + "unable_to_reassign_assets_existing_person": "Ei voida siirtää kohteita {name, select, null {olemassa olevalle henkilölle} other {{name}}}", + "unable_to_reassign_assets_new_person": "Ei voida siirtää kohteita uudelle henkilölle", + "unable_to_refresh_user": "Käyttäjän päivittäminen epäonnistui", + "unable_to_remove_album_users": "Käyttäjien poistaminen albumista epäonnistui", + "unable_to_remove_api_key": "API-avaimen poistaminen epäonnistui", + "unable_to_remove_assets_from_shared_link": "kohteiden poistaminen jaetusta linkistä epäonnistui", "unable_to_remove_comment": "", - "unable_to_remove_library": "", - "unable_to_remove_partner": "", - "unable_to_remove_reaction": "", + "unable_to_remove_library": "Kirjaston poistaminen epäonnistui", + "unable_to_remove_offline_files": "Offline-tiedostojen poistaminen epäonnistui", + "unable_to_remove_partner": "Kumppanin poistaminen epäonnistui", + "unable_to_remove_reaction": "Reaktion poistaminen epäonnistui", "unable_to_remove_user": "", - "unable_to_repair_items": "", - "unable_to_reset_password": "", - "unable_to_resolve_duplicate": "", - "unable_to_restore_assets": "", - "unable_to_restore_trash": "", - "unable_to_restore_user": "", - "unable_to_save_album": "", - "unable_to_save_name": "", - "unable_to_save_profile": "", - "unable_to_save_settings": "", - "unable_to_scan_libraries": "", - "unable_to_scan_library": "", + "unable_to_repair_items": "Kohteiden korjaaminen epäonnistui", + "unable_to_reset_password": "Salasanan nollaaminen epäonnistui", + "unable_to_resolve_duplicate": "Virheilmoitus näkyy, kun palvelin palauttaa virheen painettaessa roskakorin tai säilytä-painiketta.", + "unable_to_restore_assets": "Kohteen palauttaminen epäonnistui", + "unable_to_restore_trash": "Kohteiden palauttaminen epäonnistui", + "unable_to_restore_user": "Käyttäjän palauttaminen epäonnistui", + "unable_to_save_album": "Albumin tallentaminen epäonnistui", + "unable_to_save_api_key": "API-avaimen tallentaminen epäonnistui", + "unable_to_save_date_of_birth": "Syntymäajan tallentaminen epäonnistui", + "unable_to_save_name": "Nimen tallentaminen epäonnistui", + "unable_to_save_profile": "Profiilin tallentaminen epäonnistui", + "unable_to_save_settings": "Asetusten tallentaminen epäonnistui", + "unable_to_scan_libraries": "Kirjastojen skannaaminen epäonnistui", + "unable_to_scan_library": "Kirjaston skannaaminen epäonnistui", + "unable_to_set_feature_photo": "Ei voida asettaa ominaiskuvaa", "unable_to_set_profile_picture": "Profiilikuvan asetus epäonnistui", "unable_to_submit_job": "Työtä ei voitu lähettää", "unable_to_trash_asset": "Median siirto roskakoriin epäonnistui", "unable_to_unlink_account": "Tunnuksen irroitus epäonnistui", + "unable_to_unlink_motion_video": "Ei voida irrottaa liikevideota", "unable_to_update_album_cover": "Albumin kannen päivitys epäonnistui", "unable_to_update_album_info": "Albumin tietojen päivitys epäonnistui", "unable_to_update_library": "Kirjaston päivitys epäonnistui", @@ -648,59 +729,82 @@ "expired": "Voimassaolo päättynyt", "expires_date": "Vanhenee {date}", "explore": "Tutki", + "explorer": "Tutkija", "export": "Vie", "export_as_json": "Vie JSON-muodossa", - "extension": "", - "external_libraries": "", + "extension": "Tiedostopääte", + "external": "Ulkoisesta", + "external_libraries": "Ulkoiset kirjastot", + "face_unassigned": "Ei määritelty", "failed_to_get_people": "", "favorite": "Suosikki", - "favorite_or_unfavorite_photo": "", + "favorite_or_unfavorite_photo": "Suosikki- tai ei-suosikkikuva", "favorites": "Suosikit", "feature": "", "feature_photo_updated": "Kansikuva ladattu", "featurecollection": "", - "file_name": "", - "file_name_or_extension": "", + "features": "Ominaisuudet", + "features_setting_description": "Hallitse sovelluksen ominaisuuksia", + "file_name": "Tiedoston nimi", + "file_name_or_extension": "Tiedostonimi tai tiedostopääte", "filename": "Tiedostonimi", "files": "", "filetype": "Tiedostotyyppi", - "filter_people": "", - "fix_incorrect_match": "", - "force_re-scan_library_files": "", + "filter_people": "Suodata henkilöt", + "find_them_fast": "Löydä nopeasti hakemalla nimellä", + "fix_incorrect_match": "Korjaa virheellinen osuma", + "folders": "Kansiot", + "folders_feature_description": "Käytetään kansionäkymää valokuvien ja videoiden selaamiseen järjestelmässä", + "force_re-scan_library_files": "Pakota kaikkien kirjastotiedostojen uudelleenskannaus", "forward": "Eteenpäin", - "general": "", - "get_help": "", - "getting_started": "", + "general": "Yleinen", + "get_help": "Hae apua", + "getting_started": "Aloittaminen", "go_back": "Palaa", - "go_to_search": "", + "go_to_search": "Siirry hakuun", "go_to_share_page": "", - "group_albums_by": "", + "group_albums_by": "Ryhmitä albumi...", "group_no": "Ei ryhmitystä", "group_owner": "Ryhmitä omistajan mukaan", "group_year": "Ryhmitä vuoden mukaan", - "has_quota": "", + "has_quota": "On kiintiö", "hi_user": "Hei {name} ({email})", - "hide_gallery": "", - "hide_password": "", - "hide_person": "", - "host": "", + "hide_all_people": "Piilota kaikki henkilöt", + "hide_gallery": "Piilota galleria", + "hide_named_person": "Piilota henkilön {name}", + "hide_password": "Piilota salasana", + "hide_person": "Piilota henkilö", + "hide_unnamed_people": "Piilota nimeämättömät henkilöt", + "host": "Isäntä", "hour": "Tunti", "image": "Kuva", + "image_alt_text_date": "{isVideo, select, true {Video} other {Kuva}} otettu {date}", + "image_alt_text_date_1_person": "{isVideo, select, true {Video} other {Kuva}} otettu {person1} kanssa {date}", + "image_alt_text_date_2_people": "{isVideo, select, true {Video} other {Kuva}} otettu {person1}n ja {person2}n kanssa {date}", + "image_alt_text_date_3_people": "{isVideo, select, true {Video} other {Kuva}} otettu {person1}n, {person2}n ja {person3}n kanssa {date}", + "image_alt_text_date_4_or_more_people": "{isVideo, select, true {Video} other {Kuva}} otettu {person1}n, {person2}n ja {additionalCount, number} muissa kanssa {date}", + "image_alt_text_date_place": "{isVideo, select, true {Video} other {Kuva}} otettu {city}ssä, {country}ssä {date}", + "image_alt_text_date_place_1_person": "{isVideo, select, true {Video} other {Kuva}} otettu {city}ssä, {country}ssä {person1}n kanssa {date}", + "image_alt_text_date_place_2_people": "{isVideo, select, true {Video} other {Kuva}} otettu {city}ssä, {country}ssä {person1}n ja {person2}n kanssa {date}", + "image_alt_text_date_place_3_people": "{isVideo, select, true {Video} other {Kuva}} otettu {city}ssä, {country}ssä {person1}n, {person2}n ja {person3}n kanssa {date}", + "image_alt_text_date_place_4_or_more_people": "{isVideo, select, true {Video} other {Kuva}} otettu {city}ssä, {country}ssä {person1}n, {person2}n ja {additionalCount, number} muun kanssa {date}", "img": "", - "immich_logo": "", - "import_path": "", + "immich_logo": "Immich Logo", + "immich_web_interface": "Immich verkkoliittymä", + "import_from_json": "Tuo JSON-tiedostosta", + "import_path": "Tuontipolku", "in_albums": "{count, plural, one {# Albumissa} other {# albumissa}}", "in_archive": "Arkistossa", "include_archived": "Sisällytä arkistoidut", - "include_shared_albums": "", - "include_shared_partner_assets": "", - "individual_share": "", + "include_shared_albums": "Sisällytä jaetut albumit", + "include_shared_partner_assets": "Sisällytä jaetut kumppanikohteet", + "individual_share": "Yksittäinen jako", "info": "Lisätietoja", "interval": { - "day_at_onepm": "", - "hours": "", - "night_at_midnight": "", - "night_at_twoam": "" + "day_at_onepm": "Joka päivä klo 13:00", + "hours": "Joka {hours, plural, one {tunti} other {{hours, number} tuntia}}", + "night_at_midnight": "Joka yö keskiyöllä", + "night_at_twoam": "Joka yö klo 02:00" }, "invite_people": "Kutsu ihmisiä", "invite_to_album": "Kutsu albumiin", @@ -714,47 +818,58 @@ "language_setting_description": "Valitse suosimasi kieli", "last_seen": "Viimeksi nähty", "latest_version": "Viimeisin versio", + "latitude": "Leveysaste", "leave": "Lähde", "let_others_respond": "Anna muiden vastata", "level": "Taso", "library": "Kirjasto", - "library_options": "", + "library_options": "Kirjastovaihtoehdot", "license_button_buy": "Osta", "license_button_select": "Valitse", "light": "Vaalea", - "link_options": "", - "link_to_oauth": "", - "linked_oauth_account": "", + "like_deleted": "Tykkäys poistettu", + "link_motion_video": "Linkitä liikevideo", + "link_options": "Linkin asetukset", + "link_to_oauth": "Linkki OAuth", + "linked_oauth_account": "Linkitetty OAuth-tili", "list": "Lista", "loading": "Ladataan", - "loading_search_results_failed": "", + "loading_search_results_failed": "Hakutulosten lataaminen epäonnistui", "log_out": "Kirjaudu ulos", "log_out_all_devices": "Kirjaudu ulos kaikilta laitteilta", + "logged_out_all_devices": "Kaikki laitteet kirjattu ulos", + "logged_out_device": "Laite kirjattu ulos", "login": "Kirjaudu", "login_has_been_disabled": "Kirjautuminen on otettu pois käytöstä.", "logout_all_device_confirmation": "Haluatko varmasti kirjautua ulos kaikilta laitteilta?", "logout_this_device_confirmation": "Haluatko varmasti kirjautua ulos näiltä laitteilta?", + "longitude": "Pituusaste", "look": "Tyyli", - "loop_videos": "", - "loop_videos_description": "", + "loop_videos": "Toista videot uudelleen", + "loop_videos_description": "Ota käyttöön videon automaattinen toisto tarkemmassa näkymässä.", "make": "Valmistaja", "manage_shared_links": "Hallitse jaettuja linkkejä", - "manage_sharing_with_partners": "", + "manage_sharing_with_partners": "Hallitse jakamista kumppaneille", "manage_the_app_settings": "Hallitse sovelluksen asetuksia", "manage_your_account": "Hallitse tiliäsi", - "manage_your_api_keys": "", - "manage_your_devices": "", - "manage_your_oauth_connection": "", + "manage_your_api_keys": "Hallitse API-avaimiasi", + "manage_your_devices": "Hallitse sisäänkirjautuneita laitteitasi", + "manage_your_oauth_connection": "Hallitse OAuth-yhteyttäsi", "map": "Kartta", - "map_marker_with_image": "", + "map_marker_for_images": "Karttamarkerointi kuville, jotka on otettu {city}ssä, {country}ssä", + "map_marker_with_image": "Karttamarkerointi kuvalla", "map_settings": "Kartta-asetukset", + "matches": "Osumia", "media_type": "Median tyyppi", - "memories": "", - "memories_setting_description": "", + "memories": "Muistoja", + "memories_setting_description": "Hallitse mitä näet muistoissasi", "memory": "Muisto", + "memory_lane_title": "Muistojen polku {title}", "menu": "Valikko", "merge": "Yhdistä", "merge_people": "Yhdistä henkilöt", + "merge_people_limit": "Voit yhdistää vain enintään 5 kasvoa kerrallaan", + "merge_people_prompt": "Haluatko yhdistää nämä henkilöt? Tätä valintaa ei voi peruuttaa.", "merge_people_successfully": "Henkilöt yhdistetty", "merged_people_count": "{count, plural, one {# Henkilö} other {# henkilöä}} yhdistetty", "minimize": "PIenennä", @@ -768,6 +883,7 @@ "name": "Nimi", "name_or_nickname": "Nimi tai lempinimi", "never": "ei koskaan", + "new_album": "Uusi Albumi", "new_api_key": "Uusi API Key", "new_password": "Uusi salasana", "new_person": "Uusi henkilö", @@ -780,42 +896,55 @@ "no_albums_message": "Luo albumi pitääksesi kuvat ja videot järjestyksessä", "no_albums_with_name_yet": "Näyttää siltä, ettei sinulla ole yhtään tämän nimistä albumia.", "no_albums_yet": "Näyttää siltä, ettei sinulla ole vielä yhtään albumia.", - "no_archived_assets_message": "", + "no_archived_assets_message": "Arkistoi kuvia ja videoita piilottaaksesi ne kuvat näkymästä", "no_assets_message": "NAPAUTA LATAAKSESI ENSIMMÄISEN KUVASI", + "no_duplicates_found": "Kaksoiskappaleita ei löytynyt.", "no_exif_info_available": "EXIF-tietoa ei saatavilla", - "no_explore_results_message": "", + "no_explore_results_message": "Lataa lisää kuvia tutkiaksesi kokoelmaasi.", "no_favorites_message": "Lisää suosikkeja löytääksesi nopeasti parhaat kuvasi ja videosi", - "no_libraries_message": "", + "no_libraries_message": "Luo ulkoinen kirjasto nähdäksesi valokuvasi ja videot", "no_name": "Ei nimeä", - "no_places": "", + "no_places": "Ei paikkoja", "no_results": "Ei tuloksia", + "no_results_description": "Kokeile synonyymiä tai yleisempää avainsanaa", "no_shared_albums_message": "Luo albumi, jotta voit jakaa kuvia ja videoita toisille", "not_in_any_album": "Ei yhdessäkään albumissa", + "note_apply_storage_label_to_previously_uploaded assets": "Huom: Jotta voit soveltaa tallennustunnistetta aiemmin ladattuihin kohteisiin, suorita", + "note_unlimited_quota": "Huomio: Syötä 0 rajoittamatonta kiintiötä varten", "notes": "Muistiinpanot", "notification_toggle_setting_description": "Ota sähköpostilmoitukset käyttöön", "notifications": "Ilmoitukset", "notifications_setting_description": "Hallitse ilmoituksia", "oauth": "OAuth", - "offline": "", + "offline": "Offline", + "offline_paths": "Offline-polut", + "offline_paths_description": "Nämä tulokset voivat johtua tiedostojen manuaalisesta poistamisesta, jotka eivät ole osa ulkoista kirjastoa.", "ok": "Ok", "oldest_first": "Vanhin ensin", + "onboarding": "Käyttöönotto", + "onboarding_privacy_description": "Seuraavat (valinnaiset) ominaisuudet perustuvat ulkoisiin palveluihin, ja ne voidaan poistaa käytöstä milloin tahansa hallinta asetuksista.", + "onboarding_theme_description": "Valitse väriteema istunnollesi. Voit muuttaa tämän myöhemmin asetuksistasi.", + "onboarding_welcome_description": "Aloitetaa laittamalla istuntoosi joitakin yleisiä asetuksia.", "onboarding_welcome_user": "Tervetuloa {user}", "online": "Online", "only_favorites": "Vain suosikit", - "only_refreshes_modified_files": "", + "only_refreshes_modified_files": "Päivittää vain muakatut tiedostot", + "open_in_map_view": "Avaa karttanäkymässä", "open_in_openstreetmap": "Avaa OpenStreetMapissa", - "open_the_search_filters": "", + "open_the_search_filters": "Avaa hakusuodattimet", "options": "Vaihtoehdot", "or": "tai", "organize_your_library": "Järjestele kirjastosi", "original": "alkuperäinen", "other": "Muut", "other_devices": "Toiset laitteet", - "other_variables": "", + "other_variables": "Muut muuttujat", "owned": "Omistettu", "owner": "Omistaja", "partner": "Kumppani", "partner_can_access": "{partner} voi päästä", + "partner_can_access_assets": "Kaikki valokuvasi ja videosi, lukuun ottamatta arkistoituja ja poistettuja", + "partner_can_access_location": "Sijainti, jossa kuvasi on otettu", "partner_sharing": "Kumppanijako", "partners": "Kumppanit", "password": "Salasana", @@ -823,22 +952,26 @@ "password_required": "Salasana vaaditaan", "password_reset_success": "Salasanan nollaus onnistui", "past_durations": { - "days": "{years, plural, one {Viimeisin päivä} other {Viimeiset # päivää}}", - "hours": "{years, plural, one {Viimeisin tunti} other {Viimeiset # tuntia}}", + "days": "Viime {days, plural, one {päivä} other {# päivää}}", + "hours": "Viime {hours, plural, one {tunti} other {# tuntia}}", "years": "{years, plural, one {Viimeisin vuosi} other {Viimeiset # vuotta}}" }, "path": "Polku", - "pattern": "", + "pattern": "Kaava", "pause": "Tauko", - "pause_memories": "", + "pause_memories": "Pysäytä muistot", "paused": "Tauotettu", "pending": "Odottaa", "people": "Ihmiset", - "people_sidebar_description": "", + "people_edits_count": "Muokattu {count, plural, one {# henkilö} other {# henkilöä}}", + "people_feature_description": "Selataan valokuvia ja videoita, jotka on ryhmitelty henkilöiden mukaan", + "people_sidebar_description": "Näytä linkki Henkilöihin sivupalkissa", "perform_library_tasks": "", - "permanent_deletion_warning": "", - "permanent_deletion_warning_setting_description": "", + "permanent_deletion_warning": "Pysyvän poiston varoitus", + "permanent_deletion_warning_setting_description": "Näytä varoitus, kun poistat kohteita pysyvästi", "permanently_delete": "Poista pysyvästi", + "permanently_delete_assets_count": "Poista pysyvästi {count, plural, one {kohde} other {kohteita}}", + "permanently_delete_assets_prompt": "Oletko varma, että haluat poistaa pysyvästi {count, plural, one {tämän kohteen?} other {nämä # kohteet?}} Tämä poistaa myös {count, plural, one {sen sen} other {ne niiden}} albumista.", "permanently_deleted_asset": "Media poistettu pysyvästi", "permanently_deleted_assets_count": "{count, plural, one {# media} other {# mediaa}} poistettu pysyvästi", "person": "Henkilö", @@ -853,7 +986,7 @@ "places": "Paikat", "play": "Toista", "play_memories": "Toista muistot", - "play_motion_photo": "", + "play_motion_photo": "Toista Liikekuva", "play_or_pause_video": "Toista tai keskeytä video", "point": "", "port": "Portti", @@ -863,15 +996,53 @@ "previous_memory": "Edellinen muisto", "previous_or_next_photo": "Edellinen tai seuraava kuva", "primary": "Ensisijainen", + "privacy": "Yksityisyys", "profile_image_of_user": "Käyttäjän {user} profiilikuva", "profile_picture_set": "Profiilikuva asetettu.", "public_album": "Julkinen albumi", "public_share": "Julkinen jako", + "purchase_account_info": "Tukija", + "purchase_activated_subtitle": "Kiitos Immichin ja avoimen lähdekoodin ohjelmiston tukemisesta", + "purchase_activated_time": "Aktivoitu {date, date}", + "purchase_activated_title": "Avaimesi on aktivoitu onnistuneesti", + "purchase_button_activate": "Aktivoi", + "purchase_button_buy": "Osta", + "purchase_button_buy_immich": "Osta Immich", + "purchase_button_never_show_again": "Älä näytä koskaan uudelleen", + "purchase_button_reminder": "Muistuta minua 30 päivän kuluessa", + "purchase_button_remove_key": "Poista avain", + "purchase_button_select": "Valitse", + "purchase_failed_activation": "Aktivointi epäonnistui! Tarkista sähköpostisi oikean tuoteavaimen varalta!", + "purchase_individual_description_1": "Yksittäiselle henkilölle", + "purchase_individual_description_2": "Tukijan tila", + "purchase_individual_title": "Yksittäinen", + "purchase_input_suggestion": "Onko sinulla tuoteavain? Syötä avain alle", + "purchase_license_subtitle": "Osta Immich tukeaksesi palvelun jatkuvaa kehittämistä", + "purchase_lifetime_description": "Elinikäinen osto", + "purchase_option_title": "OSTOVAIHTOEHDOT", + "purchase_panel_info_1": "Immichin rakentaminen vie paljon aikaa ja vaivannäköä, ja meillä on kokopäiväisiä insinöörejä työskentelemässä sen parissa, jotta voimme tehdä siitä mahdollisimman hyvän. Missiomme on, että avoimen lähdekoodin ohjelmistosta ja eettisistä liiketoimintakäytännöistä tulee kestävä tulonlähde kehittäjille, sekä luoda yksityisyyttä kunnioittava ekosysteemi, jossa on todellisia vaihtoehtoja hyväksikäyttöön perustuville pilvipalveluille.", + "purchase_panel_info_2": "Koska olemme sitoutuneet siihen, ettemme lisää maksumuuria, tämä osto ei anna sinulle mitään lisäominaisuuksia Immichissa. Luotamme kaltaisiisi käyttäjiin tukeaksemme Immichin jatkuvaa kehittämistä.", + "purchase_panel_title": "Tue projektia", + "purchase_per_server": "Per serveri", + "purchase_per_user": "Per käyttäjä", + "purchase_remove_product_key": "Poista Tuoteavain", + "purchase_remove_product_key_prompt": "Haluatko varmasti poistaa tuoteavaimen?", + "purchase_remove_server_product_key": "Poista palvelimen tuoteavain", + "purchase_remove_server_product_key_prompt": "Haluatko varmasti poistaa palvelimen tuoteavaimen?", + "purchase_server_description_1": "Koko palvelimelle", + "purchase_server_description_2": "Tukijan tila", + "purchase_server_title": "Serveri", + "purchase_settings_server_activated": "Palvelimen tuoteavainta hallinnoi ylläpitäjä", "range": "", + "rating": "Tähtiarvostelu", + "rating_clear": "Tyhjennä arvostelu", + "rating_count": "{count, plural, one {# tähti} other {# tähteä}}", + "rating_description": "Näytä EXIF-arvosana tiedot-paneelissa", "raw": "", - "reaction_options": "", + "reaction_options": "Reaktioasetukset", "read_changelog": "Lue muutosloki", "reassign": "Määritä uudelleen", + "reassigned_assets_to_existing_person": "Uudelleen määritetty {count, plural, one {# kohde} other {# kohdetta}} {name, select, null {olemassa olevalle henkilölle} other {{name}}}", "reassigned_assets_to_new_person": "Määritetty {count, plural, one {# media} other {# mediaa}} uudelle henkilölle", "reassing_hint": "Määritä valitut mediat käyttäjälle", "recent": "Viimeisin", @@ -899,9 +1070,10 @@ "removed_from_archive": "Poistettu arkistosta", "removed_from_favorites": "Poistettu suosikeista", "removed_from_favorites_count": "{count, plural, other {Poistettu #}} suosikeista", + "removed_tagged_assets": "Poistettu tunniste {count, plural, one {# kohteesta} other {# kohteesta}}", "rename": "Nimeä uudelleen", "repair": "Korjaa", - "repair_no_results_message": "", + "repair_no_results_message": "Seuraamattomat ja puuttuvat tiedostot näkyvät täällä", "replace_with_upload": "Korvaa tiedostolla", "repository": "Tietovarasto", "require_password": "Vaadi salasana", @@ -911,6 +1083,7 @@ "reset_people_visibility": "Nollaa henkilöiden näkyvyysasetukset", "reset_settings_to_default": "", "reset_to_default": "Palauta oletusasetukset", + "resolve_duplicates": "Ratkaise kaksoiskappaleet", "resolved_all_duplicates": "Kaikki kaksoiskappaleet selvitetty", "restore": "Palauta", "restore_all": "Palauta kaikki", @@ -920,7 +1093,7 @@ "retry_upload": "Yritä latausta uudelleen", "review_duplicates": "Tarkastele kaksoiskappaleita", "role": "Rooli", - "role_editor": "Muokkain", + "role_editor": "Editori", "role_viewer": "Toistin", "save": "Tallenna", "saved_api_key": "API Key tallennettu", @@ -935,6 +1108,8 @@ "search": "Haku", "search_albums": "Etsi albumeita", "search_by_context": "Etsi kontekstin perusteella", + "search_by_filename": "Hae tiedostonimen tai -päätteen mukaan", + "search_by_filename_example": "esim. IMG_1234.JPG tai PNG", "search_camera_make": "Etsi kameramerkkiä...", "search_camera_model": "Etsi kameramallia...", "search_city": "Etsi kaupunkia...", @@ -942,9 +1117,12 @@ "search_for_existing_person": "Etsi olemassa olevaa henkilöä", "search_no_people": "Ei henkilöitä", "search_no_people_named": "Ei \"{name}\" nimisiä henkilöitä", + "search_options": "Hakuvaihtoehdot", "search_people": "Etsi ihmisiä", "search_places": "Etsi paikkoja", + "search_settings": "Hakuasetukset", "search_state": "Etsi tilaa...", + "search_tags": "Haku tageja...", "search_timezone": "Etsi aikavyöhyke...", "search_type": "Etsinnän tyyppi", "search_your_photos": "Etsi kuvia", @@ -953,6 +1131,7 @@ "see_all_people": "Näytä kaikki henkilöt", "select_album_cover": "Valitse albmin kansi", "select_all": "Valitse kaikki", + "select_all_duplicates": "Valitse kaikki kaksoiskappaleet", "select_avatar_color": "Valitse avatarin väri", "select_face": "Valitse kasvo", "select_featured_photo": "Valitse esittelykuva", @@ -967,6 +1146,7 @@ "send_message": "Lähetä viesti", "send_welcome_email": "Lähetä tervetuloviesti", "server": "Palvelin", + "server_offline": "Serveri Offline-tilassa", "server_online": "Palvelin on linjalla", "server_stats": "Palvelimen tilastot", "server_version": "Palvelimen versio", @@ -984,6 +1164,7 @@ "shared_by_user": "Käyttäjän {user} jakama", "shared_by_you": "Sinun jakamasi", "shared_from_partner": "{partner}n kuvia", + "shared_link_options": "Jaetun linkin vaihtoehdot", "shared_links": "Jaetut linkit", "shared_photos_and_videos_count": "{assetCount, plural, other {# jaettua kuvaa ja videota.}}", "shared_with_partner": "Jaa {partner} kanssa", @@ -992,6 +1173,7 @@ "sharing_sidebar_description": "Näytä jakamislinkki sivupalkissa", "shift_to_permanent_delete": "Paina ⇧ poistaaksesi median pysyvästi", "show_album_options": "Näytä albumin asetukset", + "show_albums": "Näytä albumit", "show_all_people": "Näytä kaikki henkilöt", "show_and_hide_people": "Näytä / piilota henkilöitä", "show_file_location": "Näytä tiedostosijainti", @@ -1006,11 +1188,17 @@ "show_person_options": "Näytä henkilöasetukset", "show_progress_bar": "Näytä eteneminen", "show_search_options": "Näytä hakuvaihtoehdot", + "show_supporter_badge": "Kannattajan merkki", + "show_supporter_badge_description": "Näytä kannattajan merkki", "shuffle": "Sekoita", + "sidebar": "Sivupalkki", + "sidebar_display_description": "Näytä linkki näkymään sivupalkissa", "sign_out": "Kirjaudu ulos", "sign_up": "Rekisteröidy", "size": "Koko", "skip_to_content": "Siirry sisältöön", + "skip_to_folders": "Siirry kansioihin", + "skip_to_tags": "Siirry tageihin", "slideshow": "Diaesitys", "slideshow_settings": "Diaesityksen asetukset", "sort_albums_by": "Järjestä albumit...", @@ -1022,6 +1210,8 @@ "sort_title": "Otsikko", "source": "Lähde", "stack": "Pinoa", + "stack_duplicates": "Pinoa kaksoiskappaleet", + "stack_select_one_photo": "Valitse yksi pääkuva pinolle", "stack_selected_photos": "Pinoa valitut kuvat", "stacked_assets_count": "Pinottu {count, plural, one {# media} other {# mediaa}}", "stacktrace": "Vianetsintätiedot", @@ -1041,6 +1231,14 @@ "sunrise_on_the_beach": "Auringonnousu rannalla", "swap_merge_direction": "Käännä yhdistämissuunta", "sync": "Synkronoi", + "tag": "Tagi", + "tag_assets": "Merkitse kohde", + "tag_created": "Luotu tunniste: {tag}", + "tag_feature_description": "Selaa valokuvia ja videoita, jotka on ryhmitelty loogisten tagiotsikoiden mukaan", + "tag_not_found_question": "Etkö löydä tunnistetta? Luo yksi tästä", + "tag_updated": "Päivitetty tunniste: {tag}", + "tagged_assets": "Tunnistettu {count, plural, one {# kohde} other {# kohdetta}}", + "tags": "Tagit", "template": "Template", "theme": "Teema", "theme_selection": "Teeman valinta", @@ -1052,14 +1250,15 @@ "to_change_password": "Vaihda salasana", "to_favorite": "Aseta suosikiksi", "to_login": "Kirjaudu sisään", + "to_parent": "Siirry vanhempaan", "to_trash": "Roskakoriin", "toggle_settings": "Määritä asetukset", - "toggle_theme": "Aseta teema", + "toggle_theme": "Aseta tumma teema", "toggle_visibility": "Aseta näkyvyys", "total_usage": "Käyttö yhteensä", "trash": "Roskakori", "trash_all": "Vie kaikki roskakoriin", - "trash_count": "Vie {count} roskakoriin", + "trash_count": "Roskakori {count, number}", "trash_delete_asset": "Poista / vie roskakoriin", "trash_no_results_message": "Roskakorissa olevat kuvat ja videot näytetään täällä.", "trashed_items_will_be_permanently_deleted_after": "Roskakorin kohteet poistetaan pysyvästi {days, plural, one {# päivän} other {# päivän}} päästä.", @@ -1073,13 +1272,17 @@ "unknown_album": "", "unknown_year": "Tuntematon vuosi", "unlimited": "Rajoittamaton", + "unlink_motion_video": "Poista liikevideon linkitys", "unlink_oauth": "Poista OAuth-linkitys", "unlinked_oauth_account": "Linkittämätön OAuth-tili", "unnamed_album": "Nimetön albumi", + "unnamed_album_delete_confirmation": "Haluatko varmasti poistaa tämän albumin?", "unnamed_share": "Nimetön jako", "unsaved_change": "Tallentamaton muutos", "unselect_all": "Poista valinnat", + "unselect_all_duplicates": "Poista kaikkien kaksoiskappaleiden valinta", "unstack": "Pura pino", + "unstacked_assets_count": "Poistettu pinosta {count, plural, one {# kohde} other {# kohdetta}}", "untracked_files": "Tiedostot joita ei seurata", "untracked_files_decription": "Järjestelmä ei seuraa näitä tiedostoja. Ne voivat johtua epäonnistuneista siirroista, keskeytyneistä latauksista, tai ovat jääneet ohjelmavian seurauksena", "up_next": "Seuraavaksi", @@ -1087,7 +1290,7 @@ "upload": "Siirrä palvelimelle", "upload_concurrency": "Latausten samanaikaisuus", "upload_errors": "Lataus valmistui {count, plural, one {# virheen} other {# virheen}} kanssa. Päivitä sivu nähdäksesi ladatut tiedot.", - "upload_progress": "{remaining} jäljellä - {processed}/{total} käsitelty", + "upload_progress": "Jäljellä {remaining, number} - Käsitelty {processed, number}/{total, number}", "upload_skipped_duplicates": "Ohitettiin {count, plural, one {# kaksoiskappale} other {# kaksoiskappaletta}}", "upload_status_duplicates": "Kaksoiskappaleet", "upload_status_errors": "Virheet", @@ -1099,6 +1302,8 @@ "user": "Käyttäjä", "user_id": "Käyttäjän ID", "user_liked": "{user} tykkäsi {type, select, photo {kuvasta} video {videosta} asset {mediasta} other {tästä}}", + "user_purchase_settings": "Osta", + "user_purchase_settings_description": "Hallitse ostostasi", "user_role_set": "Tee käyttäjästä {user} {role}", "user_usage_detail": "Käyttäjän käytön tiedot", "username": "Käyttäjänimi", diff --git a/web/src/lib/i18n/fr.json b/web/src/lib/i18n/fr.json index 9628573b0d30b..b86251e039271 100644 --- a/web/src/lib/i18n/fr.json +++ b/web/src/lib/i18n/fr.json @@ -41,6 +41,7 @@ "confirm_email_below": "Pour confirmer, tapez « {email} » ci-dessous", "confirm_reprocess_all_faces": "Êtes-vous sûr de vouloir retraiter tous les visages ? Cela effacera également les personnes déjà identifiées.", "confirm_user_password_reset": "Êtes-vous sûr de vouloir réinitialiser le mot de passe de {user} ?", + "create_job": "Créer une tâche", "crontab_guru": "Générateur de règles Cron", "disable_login": "Désactiver la connexion", "disabled": "Désactivé", @@ -70,6 +71,7 @@ "image_thumbnail_resolution": "Résolution des miniatures", "image_thumbnail_resolution_description": "Utilisée lors du visionnage de groupes de photos (vue principale, albums, etc.). Une résolution plus élevée préserve davantage de détails, mais est plus longue à encoder, produit des fichiers plus lourds, et peut réduire la réactivité de l'application.", "job_concurrency": "{job} : nombre de tâches simultanées", + "job_created": "Tâche créée", "job_not_concurrency_safe": "Cette tâche ne peut pas être exécutée en multitâche de façon sûre.", "job_settings": "Paramètres des tâches", "job_settings_description": "Gestion des tâches simultanées", @@ -152,7 +154,7 @@ "note_cannot_be_changed_later": "REMARQUE : Il n'est pas possible de modifier ce paramètre ultérieurement !", "note_unlimited_quota": "Note : saisir 0 pour un quota illimité", "notification_email_from_address": "Depuis l'adresse", - "notification_email_from_address_description": "Adresse courriel de l'expéditeur, par exemple : « Serveur de photos Immich  »", + "notification_email_from_address_description": "Adresse courriel de l'expéditeur, par exemple : « Serveur de photos Immich  »", "notification_email_host_description": "Hôte du serveur de messagerie électronique (par exemple, smtp.immich.app)", "notification_email_ignore_certificate_errors": "Ignorer les erreurs de certificat", "notification_email_ignore_certificate_errors_description": "Ignorer les erreurs de validation du certificat TLS (non recommandé)", @@ -198,6 +200,7 @@ "password_settings": "Connexion par mot de passe", "password_settings_description": "Gérer les paramètres de connexion par mot de passe", "paths_validated_successfully": "Tous les chemins ont été validés avec succès", + "person_cleanup_job": "Nettoyage des personnes", "quota_size_gib": "Taille du quota (Go)", "refreshing_all_libraries": "Actualisation de toutes les bibliothèques", "registration": "Enregistrement de l'administrateur", @@ -211,6 +214,7 @@ "reset_settings_to_recent_saved": "Paramètres réinitialisés avec les derniers paramètres enregistrés", "scanning_library_for_changed_files": "Recherche de fichiers modifiés dans la bibliothèque", "scanning_library_for_new_files": "Recherche de nouveaux fichiers dans la bibliothèque", + "search_jobs": "Recherche des tâches ...", "send_welcome_email": "Envoyer un courriel de bienvenue", "server_external_domain_settings": "Domaine externe", "server_external_domain_settings_description": "Nom de domaine pour les liens partagés publics, y compris http(s)://", @@ -238,6 +242,7 @@ "storage_template_settings_description": "Gérer la structure des dossiers et le nom des fichiers du média envoyé", "storage_template_user_label": "{label} est l'étiquette de stockage de l'utilisateur", "system_settings": "Paramètres du système", + "tag_cleanup_job": "Nettoyage des étiquettes", "theme_custom_css_settings": "CSS personnalisé", "theme_custom_css_settings_description": "Les feuilles de style en cascade (CSS) permettent de personnaliser l'apparence d'Immich.", "theme_settings": "Paramètres du thème", @@ -312,6 +317,7 @@ "trash_settings_description": "Gérer les paramètres de la corbeille", "untracked_files": "Fichiers non suivis", "untracked_files_description": "Ces fichiers ne sont pas suivis par l'application. Ils peuvent être le résultat d'erreurs de déplacement, d'envois interrompus, ou d'abandons en raison d'un bug", + "user_cleanup_job": "Nettoyage des utilisateurs", "user_delete_delay": "La suppression définitive du compte et des médias de {user} sera programmée dans {delay, plural, one {# jour} other {# jours}}.", "user_delete_delay_settings": "Délai de suppression", "user_delete_delay_settings_description": "Nombre de jours après la validation pour supprimer définitivement le compte et les médias d'un utilisateur. La suppression des utilisateurs se lance à minuit. Les modifications apportées à ce paramètre seront pris en compte lors de la prochaine exécution.", @@ -488,8 +494,8 @@ "create_new_person": "Créer une nouvelle personne", "create_new_person_hint": "Attribuer les médias sélectionnés à une nouvelle personne", "create_new_user": "Créer un nouvel utilisateur", - "create_tag": "Créer un tag", - "create_tag_description": "Créer un nouveau tag. Pour les tags imbriqués, veuillez entrer le chemin complet du tag, y compris les \"/\" avant.", + "create_tag": "Créer une étiquette", + "create_tag_description": "Créer une nouvelle étiquette. Pour les étiquettes imbriquées, veuillez entrer le chemin complet de l'étiquette, y compris les caractères \"/\".", "create_user": "Créer un utilisateur", "created": "Créé", "current_device": "Appareil actuel", @@ -513,8 +519,8 @@ "delete_library": "Supprimer la bibliothèque", "delete_link": "Supprimer le lien", "delete_shared_link": "Supprimer le lien partagé", - "delete_tag": "Supprimer le tag", - "delete_tag_confirmation_prompt": "Êtes-vous sûr de vouloir supprimer le tag {tagName} ?", + "delete_tag": "Supprimer l'étiquette", + "delete_tag_confirmation_prompt": "Êtes-vous sûr de vouloir supprimer l'étiquette {tagName} ?", "delete_user": "Supprimer l'utilisateur", "deleted_shared_link": "Lien partagé supprimé", "description": "Description", @@ -563,7 +569,7 @@ "edit_location": "Modifier la localisation", "edit_name": "Modifier le nom", "edit_people": "Modifier les personnes", - "edit_tag": "Modifier le tag", + "edit_tag": "Modifier l'étiquette", "edit_title": "Modifier le title", "edit_user": "Modifier l'utilisateur", "edited": "Modifié", @@ -1141,8 +1147,9 @@ "search_options": "Rechercher une option", "search_people": "Rechercher une personne", "search_places": "Rechercher un lieu", + "search_settings": "Paramètres de recherche", "search_state": "Rechercher par état/région...", - "search_tags": "Recherche de tags...", + "search_tags": "Recherche d'étiquettes...", "search_timezone": "Rechercher par fuseau horaire...", "search_type": "Rechercher par type", "search_your_photos": "Rechercher vos photos", @@ -1218,7 +1225,7 @@ "size": "Taille", "skip_to_content": "Passer", "skip_to_folders": "Passer vers les dossiers", - "skip_to_tags": "Passer vers les tags", + "skip_to_tags": "Passer vers les étiquettes", "slideshow": "Diaporama", "slideshow_settings": "Paramètres du diaporama", "sort_albums_by": "Trier les albums par...", @@ -1253,12 +1260,12 @@ "sync": "Synchroniser", "tag": "Tag", "tag_assets": "Taguer les médias", - "tag_created": "Tag créé : {tag}", + "tag_created": "Étiquette créée : {tag}", "tag_feature_description": "Parcourir les photos et vidéos groupées par thèmes logiques", - "tag_not_found_question": "Vous ne trouvez pas un tag ? Créez-en un ici", - "tag_updated": "Tag mis à jour : {tag}", + "tag_not_found_question": "Vous ne trouvez pas une étiquette ? Créez-en une ici", + "tag_updated": "Étiquette mise à jour : {tag}", "tagged_assets": "Tag ajouté à {count, plural, one {# média} other {# médias}}", - "tags": "Tags", + "tags": "Étiquettes", "template": "Modèle", "theme": "Thème", "theme_selection": "Sélection du thème", diff --git a/web/src/lib/i18n/he.json b/web/src/lib/i18n/he.json index 05eab7a804519..62c30461a5854 100644 --- a/web/src/lib/i18n/he.json +++ b/web/src/lib/i18n/he.json @@ -41,6 +41,7 @@ "confirm_email_below": "כדי לאשר, יש להקליד \"{email}\" למטה", "confirm_reprocess_all_faces": "האם את/ה בטוח/ה שברצונך לעבד מחדש את כל הפנים? זה גם ינקה אנשים בעלי שם.", "confirm_user_password_reset": "האם את/ה בטוח/ה שברצונך לאפס את הסיסמה של המשתמש {user}?", + "create_job": "צור עבודה", "crontab_guru": "Crontab Guru", "disable_login": "השבת כניסה", "disabled": "מושבת", @@ -70,6 +71,7 @@ "image_thumbnail_resolution": "רזולוציית תמונה ממוזערת", "image_thumbnail_resolution_description": "משמש בעת צפייה בקבוצות של תמונות (ציר זמן ראשי, תצוגת אלבום וכו'). רזולוציות גבוהות יותר יכולות לשמר פירוט רב יותר אך לוקחות יותר זמן לקידוד, יש להן גדלי קבצים גדולים יותר, ויכולות להפחית את תגובתיות היישום.", "job_concurrency": "בו-זמניות של {job}", + "job_created": "עבודה נוצרה", "job_not_concurrency_safe": "משימה זו אינה בטוחה במקביל.", "job_settings": "הגדרות משימה", "job_settings_description": "ניהול בו-זמניות של משימה", @@ -198,6 +200,7 @@ "password_settings": "סיסמת התחברות", "password_settings_description": "נהל הגדרות סיסמת התחברות", "paths_validated_successfully": "כל הנתיבים אומתו בהצלחה", + "person_cleanup_job": "ניקוי אדם", "quota_size_gib": "גודל מכסה (GiB)", "refreshing_all_libraries": "מרענן את כל הספריות", "registration": "רישום מנהל מערכת", @@ -211,6 +214,7 @@ "reset_settings_to_recent_saved": "אפס הגדרות להגדרות שנשמרו לאחרונה", "scanning_library_for_changed_files": "סורק ספרייה לאיתור קבצים שהשתנו", "scanning_library_for_new_files": "סורק ספרייה לאיתור קבצים חדשים", + "search_jobs": "חיפוש עבודות...", "send_welcome_email": "שלח דוא\"ל ברוכים הבאים", "server_external_domain_settings": "דומיין חיצוני", "server_external_domain_settings_description": "דומיין עבור קישורים משותפים ציבוריים, כולל http(s)://", @@ -238,6 +242,7 @@ "storage_template_settings_description": "נהל את מבנה התיקיות ואת שם הקובץ של נכס ההעלאה", "storage_template_user_label": "{label} היא תווית האחסון של המשתמש", "system_settings": "הגדרות מערכת", + "tag_cleanup_job": "ניקוי תגים", "theme_custom_css_settings": "CSS בהתאמה אישית", "theme_custom_css_settings_description": "גיליונות סגנון מדורגים (CSS) מאפשרים התאמה אישית של העיצוב של Immich.", "theme_settings": "הגדרות ערכת נושא", @@ -312,6 +317,7 @@ "trash_settings_description": "נהל את הגדרות האשפה", "untracked_files": "קבצים ללא מעקב", "untracked_files_description": "קבצים אלה אינם נמצאים במעקב של היישום. הם יכולים להיות תוצאות של העברות כושלות, העלאות שנקטעו, או שנותרו מאחור בגלל שיבוש בתוכנה", + "user_cleanup_job": "ניקוי משתמשים", "user_delete_delay": "החשבון והנכסים של {user} יתוזמנו למחיקה לצמיתות בעוד {delay, plural, one {יום #} other {# ימים}}.", "user_delete_delay_settings": "עיכוב מחיקה", "user_delete_delay_settings_description": "מספר הימים לאחר ההסרה עד מחיקה לצמיתות של החשבון והנכסים של המשתמש. משימת מחיקת המשתמש פועלת בחצות כדי לבדוק אם יש משתמשים שמוכנים למחיקה. שינויים בהגדרה זו יוערכו בביצוע הבא.", @@ -1141,6 +1147,7 @@ "search_options": "אפשרויות חיפוש", "search_people": "חפש אנשים", "search_places": "חפש מקומות", + "search_settings": "הגדרות חיפוש", "search_state": "חפש מדינה...", "search_tags": "חיפוש תגים...", "search_timezone": "חפש אזור זמן...", diff --git a/web/src/lib/i18n/hr.json b/web/src/lib/i18n/hr.json index 954eeff202d6a..4247de3c42777 100644 --- a/web/src/lib/i18n/hr.json +++ b/web/src/lib/i18n/hr.json @@ -41,6 +41,7 @@ "confirm_email_below": "Za potvrdu upišite \"{email}\" ispod", "confirm_reprocess_all_faces": "Jeste li sigurni da želite ponovno obraditi sva lica? Ovo će također obrisati imenovane osobe.", "confirm_user_password_reset": "Jeste li sigurni da želite poništiti lozinku korisnika {user}?", + "create_job": "Izradi zadatak", "crontab_guru": "Crontab Guru", "disable_login": "Onemogući prijavu", "duplicate_detection_job_description": "Pokrenite strojno učenje na materijalima kako biste otkrili slične slike. Oslanja se na Pametno Pretraživanje", @@ -69,6 +70,7 @@ "image_thumbnail_resolution": "Razlučivost sličica", "image_thumbnail_resolution_description": "Koristi se prilikom pregledavanja grupa fotografija (glavna vremenska traka, prikaz albuma itd.). Veće razlučivosti mogu sačuvati više detalja, ali trebaju dulje za kodiranje, imaju veće veličine datoteka i mogu smanjiti odaziv aplikacije.", "job_concurrency": "{job} istovremenost", + "job_created": "Zadatak je kreiran", "job_not_concurrency_safe": "Ovaj posao nije siguran za istovremenost.", "job_settings": "Postavke posla", "job_settings_description": "Upravljajte istovremenošću poslova", @@ -91,8 +93,8 @@ "library_watching_settings": "Gledanje biblioteke (EKSPERIMENTALNO)", "library_watching_settings_description": "Automatsko praćenje promijenjenih datoteke", "logging_enable_description": "Omogući zapisivanje", - "logging_level_description": "Kada je omogućeno, koju razinu zapisavanje koristiti.", - "logging_settings": "Zapisavanje", + "logging_level_description": "Kada je omogućeno, koju razinu zapisivanja koristiti.", + "logging_settings": "Zapisivanje", "machine_learning_clip_model": "CLIP model", "machine_learning_clip_model_description": "Naziv CLIP modela navedenog ovdje. Imajte na umu da morate ponovno pokrenuti posao 'Pametno Pretraživanje' za sve slike nakon promjene modela.", "machine_learning_duplicate_detection": "Detekcija Duplikata", @@ -138,7 +140,7 @@ "map_settings_description": "Upravljanje postavkama karte", "map_style_description": "URL na style.json temu karte", "metadata_extraction_job": "Izdvoj metapodatke", - "metadata_extraction_job_description": "Izdvojite podatke o metapodacima iz svakog sredstva, kao što su GPS i rezolucija", + "metadata_extraction_job_description": "Izdvojite podatke o metapodacima iz svakog sredstva, kao što su GPS, lica i rezolucija", "metadata_faces_import_setting": "Omogući uvoz lica", "metadata_faces_import_setting_description": "Uvezite lica iz EXIF podataka slike i sidecar datoteka", "metadata_settings": "Postavke Metapodataka", @@ -197,6 +199,7 @@ "password_settings": "Prijava zaporkom", "password_settings_description": "Upravljanje postavkama za prijavu zaporkom", "paths_validated_successfully": "Sve su putanje uspješno potvrđene", + "person_cleanup_job": "Čišćenje lica", "quota_size_gib": "Veličina kvote (GiB)", "refreshing_all_libraries": "Osvježavanje svih biblioteka", "registration": "Registracija administratora", @@ -210,6 +213,7 @@ "reset_settings_to_recent_saved": "Resetirajte postavke na nedavno spremljene postavke", "scanning_library_for_changed_files": "Skeniranje biblioteke za promijenjene datoteke", "scanning_library_for_new_files": "Skeniranje biblioteke za nove datoteke", + "search_jobs": "Traži zadatke…", "send_welcome_email": "Pošaljite email dobrodošlice", "server_external_domain_settings": "Vanjska domena", "server_external_domain_settings_description": "Domena za javno dijeljene linkove, uključujući http(s)://", @@ -237,6 +241,7 @@ "storage_template_settings_description": "Upravljajte strukturom mape i nazivom datoteke učitanog sredstva", "storage_template_user_label": "{label} je korisnička oznaka za pohranu", "system_settings": "Postavke Sustava", + "tag_cleanup_job": "Čišćenje oznaka", "theme_custom_css_settings": "Prilagođeni CSS", "theme_custom_css_settings_description": "Kaskadni listovi stilova (CSS) omogućuju prilagođavanje dizajna Immicha.", "theme_settings": "Postavke tema", @@ -310,6 +315,7 @@ "trash_settings_description": "Upravljanje postavkama smeća", "untracked_files": "Nepraćene datoteke", "untracked_files_description": "Aplikacija ne prati ove datoteke. Mogu biti rezultat neuspjelih premještanja, prekinutih prijenosa ili izostale zbog pogreške", + "user_cleanup_job": "Čišćenje korisnika", "user_delete_delay": "Račun i sredstva korisnika {user} bit će zakazani za trajno brisanje za {delay, plural, one {# day} other {# days}}.", "user_delete_delay_settings": "Brisanje odgode", "user_delete_delay_settings_description": "Broj dana nakon uklanjanja za trajno brisanje korisničkog računa i imovine. Posao brisanja korisnika pokreće se u ponoć kako bi se provjerili korisnici koji su spremni za brisanje. Promjene ove postavke bit će procijenjene pri sljedećem izvršavanju.", @@ -449,7 +455,7 @@ "clear_value": "Očisti vrijednost", "clockwise": "U smjeru kazaljke na satu", "close": "Zatvori", - "collapse": "Sažimanje", + "collapse": "Sažmi", "collapse_all": "Sažmi sve", "color": "Boja", "color_theme": "Tema boja", @@ -918,98 +924,179 @@ "owner": "Vlasnik", "partner": "Partner", "partner_can_access": "{partner} može pristupiti", - "partner_can_access_assets": "", - "partner_can_access_location": "", - "partner_sharing": "", - "partners": "", - "password": "", - "password_does_not_match": "", - "password_required": "", - "password_reset_success": "", + "partner_can_access_assets": "Sve vaše fotografije i videi osim onih u arhivi i smeću", + "partner_can_access_location": "Mjesto otkuda je slika otkinuta", + "partner_sharing": "Dijeljenje s partnerom", + "partners": "Partneri", + "password": "Zaporka", + "password_does_not_match": "Zaporka se ne podudara", + "password_required": "Zaporka je obavezna", + "password_reset_success": "Reset zaporke je uspješan", "past_durations": { - "days": "", - "hours": "", - "years": "" + "days": "{days, plural, one {Prošli dan} few {Prošlih # dana} other {Prošlih # dana}}", + "hours": "{hours, plural, one {Prošli sat} few {Prošla # sata} other {Prošlih # sati}}", + "years": "{years, plural, one {Prošle godine} few {Prošle # godine} other {Prošlih # godina}}" }, - "path": "", - "pattern": "", - "pause": "", - "pause_memories": "", - "paused": "", - "pending": "", - "people": "", - "people_sidebar_description": "", - "permanent_deletion_warning": "", - "permanent_deletion_warning_setting_description": "", - "permanently_delete": "", - "permanently_deleted_asset": "", - "photos": "", - "photos_count": "", - "photos_from_previous_years": "", - "pick_a_location": "", - "place": "", - "places": "", - "play": "", - "play_memories": "", - "play_motion_photo": "", - "play_or_pause_video": "", - "port": "", - "preset": "", - "preview": "", - "previous": "", - "previous_memory": "", - "previous_or_next_photo": "", - "primary": "", - "profile_picture_set": "", - "public_share": "", - "reaction_options": "", - "read_changelog": "", - "recent": "", - "recent_searches": "", - "refresh": "", - "refreshed": "", - "refreshes_every_file": "", - "remove": "", - "remove_from_album": "", - "remove_from_favorites": "", - "remove_from_shared_link": "", - "remove_offline_files": "", - "removed_api_key": "", - "rename": "", - "repair": "", - "repair_no_results_message": "", - "replace_with_upload": "", - "require_password": "", - "require_user_to_change_password_on_first_login": "", - "reset": "", - "reset_password": "", - "reset_people_visibility": "", - "restore": "", - "restore_all": "", - "restore_user": "", - "resume": "", - "retry_upload": "", - "review_duplicates": "", - "role": "", - "save": "", - "saved_api_key": "", - "saved_profile": "", - "saved_settings": "", - "say_something": "", - "scan_all_libraries": "", - "scan_all_library_files": "", - "scan_new_library_files": "", - "scan_settings": "", - "search": "", - "search_albums": "", - "search_by_context": "", - "search_camera_make": "", - "search_camera_model": "", - "search_city": "", - "search_country": "", - "search_for_existing_person": "", - "search_people": "", - "search_places": "", + "path": "Putanja", + "pattern": "Uzorak", + "pause": "Pauza", + "pause_memories": "Pauziraj sjećanja", + "paused": "Pauzirano", + "pending": "Na čekanju", + "people": "Ljudi", + "people_edits_count": "Izmjenjeno {count, plural, one {# osoba} other {# osobe}}", + "people_feature_description": "Pregledavanje fotografija i videozapisa grupiranih po osobama", + "people_sidebar_description": "Prikažite poveznicu na Osobe na bočnoj traci", + "permanent_deletion_warning": "Upozorenje za nepovratno brisanje", + "permanent_deletion_warning_setting_description": "Prikaži upozorenje prilikom trajnog brisanja sredstava", + "permanently_delete": "Nepovratno obriši", + "permanently_delete_assets_count": "Trajno izbriši {count, plural, one {datoteku} other {datoteke}}", + "permanently_delete_assets_prompt": "Da li ste sigurni da želite trajni izbrisati {count, plural, one {ovu datoteku?} other {ove # datoteke?}}Ovo će ih također ukloniti {count, plural, one {iz njihovog} other {iz njihovih}} albuma.", + "permanently_deleted_asset": "Trajno izbrisano sredstvo", + "permanently_deleted_assets_count": "Trajno izbrisano {count, plural, one {# datoteka} other {# datoteke}}", + "person": "Osoba", + "person_hidden": "{name}{hidden, select, true { (skriveno)} other {}}", + "photo_shared_all_users": "Čini se da ste svoje fotografije podijelili sa svim korisnicima ili nemate nijednog korisnika s kojim biste ih podijelili.", + "photos": "Fotografije", + "photos_and_videos": "Fotografije i videozapisi", + "photos_count": "{count, plural, one {{count, number} fotografija} few {{count, number} fotografije} other {{count, number} fotografija}}", + "photos_from_previous_years": "Fotografije iz prethodnih godina", + "pick_a_location": "Odaberite lokaciju", + "place": "Mjesto", + "places": "Mjesta", + "play": "Pokreni", + "play_memories": "Pokreni sjećanja", + "play_motion_photo": "Reproduciraj Pokretnu fotografiju", + "play_or_pause_video": "Reproducirajte ili pauzirajte video", + "port": "Port", + "preset": "Unaprijed postavljeno", + "preview": "Pregled", + "previous": "Prethodno", + "previous_memory": "Prethodno sjećanje", + "previous_or_next_photo": "Prethodna ili sljedeća fotografija", + "primary": "Primarna (Primary)", + "privacy": "Privatnost", + "profile_image_of_user": "Profilna slika korisnika {user}", + "profile_picture_set": "Profilna slika postavljena.", + "public_album": "Javni album", + "public_share": "Javno dijeljenje", + "purchase_account_info": "Podržava softver", + "purchase_activated_subtitle": "Hvala što podržavate Immich i softver otvorenog koda", + "purchase_activated_time": "Aktivirano {date, date}", + "purchase_activated_title": "Vaš ključ je uspješno aktiviran", + "purchase_button_activate": "Aktiviraj", + "purchase_button_buy": "Kupi", + "purchase_button_buy_immich": "Kupi Immich", + "purchase_button_never_show_again": "Nikad više ne prikazuj", + "purchase_button_reminder": "Podsjeti me za 30 dana", + "purchase_button_remove_key": "Ukloni ključ", + "purchase_button_select": "Odaberite", + "purchase_failed_activation": "Aktivacija nije uspjela! Provjerite svoju e-poštu za točan ključ proizvoda!", + "purchase_individual_description_1": "Za pojedinca", + "purchase_individual_description_2": "Status podržavanja", + "purchase_individual_title": "Pojedinačna licenca", + "purchase_input_suggestion": "Imate ključ proizvoda? Unesite ključ ispod", + "purchase_license_subtitle": "Kupite Immich kako biste podržali kontinuirani razvoj usluge", + "purchase_lifetime_description": "Doživotna kupnja", + "purchase_option_title": "MOGUĆNOSTI KUPNJE", + "purchase_panel_info_1": "Za izgradnju Immicha potrebno je puno vremena i truda, a mi imamo inženjere koji rade na tome s punim radnim vremenom kako bismo ga učinili što boljim. Naša je misija da softver otvorenog koda i etička poslovna praksa postanu održivi izvor prihoda za programere i da se stvori ekosustav koji poštuje privatnost sa stvarnim alternativama eksploatacijskim uslugama u oblaku.", + "purchase_panel_info_2": "Budući da se obvezujemo da nećemo dodavati dodatne pretplate, ova vam kupnja neće dodijeliti nikakve dodatne značajke u Immichu. Oslanjamo se na korisnike poput vas da podržimo stalni razvoj Immicha.", + "purchase_panel_title": "Podrži projekt", + "purchase_per_server": "Po serveru", + "purchase_per_user": "Po korisniku", + "purchase_remove_product_key": "Ukloni ključ proizvoda", + "purchase_remove_product_key_prompt": "Jeste li sigurni da želite ukloniti ključ proizvoda?", + "purchase_remove_server_product_key": "Uklonite ključ proizvoda poslužitelja (Server)", + "purchase_remove_server_product_key_prompt": "Jeste li sigurni da želite ukloniti ključ proizvoda poslužitelja (Server)?", + "purchase_server_description_1": "Za cijeli server", + "purchase_server_description_2": "Status podupiratelja", + "purchase_server_title": "Poslužitelj (Server)", + "purchase_settings_server_activated": "Ključem proizvoda poslužitelja upravlja administrator", + "rating": "Broj zvjezdica", + "rating_clear": "Obriši ocjenu", + "rating_count": "{count, plural, one {# zvijezda} other {# zvijezde}}", + "rating_description": "Prikaži EXIF ocjenu na info ploči", + "reaction_options": "Mogućnosti reakcije", + "read_changelog": "Pročitajte Dnevnik promjena", + "reassign": "Ponovno dodijeli", + "reassigned_assets_to_existing_person": "Ponovo dodijeljeno{count, plural, one {# datoteka} other {# datoteke}} postojećoj {name, select, null {osobi} other {{name}}}", + "reassigned_assets_to_new_person": "Ponovo dodijeljeno {count, plural, one {# datoteka} other {# datoteke}} novoj osobi", + "reassing_hint": "Dodijelite odabrane datoteke postojećoj osobi", + "recent": "Nedavno", + "recent_searches": "Nedavne pretrage", + "refresh": "Osvježi", + "refresh_encoded_videos": "Osvježite kodirane videozapise", + "refresh_metadata": "Osvježi metapodatke", + "refresh_thumbnails": "Osvježi sličice", + "refreshed": "Osvježeno", + "refreshes_every_file": "Osvježava svaku datoteku", + "refreshing_encoded_video": "Osvježavanje kodiranog videa", + "refreshing_metadata": "Osvježavanje metapodataka", + "regenerating_thumbnails": "Obnavljanje sličica", + "remove": "Ukloni", + "remove_assets_album_confirmation": "Jeste li sigurni da želite ukloniti {count, plural, one {# datoteku} other {# datoteke}} iz albuma?", + "remove_assets_shared_link_confirmation": "Jeste li sigurni da želite ukloniti {count, plural, one {# datoteku} other {# datoteke}} iz ove dijeljene veze?", + "remove_assets_title": "Ukloniti datoteke?", + "remove_custom_date_range": "Ukloni prilagođeni datumski raspon", + "remove_from_album": "Ukloni iz albuma", + "remove_from_favorites": "Ukloni iz favorita", + "remove_from_shared_link": "Ukloni iz dijeljene poveznice", + "remove_offline_files": "Ukloni izvanmrežne datoteke", + "remove_user": "Ukloni korisnika", + "removed_api_key": "Uklonjen API ključ: {name}", + "removed_from_archive": "Uklonjeno iz arhive", + "removed_from_favorites": "Uklonjeno iz favorita", + "removed_from_favorites_count": "{count, plural, other {Uklonjeno #}} iz omiljenih", + "removed_tagged_assets": "Uklonjena oznaka iz {count, plural, one {# datoteke} other {# datoteka}}", + "rename": "Preimenuj", + "repair": "Popravi", + "repair_no_results_message": "Nepraćene datoteke i datoteke koje nedostaju pojavit će se ovdje", + "replace_with_upload": "Zamijeni s prijenosom", + "repository": "Spremište (Repository)", + "require_password": "Zahtijevaj lozinku", + "require_user_to_change_password_on_first_login": "Zahtijevajte od korisnika promjenu lozinke pri prvoj prijavi", + "reset": "Reset", + "reset_password": "Resetiraj lozinku", + "reset_people_visibility": "Poništi vidljivost ljudi", + "reset_to_default": "Vrati na zadano", + "resolve_duplicates": "Riješite duplikate", + "resolved_all_duplicates": "Razriješi sve duplikate", + "restore": "Oporavi", + "restore_all": "Oporavi sve", + "restore_user": "Vrati korisnika", + "restored_asset": "Obnovljena datoteka", + "resume": "Nastavi", + "retry_upload": "Ponovi prijenos", + "review_duplicates": "Pregledajte duplikate", + "role": "Uloga", + "role_editor": "Urednik", + "role_viewer": "Gledatelj", + "save": "Spremi", + "saved_api_key": "Spremljen API ključ", + "saved_profile": "Spremljen profil", + "saved_settings": "Spremljene postavke", + "say_something": "Reci nešto", + "scan_all_libraries": "Skeniraj sve Knjižnice", + "scan_all_library_files": "Ponovno skenirajte sve datoteke Knjižnice", + "scan_new_library_files": "Skeniraj nove datoteke Knjižnice", + "scan_settings": "Postavke skeniranja", + "scanning_for_album": "Skeniranje albuma...", + "search": "Pretraživanje", + "search_albums": "Traži albume", + "search_by_context": "Pretraživanje po kontekstu", + "search_by_filename": "Pretražujte prema nazivu datoteke ili ekstenziji", + "search_by_filename_example": "npr. IMG_1234.JPG ili PNG", + "search_camera_make": "Pretražite marku kamere...", + "search_camera_model": "Pretražite model kamere...", + "search_city": "Pretražite grad...", + "search_country": "Pretražite državu...", + "search_for_existing_person": "Potražite postojeću osobu", + "search_no_people": "Nema ljudi", + "search_no_people_named": "Nema osoba s imenom \"{name}\"", + "search_options": "Opcije pretraživanja", + "search_people": "Traži ljude", + "search_places": "Traži mjesta", + "search_settings": "Postavke pretraživanja", "search_state": "", "search_timezone": "", "search_type": "", diff --git a/web/src/lib/i18n/hu.json b/web/src/lib/i18n/hu.json index 249b663a773ef..851171bc9974d 100644 --- a/web/src/lib/i18n/hu.json +++ b/web/src/lib/i18n/hu.json @@ -34,13 +34,14 @@ "authentication_settings_reenable": "Az újbóli engedélyezéshez használjon egySzerver Parancsot.", "background_task_job": "Háttérfolyamatok", "check_all": "Összes Kipiálása", - "cleared_jobs": "{job} munkák kitörölve", + "cleared_jobs": "{job}: feladatok törölve", "config_set_by_file": "A konfigurációt jelenleg egy konfigurációs fájl állítja be", "confirm_delete_library": "Biztosan ki szeretné törölni a {library} képtárat?", "confirm_delete_library_assets": "Biztosan kitörli ezt a képtárat? Ez kitöröl {count, plural, one {#} other {#}} benne lévő fájlt az Immichből és nem visszavonható. A fájlok a lemezen maradnak.", "confirm_email_below": "A megerősítéshez írja \"{email}\"-t alább", "confirm_reprocess_all_faces": "Biztos benne, hogy újra szeretné feldolgozni az összes arcot? Ez a megnevezett személyeket is törli.", "confirm_user_password_reset": "Biztosan vissza szeretné állítani {user} jelszavát?", + "create_job": "Feladat létrehozása", "crontab_guru": "Crontab Guru", "disable_login": "Belépés letiltása", "disabled": "Letiltva", @@ -70,6 +71,7 @@ "image_thumbnail_resolution": "Bélyegkép felbontás", "image_thumbnail_resolution_description": "Képek csoportosított nézetekor használatos (idővonal, album nézet stb). Nagyobb felbontás esetén a kép részletgazdagabb marad, de tovább tart elkészíteni, nagyobb fájl méretet eredményes, és ronthatja az alkalmazás reagálását.", "job_concurrency": "{job} párhuzamosság", + "job_created": "Feladat létrehozva", "job_not_concurrency_safe": "Ez a feladat nem párhuzamosság-biztos.", "job_settings": "Feladat beállítások", "job_settings_description": "Feladatok párhuzamosságának beállítása", @@ -96,8 +98,8 @@ "logging_settings": "Naplózás", "machine_learning_clip_model": "CLIP modell", "machine_learning_clip_model_description": "Egy CLIP modell neve az itt felsoroltak közül. A modell megváltoztatása után újra kell futtatni az 'Okos Keresés' munkát minden képre.", - "machine_learning_duplicate_detection": "Másolatok Észlelése", - "machine_learning_duplicate_detection_enabled": "Másolatkeresés engedélyezése", + "machine_learning_duplicate_detection": "Duplikáltak Észlelése", + "machine_learning_duplicate_detection_enabled": "Duplikáltak keresésének engedélyezése", "machine_learning_duplicate_detection_enabled_description": "Ha ki van kapcsolva, a pontosan azonos fájlok akkor sem lesznek duplikálva.", "machine_learning_duplicate_detection_setting_description": "CLIP beágyazások használata a valószínű másolatok kereséséhez", "machine_learning_enabled": "Gépi tanulás engedélyezése", @@ -107,7 +109,7 @@ "machine_learning_facial_recognition_model": "Arcfelismerési modell", "machine_learning_facial_recognition_model_description": "A modellek méret szerint csökkenő sorrendben vannak felsorolva. A nagyobb modellek lassabbak és több memóriát használnak, de jobb eredményt produkálnak. Modellváltás után az összes képen újra le kell futtatni az arcfelismerési feladatot.", "machine_learning_facial_recognition_setting": "Arckeresés engedélyezése", - "machine_learning_facial_recognition_setting_description": "Ha ki van kapcsolva, a képek nem lesznek az arcfelismerésen lefuttatva és a Felfedezés oldalon az Személyek szekcióban nem fog szerepelni senki.", + "machine_learning_facial_recognition_setting_description": "Ha ki van kapcsolva, a képek nem lesznek az arcfelismerésen lefuttatva és a Böngészés oldalon az Személyek szekcióban nem fog szerepelni senki.", "machine_learning_max_detection_distance": "Maximum észlelési távolság", "machine_learning_max_detection_distance_description": "Két kép közötti maximális távolság, amely esetében még másolatnak tekintjük őket (0.001 és 0.1 közötti érték). Magasabb értékek több másolatot találnak meg, de a hamis találatok esélye is nagyobb.", "machine_learning_max_recognition_distance": "Maximum felismerési távolság", @@ -138,11 +140,14 @@ "map_settings": "Térkép", "map_settings_description": "Térkép beállítások kezelése", "map_style_description": "Egy style.json térképstílusra mutató URL", - "metadata_extraction_job": "Metaadatok feldolgozása", - "metadata_extraction_job_description": "Metaadat-információk kinyerése minden tartalomból, például GPS, arcok és felbontás", + "metadata_extraction_job": "Metaadatok kinyerése", + "metadata_extraction_job_description": "Metaadat-információk kinyerése minden fájlból, például GPS, arcok és felbontás", + "metadata_faces_import_setting": "Arc importálás engedélyezése", + "metadata_faces_import_setting_description": "Arcok importálása a kép Exif adatából és metaadat fájlokból", "metadata_settings": "Metaadat beállítások", - "migration_job": "Migráció", - "migration_job_description": "Az képi vagyon és arcok bélyegképeinek migrálása a legújabb mappastruktúrába", + "metadata_settings_description": "Metaadat-beállítások kezelése", + "migration_job": "Migrálás", + "migration_job_description": "A fájlok és arcok bélyegképeinek migrálása a legújabb mappastruktúrába", "no_paths_added": "Nincs megadva elérési útvonal", "no_pattern_added": "Nincs megadva illesztési minta (pattern)", "note_apply_storage_label_previous_assets": "Megjegyzés: Tárolási Cimkék már korábban feltöltött képi vagyonra ragasztásához futtasd a következőt -", @@ -175,7 +180,7 @@ "oauth_issuer_url": "Kibocsátó URL", "oauth_mobile_redirect_uri": "Mobil átirányítási URI", "oauth_mobile_redirect_uri_override": "Mobil átirányítási URI felülírás", - "oauth_mobile_redirect_uri_override_description": "Engedélyezze, ha az 'app.immich:/' érvénytelen átirányítási URI.", + "oauth_mobile_redirect_uri_override_description": "Engedélyezze, ha az OAuth szolgáltató tiltja a mobil URI-t, mint például '{callback}'", "oauth_profile_signing_algorithm": "Profil aláíró algoritmus", "oauth_profile_signing_algorithm_description": "A felhasználói profil aláírásához használt algoritmus.", "oauth_scope": "Hatókör", @@ -195,6 +200,7 @@ "password_settings": "Jelszavas Bejelentkezés", "password_settings_description": "Jelszavas bejelentkezés beállítások kezelése", "paths_validated_successfully": "Összes útvonal sikeresen érvényesítve", + "person_cleanup_job": "Személy törlése", "quota_size_gib": "Kvóta Mérete (GiB)", "refreshing_all_libraries": "Összes képtár újratöltése", "registration": "Admin Regisztráció", @@ -208,6 +214,7 @@ "reset_settings_to_recent_saved": "Beállítások visszaállítása a legutóbb mentettre", "scanning_library_for_changed_files": "Képtár átfésülése megváltozott fájlok után", "scanning_library_for_new_files": "Képtár átfésülése új fájlok után", + "search_jobs": "Feladat keresés...", "send_welcome_email": "Üdvözlő email küldése", "server_external_domain_settings": "Külső domain", "server_external_domain_settings_description": "Nyilvánosan megosztott linkek domainje (http(s)://-sel)", @@ -215,10 +222,10 @@ "server_settings_description": "Szerver beállítások kezelése", "server_welcome_message": "Üdvözlő üzenet", "server_welcome_message_description": "A bejelentkezőoldalon megjelenő üzenet.", - "sidecar_job": "Oldalkocsi fájl metaadatok", - "sidecar_job_description": "Fedezze fel vagy szinkronizálja az oldalkocsi fájlokban tárolt metaadatokat a fájlrendszerből", + "sidecar_job": "Metaadat feldolgozás", + "sidecar_job_description": "Metaadatok keresése vagy szinkronizálása a fájlrendszer alapján", "slideshow_duration_description": "Az egyes képek megjelenítésének ideje másodpercben", - "smart_search_job_description": "Futtasson gépi tanulást a képi vagyonon az intelligens keresés támogatása érdekében", + "smart_search_job_description": "Gépi tanulás futtatása a fájlokon az okos keresés támogatásához", "storage_template_date_time_description": "A fájl készítési időpontja lesz felhasználva az időpont információhoz", "storage_template_date_time_sample": "Példa időpont {date}", "storage_template_enable_description": "Tárolási sablon motor engedélyezése", @@ -235,13 +242,14 @@ "storage_template_settings_description": "Kezelje a feltöltött képi vagyontárgyak mappaszerkezetét és fájlnevét", "storage_template_user_label": "A felhasználó Tároló Címkéje {label}", "system_settings": "Rendszerbeállítások", + "tag_cleanup_job": "Címke törlés", "theme_custom_css_settings": "Egyedi CSS", "theme_custom_css_settings_description": "CSS Stíluslapokkal az Immich stílusa megváltoztatható.", "theme_settings": "Stílus Beállítások", "theme_settings_description": "Kezelje az Immich webes felület testreszabását", "these_files_matched_by_checksum": "Ezek a fájlok egyeznek az ellenőrző összegük alapján", "thumbnail_generation_job": "Bélyegképek Generálása", - "thumbnail_generation_job_description": "Hozzon létre nagy, kicsi és elmosódott bélyegképeket minden egyes elemhez, valamint bélyegképeket minden egyes személyhez", + "thumbnail_generation_job_description": "Nagy, kicsi és elmosódott bélyegképek létrehozása minden elemhez, valamint bélyegképek generálása minden személyhez", "transcode_policy_description": "", "transcoding_acceleration_api": "Gyorsító API", "transcoding_acceleration_api_description": "Az API, amely interakcióba lép az eszközzel az átkódolás felgyorsítása érdekében. Ez a beállítás a „legtöbb, amit megtehetünk” alapon működik: hiba esetén visszaáll a szoftveres átkódolásra. A VP9 a hardvertől függően vagy működik, vagy nem.", @@ -257,11 +265,11 @@ "transcoding_accepted_video_codecs_description": "Válassza ki, mely videó kodexeket nem kell átkódolni. Csak bizonyos átkódolási szabályzatokhoz használatos.", "transcoding_advanced_options_description": "Ezeket az opciókat a legtöbb felhasználónak nem kell módosítania", "transcoding_audio_codec": "Audio kodek", - "transcoding_audio_codec_description": "Az Opus a legjobb minőségű opció (jobb minőség ugyanannyi helyet foglalva), de kevésbé kompatibilis a régi eszközökkel vagy szoftverekkel.", + "transcoding_audio_codec_description": "Az Opus a legjobb minőségű opció (jobb hangminőség ugyanakkora tárhelyen), de kevésbé kompatibilis a régi eszközökkel vagy szoftverekkel.", "transcoding_bitrate_description": "A maximum bitrátát meghaladó vagy nem megfelelő formátumú videókat", "transcoding_codecs_learn_more": "Hogy többet tudjon meg az itt felhasznált kifejezésekről, látogassa meg az FFmpeg dokumentációt a H.264 kodekhez, a HEVC kodekhez és a VP9 kodekhez.", "transcoding_constant_quality_mode": "Állandó minőségi mód", - "transcoding_constant_quality_mode_description": "Az ICQ jobb, mint a CQP, viszont nem minden hardver támogatja. A rendszer az itt beállított módszert részesíti előnyben. A NVENC ignorálja a beállítást, mivel nem támogatja az ICQ-t.", + "transcoding_constant_quality_mode_description": "Az ICQ jobb, mint a CQP, viszont az előbbit nem minden hardver támogatja. A rendszer az itt beállított módot preferálja a minőség orientált enkódoláshoz. Az NVENC nem használja ezt a beállítást, mivel nem támogatja az ICQ-t.", "transcoding_constant_rate_factor": "Állandó ráta tényező (-crf)", "transcoding_constant_rate_factor_description": "Videó minőségi szint. Jellemző értékek kodekenként: H.264: 23, HEVC: 28, VP9: 31, AV1: 35. Minél alacsonyabb, annál jobb minőséget eredményez, viszont nagyobb fájlmérettel is jár.", "transcoding_disabled_description": "Ne transzkódoljon videót. Nem lejátszható videókhoz vezethet néhány kliensen", @@ -287,7 +295,7 @@ "transcoding_settings": "Videó Transzkódolási Beállítások", "transcoding_settings_description": "Videófájlok felbontásának és kódólásának kezelése", "transcoding_target_resolution": "Célfelbontás", - "transcoding_target_resolution_description": "Magasabb felbontás jobb minőségben őrzi meg a részleteket, de tovább tart kódolni, nagyobb fájlmérethez vezet, és csökkentheti az alkalmazás teljesítményét.", + "transcoding_target_resolution_description": "Magasabb felbontás jobb minőségben őrzi meg a részleteket, de tovább tart kódolni, nagyobb fájlmérethez vezet, és csökkentheti az alkalmazás válaszidejét.", "transcoding_temporal_aq": "Időbeli (Temporal) AQ", "transcoding_temporal_aq_description": "Csak NVENC esetén. Növeli a nagyon részletes, keveset mozgó videóanyag minőségét. Nem minden régi hardver támogatja.", "transcoding_threads": "Folyamatok száma", @@ -299,16 +307,17 @@ "transcoding_transcode_policy": "Transzkódolási szabályzat", "transcoding_transcode_policy_description": "Mely videókat transzkódolja. HDR videók mindig transzkódolásra kerülnek (kivéve, ha a transzkódolás ki van kapcsolva).", "transcoding_two_pass_encoding": "Enkódolás két menetben", - "transcoding_two_pass_encoding_setting_description": "Ha két menetben lettek transzkódolva, az elkészült videok jobbak. Ha engedélyezve van a bitráta maximalizálása (amely egyébként szükséges a H.264 és a HEVC használatakor), ez a funkció figyelmen kívül hagyja a CRF-et és a maximális bitráta alapján választja ki a megfelelő bitráta sávot. VP9 használata esetén CRF használható, ha a bitráta nincs maxmalizáva (ki van kapcsolva).", + "transcoding_two_pass_encoding_setting_description": "Ha két menetben lettek transzkódolva, az elkészült videók jobb minőségűek. Ha engedélyezve van a bitráta maximalizálása (amely szükséges a H.264 és a HEVC használatakor), ez a funkció figyelmen kívül hagyja a CRF-et. VP9 használata esetén a CRF használható, ha a bitráta nincs maximalizálva (ki van kapcsolva).", "transcoding_video_codec": "Videó Kodek", "transcoding_video_codec_description": "VP9 hatékonyabb és kompatibilisebb webre, de tovább tart a transzkódolás. HEVC hasonló teljesítményű, de több web kompatibilitási problémát okozhat. H.264 széles körben kompatibilis és gyors a transzkódolása, de sokkal nagyobb fájlokat készít. AV1 a leghatékonyabb kodek, de régebbi eszközök nem támogatják.", "trash_enabled_description": "Lomtár engedélyezése", "trash_number_of_days": "Napok száma", - "trash_number_of_days_description": "Hány napig legyenek a lomtárban tárolva a törölt képek, videok, mielőtt véglegesen kiürítődnek", + "trash_number_of_days_description": "Hány napig legyenek a lomtárban a fájlok a végleges törlés előtt", "trash_settings": "Lomtár Beállítások", "trash_settings_description": "Lomtár beállítások kezelése", "untracked_files": "Nem kezelt fájlok", "untracked_files_description": "Ezekkel a fájlokkal semmit nem csinál az alkalmazás. Ez lehetséges pl. meghiúsult mozgatás, megszakított feltöltés miatt, vagy valamilyen alkalmazáshiba következtében", + "user_cleanup_job": "Felhasználó adatainak törlése", "user_delete_delay": "{user} felhasználói fiókja és képi vagyona véglegesen törölve lesz {delay, plural, one {# nap} other {# nap}} múlva.", "user_delete_delay_settings": "Törlési késleltetés", "user_delete_delay_settings_description": "Ennyi nap teljen el az eltávolítás után a felhasználói fiók és ahhoz tartozó elemek végleges törlése között. A törlésért felelős folyamat éjfélkor indul, és megnézi van-e törlésre kész felhasználó. A beállítás változtatása a következő végrehajtás során lép életbe.", @@ -339,7 +348,7 @@ "album_added": "Albumhoz hozzáadva", "album_added_notification_setting_description": "Küldjön emailes értesítőt, amikor hozzáadnak egy megosztott albumhoz", "album_cover_updated": "Album borító frissítve", - "album_delete_confirmation": "Biztos, hogy ki szeretné törölni a {album} albumot?", + "album_delete_confirmation": "Biztos, hogy ki szeretné törölni a(z) {album} albumot?", "album_delete_confirmation_description": "Amennyiben ez egy megosztott album, a többi felhasználó sem fog tudni hozzáférni.", "album_info_updated": "Album infó frissítve", "album_leave": "Elhagyja az albumot?", @@ -376,7 +385,7 @@ "archive_size": "Archívum mérete", "archive_size_description": "Beállítja letöltésnél az archívum méretét (GiB)", "archived": "Archíválva", - "archived_count": "{count, plural, other {Archived #}}", + "archived_count": "{count, plural, other {Archiválva #}}", "are_these_the_same_person": "Ugyanaz a személy?", "are_you_sure_to_do_this": "Biztosan ezt akarod csinálni?", "asset_added_to_album": "Hozzáadva az albumhoz", @@ -388,6 +397,7 @@ "asset_offline": "Elem offline", "asset_offline_description": "Ez az elem nem elérhető. Immich nem képes elérni a file helyét. Győződjön meg az elem elérhetőségéről és szkennelje újra a könyvtárat.", "asset_skipped": "Kihagyva", + "asset_skipped_in_trash": "Lomtárban", "asset_uploaded": "Feltöltve", "asset_uploading": "Feltöltés...", "assets": "elemek", @@ -396,12 +406,12 @@ "assets_added_to_name_count": "{count, plural, other {# elem}} hozzáadva a(z) {hasName, select, true {{name}} other {új}} albumba", "assets_count": "{count, plural, other {# elem}}", "assets_moved_to_trash": "{count, plural, one {# fájl} other {# fájl}} a lomtárba mozgatva", - "assets_moved_to_trash_count": "{count, plural, other {# elem}} szemétbe mozgatva", + "assets_moved_to_trash_count": "{count, plural, other {# elem}} lomtárba mozgatva", "assets_permanently_deleted_count": "{count, plural, other {# elem}} örökre törölve", "assets_removed_count": "{count, plural, other {# elem}} eltávolítva", - "assets_restore_confirmation": "Biztosan visszaállítja a lomtárbeli elemeket? Ez a művelet nem visszavonható!", + "assets_restore_confirmation": "Biztosan visszaállítja a lomtárban lévő elemeket? Ez a művelet nem visszavonható!", "assets_restored_count": "{count, plural, other {# elem}} visszaállítva", - "assets_trashed_count": "{count, plural, other {# elem}} kidobva", + "assets_trashed_count": "{count, plural, other {# elem}} lomtárba helyezve", "assets_were_part_of_album_count": "{count, plural, other {# elem}} már az album része volt", "authorized_devices": "Engedélyezett készülékek", "back": "Vissza", @@ -484,6 +494,8 @@ "create_new_person": "Új személy létrehozása", "create_new_person_hint": "A kiválasztott képekhez új személyt rendel hozzá", "create_new_user": "Új felhasználó létrehozása", + "create_tag": "Címke létrehozása", + "create_tag_description": "Új címke létrehozása. Beágyazott címkék esetén adja meg a címke teljes elérési útvonalát, beleértve a perjeleket is.", "create_user": "Felhasználó létrehozása", "created": "Készült", "current_device": "Ez az eszköz", @@ -507,6 +519,8 @@ "delete_library": "Képtár törlése", "delete_link": "Link törlése", "delete_shared_link": "Megosztott link törlése", + "delete_tag": "Címke törlése", + "delete_tag_confirmation_prompt": "Biztos, hogy törölni szeretné a {tagName} címkét?", "delete_user": "Felhasználó törlése", "deleted_shared_link": "Törölt megosztott link", "description": "Leírás", @@ -555,6 +569,7 @@ "edit_location": "Hely módosítása", "edit_name": "Név módosítása", "edit_people": "Személyek módosítása", + "edit_tag": "Címke szerkesztése", "edit_title": "Cím Módosítása", "edit_user": "Felhasználó módosítása", "edited": "Módosítva", @@ -567,7 +582,7 @@ "empty": "", "empty_album": "Üres Album", "empty_trash": "Lomtár Ürítése", - "empty_trash_confirmation": "Biztosan kiüríti a lomtárat? Ezzel minden lomtárbeli fájlt véglegesen letöröl az Immich szolgáltatásból.\nEz a művelet nem visszavonható!", + "empty_trash_confirmation": "Biztosan kiüríti a lomtárat? Ezzel minden lomtárban lévő fájlt véglegesen letöröl az Immich szolgáltatásból.\nEz a művelet nem visszavonható!", "enable": "Engedélyezés", "enabled": "Engedélyezve", "end_date": "Vég dátum", @@ -585,7 +600,7 @@ "cant_get_number_of_comments": "Hozzászólások számának lekérdezése sikertelen", "cant_search_people": "Emberek keresése sikertelen", "cant_search_places": "Helyek keresése sikertelen", - "cleared_jobs": "A {job} munkák törölve", + "cleared_jobs": "A {job} feladatok törölve", "error_adding_assets_to_album": "Hiba történt az elemek albumhoz való hozzáadása során", "error_adding_users_to_album": "Hiba történt a felhasználók albumhoz való hozzáadása során", "error_deleting_shared_user": "Hiba történt megosztott felhasználó törlése során", @@ -594,7 +609,7 @@ "error_removing_assets_from_album": "Hiba történt az elemek albumból való eltávolítása során, további információért ellenőrizze a logokat", "error_selecting_all_assets": "Minden elem kijelölése közben hiba lépett fel", "exclusion_pattern_already_exists": "Ez a kizárási minta már létezik.", - "failed_job_command": "Parancs {command} hibával zárult a {job} munkában", + "failed_job_command": "A(z) {command} parancs hibával zárult a(z) {job} feladatban", "failed_to_create_album": "Album készítése sikertelen", "failed_to_create_shared_link": "Megosztott link készítése sikertelen", "failed_to_edit_shared_link": "Megosztott link szerkesztése sikertelen", @@ -652,6 +667,7 @@ "unable_to_get_comments_number": "Hozzászólások számának lekérdezése sikertelen", "unable_to_get_shared_link": "Megosztott link lekérdezése sikertelen", "unable_to_hide_person": "Személy elrejtése sikertelen", + "unable_to_link_motion_video": "Nem lehet a motion videót hozzákapcsolni", "unable_to_link_oauth_account": "OAuth felhasználó csatlakoztatása sikertelen", "unable_to_load_album": "Album betöltése sikertelen", "unable_to_load_asset_activity": "Elem aktivitásának betöltése sikertelen", @@ -689,9 +705,10 @@ "unable_to_scan_library": "Könyvtár ellenőrzése sikertelen", "unable_to_set_feature_photo": "Kijelölt fénykép beállítása sikertelen", "unable_to_set_profile_picture": "Profilkép beállítása sikertelen", - "unable_to_submit_job": "Nem sikerült a profilt elmenteni", + "unable_to_submit_job": "Nem sikerült a feladatot elindítani", "unable_to_trash_asset": "Nem sikerült a fájl lomtárba mozgatása", "unable_to_unlink_account": "Nem sikerült a fiók lekapcsolása", + "unable_to_unlink_motion_video": "Nem lehet a motion videót leválasztani", "unable_to_update_album_cover": "Albumborító beállítása sikertelen", "unable_to_update_album_info": "Album információ frissítése sikertelen", "unable_to_update_library": "Nem sikerült a képtár módosítása", @@ -711,7 +728,8 @@ "expire_after": "Lejárati idő", "expired": "Lejárt", "expires_date": "Lejár {date}", - "explore": "Felfedezés", + "explore": "Böngészés", + "explorer": "Böngésző", "export": "Exportálás", "export_as_json": "Exportálás JSON formátumban", "extension": "Kiterjesztés", @@ -725,6 +743,8 @@ "feature": "", "feature_photo_updated": "Címlapkép frissítve", "featurecollection": "", + "features": "Jellemzők", + "features_setting_description": "Az alkalmazás lehetőségeinek kezelése", "file_name": "Fájlnév", "file_name_or_extension": "Fájlnév vagy kiterjesztés", "filename": "Fájlnév", @@ -733,6 +753,8 @@ "filter_people": "Személyek szűrése", "find_them_fast": "Kereséssel gyorsan megtalálhatóak név alapján", "fix_incorrect_match": "Hibás találat korrigálása", + "folders": "Mappák", + "folders_feature_description": "A fájlrendszerben lévő fényképek és videók mappanézetben való böngészése", "force_re-scan_library_files": "Az összes Képtár fájl újbóli átfésülésének indítása", "forward": "Előre", "general": "Általános", @@ -753,7 +775,7 @@ "hide_password": "Jelszó elrejtése", "hide_person": "Személy elrejtése", "hide_unnamed_people": "Megnevezetlen emberek elrejtése", - "host": "", + "host": "Kiszolgáló", "hour": "Óra", "image": "Kép", "image_alt_text_date": "{isVideo, select, true {Videó} other {Kép}} készítési dátuma {date}", @@ -804,6 +826,7 @@ "library_options": "Képtár beállítások", "light": "Világos", "like_deleted": "Tetszik törölve", + "link_motion_video": "Motion videó hozzárendelése", "link_options": "Link beállítások", "link_to_oauth": "Csatlakoztatás OAuth-hoz", "linked_oauth_account": "Csatlakoztatott OAuth felhasználó", @@ -875,8 +898,8 @@ "no_assets_message": "KATTINTSON AZ ELSŐ FÉNYKÉPE FELTÖLTÉSÉHEZ", "no_duplicates_found": "Duplikátumok nem találhatók.", "no_exif_info_available": "Exif információ nem elérhető", - "no_explore_results_message": "Töltsön fel több fényképet, hogy felfedezze a gyűjteményét.", - "no_favorites_message": "Jelöljön meg kedvenceket, hogy gyorsan megtalálhassa legjobb fényképeit és videóit", + "no_explore_results_message": "Töltsön fel több fényképet, hogy böngészhesse a gyűjteményét.", + "no_favorites_message": "Hozzáadás a kedvencekhez, hogy hamarabb megtalálhassa a legjobb fényképeit és videóit", "no_libraries_message": "Hozzon létre külső képtárat a fényképei és videói megtekintéséhez", "no_name": "Nincs Név", "no_places": "Nincsenek helyek", @@ -939,6 +962,7 @@ "pending": "Folyamatban lévő", "people": "Személyek", "people_edits_count": "{count, plural, other {# személy}} szerkesztve", + "people_feature_description": "Személyek szerint csoportosított fényképek és videók böngészése", "people_sidebar_description": "Jelenítsen meg linket a Személyek fülhöz oldalt", "perform_library_tasks": "", "permanent_deletion_warning": "Figyelmeztetés végleges törlésről", @@ -1009,7 +1033,9 @@ "purchase_settings_server_activated": "A szerver termékkulcsot az admin menedzseli", "range": "", "rating": "Értékelés csillagokkal", - "rating_description": "Exif értékelés megjelenítése az infópanelben", + "rating_clear": "Értékelés törlése", + "rating_count": "{count, plural, one {# csillag} other {# csillagok}}", + "rating_description": "Exif értékelés megjelenítése az infópanelen", "raw": "", "reaction_options": "Reakció lehetőségek", "read_changelog": "Változtatások olvasása", @@ -1042,6 +1068,7 @@ "removed_from_archive": "Archívumból eltávolítva", "removed_from_favorites": "Kedvencekből eltávolítva", "removed_from_favorites_count": "A kedvencekből el lett távolítva {count, plural, other {# elem}}", + "removed_tagged_assets": "Címke eltávolítva az {count, plural, one {# elemről} other {# elemekről}}", "rename": "Átnevezés", "repair": "Javítás", "repair_no_results_message": "Nem megfigyelt és hiányzó fájlok itt jelennek meg", @@ -1074,7 +1101,8 @@ "scan_all_libraries": "Minden könyvtár átnézése", "scan_all_library_files": "Minden könyvtárbeli elem újraellenőrzése", "scan_new_library_files": "Ellenőrzés új könyvtárbeli elemekért", - "scan_settings": "Felfedezési beállítások", + "scan_settings": "Szkennelési beállítások", + "scanning_for_album": "Album szkennelése...", "search": "Keresés", "search_albums": "Albumok keresése", "search_by_context": "Keresés kontextus alapján", @@ -1087,13 +1115,16 @@ "search_for_existing_person": "Már meglévő személy keresése", "search_no_people": "Nincs személy", "search_no_people_named": "Nincs személy \"{name}\" néven", + "search_options": "Keresési lehetőségek", "search_people": "Személyek keresése", "search_places": "Helyek keresése", + "search_settings": "Keresési beállítások", "search_state": "Régió keresése...", + "search_tags": "Címkék keresése...", "search_timezone": "Időzóna keresése...", "search_type": "Típus keresése", "search_your_photos": "Fotók keresése", - "searching_locales": "", + "searching_locales": "Helyszín keresése...", "second": "Másodperc", "see_all_people": "Minden személy megtekintése", "select_album_cover": "Albumborító kiválasztása", @@ -1107,7 +1138,7 @@ "select_library_owner": "Könyvtártulajdonos kijelölése", "select_new_face": "Új arc kiválasztása", "select_photos": "Fotók választása", - "select_trash_all": "Minden szemétbe helyezése", + "select_trash_all": "Minden lomtárba helyezése", "selected": "Kijelölt", "selected_count": "{count, plural, other {# kiválasztva}}", "send_message": "Üzenet küldése", @@ -1127,7 +1158,7 @@ "settings_saved": "Beállítások mentve", "share": "Megosztás", "shared": "Megosztva", - "shared_by": "Megosztva általa:", + "shared_by": "Megosztotta", "shared_by_user": "Megosztva {user} által", "shared_by_you": "Megosztva Ön által", "shared_from_partner": "Fényképek {partner}-tól/től", @@ -1158,10 +1189,14 @@ "show_supporter_badge": "Támogató jelvény", "show_supporter_badge_description": "Támogató jelvény megjelenítése", "shuffle": "Keverés", + "sidebar": "Oldalsáv", + "sidebar_display_description": "Nézetre mutató link megjelenítése az oldalsávban", "sign_out": "Kilépés", "sign_up": "Feliratkozás", "size": "Méret", "skip_to_content": "Ugrás a tartalomhoz", + "skip_to_folders": "Ugrás a mappákra", + "skip_to_tags": "Ugrás a címkékhez", "slideshow": "Diavetítés", "slideshow_settings": "Diavetítés beállításai", "sort_albums_by": "Albumok rendezése...", @@ -1194,6 +1229,14 @@ "sunrise_on_the_beach": "Napkelte a tengerparton", "swap_merge_direction": "Egyesítés irányának megfordítása", "sync": "Szinkronizálás", + "tag": "Címke", + "tag_assets": "Elemek címkézése", + "tag_created": "Létrehozott címke: {tag}", + "tag_feature_description": "Címkék szerinti fényképek és videók böngészése", + "tag_not_found_question": "Nem találja a címkét? Hozzon létre egyet itt", + "tag_updated": "Frissített címke: {tag}", + "tagged_assets": "Címkézett {count, plural, one {# elem} other {# elemek}}", + "tags": "Címkék", "template": "Minta", "theme": "Téma", "theme_selection": "Témaválasztás", @@ -1205,17 +1248,18 @@ "to_change_password": "Jelszó megváltoztatása", "to_favorite": "Kedvenc", "to_login": "Bejelentkezés", - "to_trash": "Szemétbe helyezés", + "to_parent": "Egy szinttel feljebb", + "to_trash": "Lomtárba helyezés", "toggle_settings": "Beállítások változtatása", - "toggle_theme": "Témaváltás", + "toggle_theme": "Sötét téma váltása", "toggle_visibility": "Láthatóság változtatása", "total_usage": "Összesen használatban", "trash": "Lomtár", "trash_all": "Mindet lomtárba", - "trash_count": "{count, number} elem szemétbe helyezése", - "trash_delete_asset": "Elem szemétbe helyezése / törlése", - "trash_no_results_message": "Itt lesznek láthatóak a lomtárba tett képek és videok.", - "trashed_items_will_be_permanently_deleted_after": "A szemeteskosárban lévő elemek véglegesen törlésre kerülnek {days, plural, other {# nap}} múlva.", + "trash_count": "{count, number} elem lomtárba helyezése", + "trash_delete_asset": "Lomtárba helyezés/törlés", + "trash_no_results_message": "Itt lesznek láthatóak a lomtárba tett képek és videók.", + "trashed_items_will_be_permanently_deleted_after": "A lomtárban lévő elemek véglegesen törlésre kerülnek {days, plural, other {# nap}} múlva.", "type": "Típus", "unarchive": "Archívumból kivétel", "unarchived": "Archívumból kivett", @@ -1226,9 +1270,11 @@ "unknown_album": "Ismeretlen Album", "unknown_year": "Ismeretlen év", "unlimited": "Korlátlan", + "unlink_motion_video": "Mozgókép leválasztása", "unlink_oauth": "OAuth leválasztása", "unlinked_oauth_account": "Leválasztott OAuth felhasználó", "unnamed_album": "Névtelen Album", + "unnamed_album_delete_confirmation": "Biztosan törölni szeretné ezt az albumot?", "unnamed_share": "Névtelen Megosztás", "unsaved_change": "Mentés nélküli változtatás", "unselect_all": "Összes kiválasztás törlése", @@ -1240,7 +1286,7 @@ "up_next": "Következik", "updated_password": "Jelszó megváltoztatva", "upload": "Feltöltés", - "upload_concurrency": "", + "upload_concurrency": "Párhuzamos feltöltés", "upload_errors": "Feltöltés befejezve {count, plural, other {# hibával}}, frissítse az oldalt az újonnan feltöltött elemek megtekintéséhez.", "upload_progress": "Hátra van {remaining, number} - Feldolgozva {processed, number}/{total, number}", "upload_skipped_duplicates": "{count, plural, other {# megegyező elem}} kihagyva", @@ -1249,7 +1295,7 @@ "upload_status_uploaded": "Feltöltve", "upload_success": "Feltöltés sikeres, frissítse az oldalt az újonnan feltöltött elemek megtekintéséhez.", "url": "URL", - "usage": "Felhasználás", + "usage": "Használat", "use_custom_date_range": "Szabadon megadott időintervallum használata", "user": "Felhasználó", "user_id": "Felhasználó azonosítója", @@ -1264,6 +1310,8 @@ "validate": "Ellenőrzés", "variables": "Változók", "version": "Verzió", + "version_announcement_closing": "Barátod, Alex", + "version_announcement_message": "Szia barátom, van egy új verziója az alkalmazásnak. Kérjük, szánj időt a verzióinformáció megtekintésére, és győződj meg róla, hogy a docker-compose.yml és a .env beállítások naprakészek, hogy elkerüld a hibás konfigurációt, különösen, ha WatchTower-t vagy valami más automatikus frissítési megoldást használsz.", "video": "Videó", "video_hover_setting": "Bélyegkép felett lebegésnél videó indítás", "video_hover_setting_description": "Ha az egér a bélyegkép felett időzik, a bélyegkép videó lejátszása induljon el. A lejátszás az indítás ikon feletti időzéssel akkor is elindul, ha ez az opció ki van kapcsolva.", @@ -1273,6 +1321,7 @@ "view_album": "Album megtekintése", "view_all": "Összes mutatása", "view_all_users": "Minden felhasználó megtekintése", + "view_in_timeline": "Megtekintés az idővonalon", "view_links": "Linkek megtekintése", "view_next_asset": "Következő elem megtekintése", "view_previous_asset": "Előző elem megtekintése", diff --git a/web/src/lib/i18n/id.json b/web/src/lib/i18n/id.json index 1321bd358bd1e..99df952c214cc 100644 --- a/web/src/lib/i18n/id.json +++ b/web/src/lib/i18n/id.json @@ -41,6 +41,7 @@ "confirm_email_below": "Untuk mengonfirmasi, ketik \"{email}\" di bawah", "confirm_reprocess_all_faces": "Apakah Anda yakin ingin memproses semua wajah? Ini juga akan menghapus nama orang.", "confirm_user_password_reset": "Apakah Anda yakin ingin mengatur ulang kata sandi {user}?", + "create_job": "Buat tugas", "disable_login": "Nonaktifkan log masuk", "duplicate_detection_job_description": "Jalankan pembelajaran mesin pada aset untuk mendeteksi gambar yang serupa. Bergantung pada Pencarian Pintar", "exclusion_pattern_description": "Pola pengecualian memungkinkan Anda mengabaikan berkas dan folder ketika memindai pustaka Anda. Ini berguna jika Anda memiliki folder yang berisi berkas yang tidak ingin diimpor, seperti berkas RAW.", @@ -68,6 +69,7 @@ "image_thumbnail_resolution": "Resolusi gambar kecil", "image_thumbnail_resolution_description": "Digunakan ketika menampilkan kelompok foto (lini masa utama, tampilan album, dll.). Resolusi yang lebih tinggi dapat menjaga lebih banyak detail tetapi memerlukan waktu lama untuk mengode, memiliki ukuran berkas yang lebih besar, dan dapat mengurangi respons aplikasi.", "job_concurrency": "Konkurensi {job}", + "job_created": "Tugas telah dibuat", "job_not_concurrency_safe": "Tugas ini tidak aman untuk konkurensi.", "job_settings": "Pengaturan Tugas", "job_settings_description": "Kelola konkurensi tugas", @@ -196,6 +198,7 @@ "password_settings": "Log Masuk Kata Sandi", "password_settings_description": "Kelola pengaturan log masuk kata sandi", "paths_validated_successfully": "Semua jalur berhasil divalidasi", + "person_cleanup_job": "Pembersihan data pribadi", "quota_size_gib": "Ukuran Kuota (GiB)", "refreshing_all_libraries": "Menyegarkan semua pustaka", "registration": "Pendaftaran Admin", @@ -209,6 +212,7 @@ "reset_settings_to_recent_saved": "Atur ulang pengaturan ke pengaturan tersimpan terkini", "scanning_library_for_changed_files": "Memindai pustaka untuk berkas yang telah diubah", "scanning_library_for_new_files": "Memindai pustaka untuk berkas baru", + "search_jobs": "Mencari tugas...", "send_welcome_email": "Kirim surel selamat datang", "server_external_domain_settings": "Domain eksternal", "server_external_domain_settings_description": "Domain untuk tautan terbagi publik, termasuk http(s)://", @@ -236,6 +240,7 @@ "storage_template_settings_description": "Kelola struktur folder dan nama berkas dari aset yang diunggah", "storage_template_user_label": "{label} adalah Label Penyimpanan pengguna", "system_settings": "Pengaturan Sistem", + "tag_cleanup_job": "Pembersihan tag", "theme_custom_css_settings": "CSS Kustom", "theme_custom_css_settings_description": "CSS memungkinkan desain Immich untuk diubah.", "theme_settings": "Pengaturan Tema", @@ -309,6 +314,7 @@ "trash_settings_description": "Kelola pengaturan sampah", "untracked_files": "Berkas yang Belum Dilacak", "untracked_files_description": "Berkas ini tidak dilacak oleh aplikasi. Mereka dapat diakibatkan oleh pemindahan gagal, pengunggahan terganggu, atau tertinggal karena oleh kutu", + "user_cleanup_job": "Pembersihan data pengguna", "user_delete_delay": "Akun dan aset {user} akan dijadwalkan untuk penghapusan permanen dalam {delay, plural, one {# hari} other {# hari}}.", "user_delete_delay_settings": "Jeda penghapusan", "user_delete_delay_settings_description": "Jumlah hari setelah penghapusan untuk menghapus akun dan aset pengguna secara permanen. Tugas penghapusan pengguna berjalan pada tengah malam untuk memeriksa pengguna yang siap untuk dihapus. Perubahan pengaturan ini akan dievaluasi pada eksekusi berikutnya.", @@ -1111,6 +1117,7 @@ "search_options": "Pilihan pencarian", "search_people": "Cari orang", "search_places": "Cari tempat", + "search_settings": "Pengaturan pencarian", "search_state": "Cari negara bagian...", "search_tags": "Cari tag...", "search_timezone": "Cari zona waktu...", diff --git a/web/src/lib/i18n/lv.json b/web/src/lib/i18n/lv.json index 2701cda4e8428..8f0835397dfe9 100644 --- a/web/src/lib/i18n/lv.json +++ b/web/src/lib/i18n/lv.json @@ -129,7 +129,7 @@ "notification_email_test_email_sent": "", "notification_email_username_description": "", "notification_enable_email_notifications": "", - "notification_settings": "", + "notification_settings": "Paziņojumu iestatījumi", "notification_settings_description": "", "oauth_auto_launch": "", "oauth_auto_launch_description": "", @@ -279,19 +279,22 @@ "archive_or_unarchive_photo": "", "archive_size": "Arhīva izmērs", "archived": "", + "are_these_the_same_person": "Vai šī ir tā pati persona?", "asset_offline": "", "asset_uploading": "Augšupielādē...", "assets": "aktīvi", "authorized_devices": "", "back": "Atpakaļ", "backward": "", + "birthdate_saved": "Dzimšanas datums veiksmīgi saglabāts", + "birthdate_set_description": "Dzimšanas datums tiek izmantots, lai aprēķinātu šīs personas vecumu fotogrāfijas uzņemšanas brīdī.", "blurred_background": "", "camera": "", "camera_brand": "", "camera_model": "", "cancel": "Atcelt", "cancel_search": "", - "cannot_merge_people": "", + "cannot_merge_people": "Nevar apvienot cilvēkus", "cannot_update_the_description": "", "cant_apply_changes": "", "cant_get_faces": "", @@ -301,17 +304,18 @@ "change_expiration_time": "Izmainīt derīguma termiņu", "change_location": "", "change_name": "", - "change_name_successfully": "", - "change_password": "Nomainīt Paroli", + "change_name_successfully": "Vārds veiksmīgi nomainīts", + "change_password": "Nomainīt paroli", "change_your_password": "", "changed_visibility_successfully": "", "check_logs": "", + "choose_matching_people_to_merge": "Izvēlies atbilstošus cilvēkus apvienošanai", "city": "Pilsēta", "clear": "Notīrīt", "clear_all": "", "clear_message": "", "clear_value": "", - "close": "", + "close": "Aizvērt", "collapse_all": "", "color_theme": "", "comment_options": "", @@ -349,6 +353,7 @@ "date_after": "", "date_and_time": "Datums un Laiks", "date_before": "", + "date_of_birth_saved": "Dzimšanas datums veiksmīgi saglabāts", "date_range": "Datumu diapazons", "day": "", "default_locale": "", @@ -401,6 +406,8 @@ "edit_user": "Labot lietotāju", "edited": "", "editor": "", + "editor_close_without_save_prompt": "Izmaiņas netiks saglabātas", + "editor_close_without_save_title": "Aizvērt redaktoru?", "email": "E-pasts", "empty": "", "empty_album": "", @@ -411,6 +418,7 @@ "error": "", "error_loading_image": "", "errors": { + "cant_search_people": "Neizdevās veikt peronu meklēšanu", "failed_to_create_album": "Neizdevās izveidot albumu", "unable_to_add_album_users": "", "unable_to_add_comment": "", @@ -429,7 +437,7 @@ "unable_to_empty_trash": "", "unable_to_enter_fullscreen": "", "unable_to_exit_fullscreen": "", - "unable_to_hide_person": "", + "unable_to_hide_person": "Neizdevās paslēpt personu", "unable_to_load_album": "", "unable_to_load_asset_activity": "", "unable_to_load_items": "", @@ -449,6 +457,7 @@ "unable_to_restore_trash": "", "unable_to_restore_user": "", "unable_to_save_album": "", + "unable_to_save_date_of_birth": "Neizdevās saglabāt dzimšanas datumu", "unable_to_save_name": "", "unable_to_save_profile": "", "unable_to_save_settings": "", @@ -500,50 +509,57 @@ "group_albums_by": "", "has_quota": "Ir kvota", "hide_gallery": "", + "hide_named_person": "Paslēpt personu {name}", "hide_password": "", - "hide_person": "", + "hide_person": "Paslēpt personu", "host": "", "hour": "", "image": "Attēls", "img": "", - "immich_logo": "", - "import_path": "", - "in_archive": "", + "immich_logo": "Immich logo", + "import_from_json": "Importēt no JSON", + "import_path": "Importa ceļš", + "in_albums": "{count, plural, one {# albumā} other {# albumos}}", + "in_archive": "Arhīvā", "include_archived": "Iekļaut arhivētos", - "include_shared_albums": "", + "include_shared_albums": "Iekļaut koplietotos albumus", "include_shared_partner_assets": "", "individual_share": "", - "info": "", + "info": "Informācija", "interval": { - "day_at_onepm": "", + "day_at_onepm": "Katru dienu 13.00", "hours": "", - "night_at_midnight": "", - "night_at_twoam": "" + "night_at_midnight": "Katru dienu pusnaktī", + "night_at_twoam": "Katru dienu 2.00 naktī" }, - "invite_people": "", + "invite_people": "Ielūgt cilvēkus", "invite_to_album": "Uzaicināt albumā", "job_settings_description": "", - "jobs": "", - "keep": "", - "keyboard_shortcuts": "", - "language": "", - "language_setting_description": "", - "last_seen": "", - "leave": "", + "jobs": "Darbi", + "keep": "Paturēt", + "keep_all": "Paturēt visus", + "keyboard_shortcuts": "Tastatūras saīsnes", + "language": "Valoda", + "language_setting_description": "Izvēlieties vēlamo valodu", + "last_seen": "Pēdējo reizi redzēts", + "latest_version": "Jaunākā versija", + "latitude": "Ģeogrāfiskais platums", + "leave": "Paturēt", "let_others_respond": "Ļaut citiem atbildēt", - "level": "", + "level": "Līmenis", "library": "Bibliotēka", "library_options": "", "light": "", "link_options": "", "link_to_oauth": "", "linked_oauth_account": "", - "list": "", - "loading": "", + "list": "Saraksts", + "loading": "Ielādē", "loading_search_results_failed": "", "log_out": "Izrakstīties", "log_out_all_devices": "", "login_has_been_disabled": "", + "longitude": "Ģeogrāfiskais garums", "look": "", "loop_videos": "", "loop_videos_description": "Iespējot, lai automātiski videoklips tiktu cikliski palaists detaļu skatītājā.", @@ -559,46 +575,53 @@ "map_marker_for_images": "Kartes marķieris attēliem, kas uzņemti {city}, {country}", "map_marker_with_image": "Kartes marķieris ar attēlu", "map_settings": "Kartes Iestatījumi", - "media_type": "", - "memories": "", + "matches": "Atbilstības", + "media_type": "Multivides veids", + "memories": "Atmiņas", "memories_setting_description": "", - "menu": "", - "merge": "", - "merge_people": "", - "merge_people_successfully": "", - "minimize": "", - "minute": "", - "missing": "", + "memory": "Atmiņa", + "menu": "Izvēlne", + "merge": "Apvienot", + "merge_people": "Cilvēku apvienošana", + "merge_people_limit": "Vienlaikus var apvienot ne vairāk kā 5 sejas", + "merge_people_prompt": "Vai vēlies apvienot šos cilvēkus? Šī darbība ir neatgriezeniska.", + "merge_people_successfully": "Cilvēki veiksmīgi apvienoti", + "minimize": "Minimizēt", + "minute": "Minūte", + "missing": "Trūkstošie", "model": "Modelis", "month": "Mēnesis", - "more": "", + "more": "Vairāk", "moved_to_trash": "", - "my_albums": "", + "my_albums": "Mani albumi", "name": "Vārds", - "name_or_nickname": "", + "name_or_nickname": "Vārds vai iesauka", "never": "nekad", - "new_api_key": "", + "new_album": "Jauns albums", + "new_api_key": "Jauna API atslēga", "new_password": "Jaunā parole", - "new_person": "", + "new_person": "Jauna persona", "new_user_created": "Izveidots jauns lietotājs", + "new_version_available": "PIEEJAMA JAUNA VERSIJA", "newest_first": "", "next": "Nākošais", - "next_memory": "", - "no": "", + "next_memory": "Nākamā atmiņa", + "no": "Nē", "no_albums_message": "", "no_archived_assets_message": "", - "no_assets_message": "", + "no_assets_message": "NOKLIKŠĶINIET, LAI AUGŠUPIELĀDĒTU SAVU PIRMO FOTOATTĒLU", "no_duplicates_found": "Dublikāti netika atrasti.", - "no_exif_info_available": "", + "no_exif_info_available": "Nav pieejama exif informācija", "no_explore_results_message": "", "no_favorites_message": "", "no_libraries_message": "", - "no_name": "", - "no_places": "", - "no_results": "", + "no_name": "Nav nosaukuma", + "no_places": "Nav atrašanās vietu", + "no_results": "Nav rezultātu", + "no_results_description": "Izmēģiniet sinonīmu vai vispārīgāku atslēgvārdu", "no_shared_albums_message": "", - "not_in_any_album": "", - "notes": "", + "not_in_any_album": "Nav nevienā albumā", + "notes": "Piezīmes", "notification_toggle_setting_description": "", "notifications": "Paziņojumi", "notifications_setting_description": "", @@ -707,7 +730,9 @@ "search_city": "", "search_country": "", "search_for_existing_person": "", - "search_people": "", + "search_no_people": "Nav cilvēku", + "search_no_people_named": "Nav cilvēku ar vārdu \"{name}\"", + "search_people": "Meklēt cilvēkus", "search_places": "", "search_state": "", "search_timezone": "", @@ -732,7 +757,7 @@ "set": "", "set_as_album_cover": "", "set_as_profile_picture": "", - "set_date_of_birth": "", + "set_date_of_birth": "Iestatīt dzimšanas datumu", "set_profile_picture": "", "set_slideshow_to_fullscreen": "", "settings": "Iestatījumi", @@ -783,6 +808,7 @@ "theme": "Dizains", "theme_selection": "", "theme_selection_description": "", + "they_will_be_merged_together": "Tās tiks apvienotas", "time_based_memories": "", "timezone": "Laika zona", "toggle_settings": "", @@ -795,8 +821,8 @@ "type": "", "unarchive": "Atarhivēt", "unarchived": "", - "unfavorite": "Noņemt no Izlases", - "unhide_person": "", + "unfavorite": "Noņemt no izlases", + "unhide_person": "Atcelt personas slēpšanu", "unknown": "", "unknown_album": "", "unknown_year": "", @@ -836,6 +862,7 @@ "week": "", "welcome_to_immich": "", "year": "", + "years_ago": "Pirms {years, plural, one {# gada} other {# gadiem}}", "yes": "Jā", "zoom_image": "Pietuvināt attēlu" } diff --git a/web/src/lib/i18n/nl.json b/web/src/lib/i18n/nl.json index dc9f003978033..23dfa7633de91 100644 --- a/web/src/lib/i18n/nl.json +++ b/web/src/lib/i18n/nl.json @@ -41,6 +41,7 @@ "confirm_email_below": "Typ hieronder \"{email}\" ter bevestiging", "confirm_reprocess_all_faces": "Weet je zeker dat je alle gezichten opnieuw wilt verwerken? Hiermee worden ook alle mensen gewist.", "confirm_user_password_reset": "Weet u zeker dat je het wachtwoord van {user} wilt resetten?", + "create_job": "Taak maken", "crontab_guru": "Crontab Guru", "disable_login": "Inloggen uitschakelen", "disabled": "Uitgeschakeld", @@ -70,6 +71,7 @@ "image_thumbnail_resolution": "Thumbnail resolutie", "image_thumbnail_resolution_description": "Gebruikt wanneer groepen foto's bekeken worden (hoofdtijdslijn, album, enzo). Hogere resoluties kunnen meer detail behouden maar duren langer om te verwerken, hebben hogere bestandsgrootte, en kunnen de applicatie langzamer maken.", "job_concurrency": "{job} gelijktijdigheid", + "job_created": "Taak aangemaakt", "job_not_concurrency_safe": "Deze taak kan niet gelijktijdig worden uitgevoerd.", "job_settings": "Achtergrondtaak-instellingen", "job_settings_description": "Beheer gelijktijdige taken", @@ -211,6 +213,7 @@ "reset_settings_to_recent_saved": "Instellingen zijn gereset naar de recent opgeslagen instellingen", "scanning_library_for_changed_files": "Bibliotheek scannen op gewijzigde bestanden", "scanning_library_for_new_files": "Bibliotheek scannen op nieuwe bestanden", + "search_jobs": "Taak zoeken...", "send_welcome_email": "Stuur een welkomstmail", "server_external_domain_settings": "Extern domein", "server_external_domain_settings_description": "Domein voor openbaar gedeelde links, inclusief http(s)://", @@ -661,6 +664,7 @@ "unable_to_get_comments_number": "Kan het aantal opmerkingen niet ophalen", "unable_to_get_shared_link": "Kan gedeelde link niet ophalen", "unable_to_hide_person": "Kan persoon niet verbergen", + "unable_to_link_motion_video": "Kan bewegende video niet verbinden", "unable_to_link_oauth_account": "Kan OAuth account niet koppelen", "unable_to_load_album": "Kan album niet laden", "unable_to_load_asset_activity": "Kan asset activiteit niet laden", @@ -701,6 +705,7 @@ "unable_to_submit_job": "Kan taak niet uitvoeren", "unable_to_trash_asset": "Kan asset niet naar prullenbak verplaatsen", "unable_to_unlink_account": "Kan account niet ontkoppelen", + "unable_to_unlink_motion_video": "Kan bewegende video niet los maken", "unable_to_update_album_cover": "Kan album cover niet bijwerken", "unable_to_update_album_info": "Kan albumgegevens niet bijwerken", "unable_to_update_library": "Kan bibliotheek niet bijwerken", @@ -846,6 +851,7 @@ "license_trial_info_4": "Overweeg een licentie te kopen om de verdere ontwikkeling van de service te ondersteunen", "light": "Licht", "like_deleted": "Like verwijderd", + "link_motion_video": "verbind bewegende video", "link_options": "Opties voor link", "link_to_oauth": "Koppel OAuth", "linked_oauth_account": "Gekoppeld OAuth account", @@ -1139,6 +1145,7 @@ "search_options": "Zoekopties", "search_people": "Zoek mensen", "search_places": "Zoek plaatsen", + "search_settings": "Zoek instellingen", "search_state": "Zoek staat...", "search_tags": "Tags zoeken...", "search_timezone": "Zoek tijdzone...", @@ -1291,6 +1298,7 @@ "unknown_album": "Onbekend album", "unknown_year": "Onbekend jaar", "unlimited": "Onbeperkt", + "unlink_motion_video": "Maak bewegende video los", "unlink_oauth": "Ontkoppel OAuth", "unlinked_oauth_account": "OAuth account ontkoppeld", "unnamed_album": "Naamloos album", diff --git a/web/src/lib/i18n/pt.json b/web/src/lib/i18n/pt.json index ebe1e85729148..5c20ffb81a592 100644 --- a/web/src/lib/i18n/pt.json +++ b/web/src/lib/i18n/pt.json @@ -1,11 +1,11 @@ { "about": "Sobre", "account": "Conta", - "account_settings": "Configurações da Conta", + "account_settings": "Definições da Conta", "acknowledge": "Confirmar", "action": "Ação", "actions": "Ações", - "active": "Ativo", + "active": "Em execução", "activity": "Atividade", "activity_changed": "A actividade está {enabled, select, true {ativada} other {desativada}}", "add": "Adicionar", @@ -22,338 +22,347 @@ "add_photos": "Adicionar fotos", "add_to": "Adicionar a...", "add_to_album": "Adicionar ao álbum", - "add_to_shared_album": "Adicionar ao álbum compartilhado", + "add_to_shared_album": "Adicionar ao álbum partilhado", "added_to_archive": "Adicionado ao arquivo", "added_to_favorites": "Adicionado aos favoritos", "added_to_favorites_count": "{count, plural, one {{count, number} adicionado aos favoritos} other {{count, number} adicionados aos favoritos}}", "admin": { - "add_exclusion_pattern_description": "Adicione padrões de exclusão. Utilizar *, ** ou ? são suportados. Para ignorar todos os arquivos em qualquer diretório chamado \"Raw\", use \"**/Raw/**'. Para ignorar todos os arquivos que finalizam em \".tif\", use \"**/*.tif\". Para ignorar um caminho absoluto, use \"/caminho/para/ignorar/**\".", - "authentication_settings": "Configurações de Autenticação", - "authentication_settings_description": "Gerenciar senhas, OAuth, e outras configurações de autenticação", - "authentication_settings_disable_all": "Tem a certeza que deseja desativar todos os métodos de entrada? Entrar será completamente desativado.", + "add_exclusion_pattern_description": "Adicione padrões de exclusão. Utilizar *, ** ou ? são suportados. Para ignorar todos os ficheiros em qualquer diretório chamado \"Raw\", use \"**/Raw/**'. Para ignorar todos os ficheiros que finalizam em \".tif\", use \"**/*.tif\". Para ignorar um caminho absoluto, use \"/caminho/para/ignorar/**\".", + "authentication_settings": "Definições de Autenticação", + "authentication_settings_description": "Gerir palavras-passe, OAuth, e outras definições de autenticação", + "authentication_settings_disable_all": "Tem a certeza que deseja desativar todos os métodos de início de sessão? O início de sessão será completamente desativado.", "authentication_settings_reenable": "Para reativar, use um Comando de servidor.", "background_task_job": "Tarefas em segundo plano", "check_all": "Selecionar Tudo", "cleared_jobs": "Eliminadas as tarefas de: {job}", - "config_set_by_file": "A configuração está atualmente definida por um arquivo de configuração", - "confirm_delete_library": "Você tem certeza que deseja excluir a biblioteca {library} ?", - "confirm_delete_library_assets": "Você tem certeza que deseja eliminar esta biblioteca? Isto eliminará {count, plural, one {# arquivo incluído} other {todos os # arquivos incluídos}} do Immich e esta ação não pode ser revertida. Os ficheiros permanecerão no disco.", - "confirm_email_below": "Para confirmar, digite o {email} abaixo", - "confirm_reprocess_all_faces": "Tem certeza de que deseja reprocessar todos as faces? Isso também limpará as pessoas nomeadas.", - "confirm_user_password_reset": "Tem certeza de que deseja redefinir a senha de {user}?", + "config_set_by_file": "A configuração está atualmente definida por um ficheiro de configuração", + "confirm_delete_library": "Tem a certeza de que deseja eliminar a biblioteca {library} ?", + "confirm_delete_library_assets": "Você tem certeza que deseja eliminar esta biblioteca? Isto eliminará {count, plural, one {# ficheiro incluído} other {todos os # ficheiros incluídos}} do Immich e esta ação não pode ser revertida. Os ficheiros permanecerão no disco.", + "confirm_email_below": "Para confirmar, escreva \"{email}\" abaixo", + "confirm_reprocess_all_faces": "Tem a certeza de que deseja reprocessar todos os rostos? Isto também limpará os nomes de pessoas.", + "confirm_user_password_reset": "Tem a certeza de que deseja redefinir a palavra-passe de {user}?", + "create_job": "Criar tarefa", "crontab_guru": "Guru do Crontab", - "disable_login": "Desabilitar login", + "disable_login": "Desativar inicio de sessão", "disabled": "", - "duplicate_detection_job_description": "Execute o aprendizado de máquina em arquivos para detectar imagens semelhantes. Depende da pesquisa inteligente", - "exclusion_pattern_description": "Os padrões de exclusão permitem ignorar arquivos e pastas ao escanear sua biblioteca. Isso é útil se você tiver pastas que contenham arquivos que não deseja importar, como arquivos RAW.", + "duplicate_detection_job_description": "Executa a aprendizagem de máquina em ficheiros para detetar imagens semelhantes. Depende da Pesquisa Inteligente", + "exclusion_pattern_description": "Os padrões de exclusão permitem ignorar ficheiros e pastas ao analisar a sua biblioteca. Isto é útil se tiver pastas que contenham ficheiros que não deseja importar, como ficheiros RAW.", "external_library_created_at": "Biblioteca externa (criada em {date})", - "external_library_management": "Gerenciamento de bibliotecas externas", - "face_detection": "Detecção de faces", - "face_detection_description": "Deteta rostos em arquivos com aprendizagem automática. Para vídeos, apenas a miniatura é considerada. \"Todos\" (re)processa todos os arquivos. \"Ausente\" enfileira arquivos que ainda não foram processados. Os rostos detetados serão enfileirados para reconhecimento facial após a conclusão da deteção de rostos, agrupando-os em pessoas novas ou já existentes.", - "facial_recognition_job_description": "Agrupa rostos detectados em pessoas. Esta etapa é executada após a conclusão da deteção de faces. \"Todos\" (re)agrupa todos os rostos. \"Ausentes\" enfileira rostos que ainda não têm uma pessoa atribuída.", + "external_library_management": "Gestão de bibliotecas externas", + "face_detection": "Deteção de Rostos", + "face_detection_description": "Deteta rostos em ficheiros utilizando aprendizagem automática. Para vídeos, apenas a miniatura é considerada. \"Todos\" (re)processa todos os ficheiros. \"Ausente\" coloca em fila ficheiros que ainda não foram processados. Os rostos detetados serão colocados em fila para Reconhecimento Facial após a conclusão da Deteção de Rostos, agrupando-os em pessoas novas ou já existentes.", + "facial_recognition_job_description": "Agrupa rostos detetadas em pessoas. Esta etapa é executada após a conclusão da Deteção de Rostos. \"Todos\" (re)agrupa todos os rostos. \"Ausentes\" coloca em fila rostos que ainda não têm uma pessoa atribuída.", "failed_job_command": "Comando {command} falhou para a tarefa: {job}", - "force_delete_user_warning": "AVISO: Isso removerá imediatamente o utilizador e todos os arquivos. Isso não pode ser desfeito e os ficheiros não poderão ser recuperados.", - "forcing_refresh_library_files": "Forçando a atualização de todos os arquivos da biblioteca", - "image_format_description": "WebP produz arquivos menores que JPEG, mas é mais lento para codificar.", - "image_prefer_embedded_preview": "Prefira visualização incorporada", - "image_prefer_embedded_preview_setting_description": "Use visualizações incorporadas em fotos RAW como entrada para processamento de imagem, quando disponível. Isso pode produzir cores mais precisas para algumas imagens, mas a qualidade da visualização depende da câmera e a imagem pode ter mais artefatos de compactação.", + "force_delete_user_warning": "AVISO: Isto removerá imediatamente o utilizador e todos os ficheiros. Isso não pode ser revertido e os ficheiros não poderão ser recuperados.", + "forcing_refresh_library_files": "A forçar a atualização de todos os ficheiros da biblioteca", + "image_format_description": "WebP produz ficheiros mais pequenos do que JPEG, mas é mais lento para codificar.", + "image_prefer_embedded_preview": "Preferir visualização incorporada", + "image_prefer_embedded_preview_setting_description": "Utilizar visualizações incorporadas em fotos RAW como entrada para processamento de imagem, quando disponível. Isto pode produzir cores mais precisas para algumas imagens, mas a qualidade da visualização depende da câmara e a imagem pode ter mais artefatos de compressão.", "image_prefer_wide_gamut": "Prefira ampla gama", - "image_prefer_wide_gamut_setting_description": "Use o Display P3 para miniaturas. Isso preserva melhor a vibração das imagens com espaços de cores amplos, mas as imagens podem aparecer de maneira diferente em dispositivos antigos com uma versão antiga do navegador. As imagens sRGB são mantidas como sRGB para evitar mudanças de cores.", + "image_prefer_wide_gamut_setting_description": "Utilizar Display P3 para miniaturas. Isso preserva melhor a vibrância das imagens com espaços de cores amplos, mas as imagens podem aparecer de maneira diferente em dispositivos antigos com uma versão antiga do navegador. As imagens sRGB são mantidas como sRGB para evitar mudanças de cores.", "image_preview_format": "Formato de visualização", "image_preview_resolution": "Resolução de visualização", - "image_preview_resolution_description": "Usado ao visualizar uma única foto e para aprendizado de máquina. Resoluções mais altas podem preservar mais detalhes, mas demoram mais para codificar, têm tamanhos de arquivo maiores e podem reduzir a capacidade de resposta do aplicativo.", + "image_preview_resolution_description": "Usado ao visualizar uma única foto e para aprendizagem de máquina. Resoluções mais altas podem preservar mais detalhes, mas demoram mais para codificar, têm tamanhos de ficheiro maiores e podem reduzir a capacidade de resposta da aplicação.", "image_quality": "Qualidade", - "image_quality_description": "Qualidade de imagem de 1 a 100. Quanto maior, melhor para a qualidade, mas produz arquivos maiores. Esta opção afeta as imagens de visualização e miniatura.", - "image_settings": "Configurações de imagem", - "image_settings_description": "Gerenciar a qualidade e resolução das imagens geradas", + "image_quality_description": "Qualidade de imagem de 1 a 100. Quanto maior, melhor para a qualidade, mas produz ficheiros maiores. Esta definição afeta as imagens de visualização e miniatura.", + "image_settings": "Definições de imagem", + "image_settings_description": "Gerir a qualidade e resolução das imagens geradas", "image_thumbnail_format": "Formato de miniatura", "image_thumbnail_resolution": "Resolução de miniatura", - "image_thumbnail_resolution_description": "Usado ao visualizar grupos de fotos (linha do tempo principal, visualização de álbum, etc.). Resoluções mais altas podem preservar mais detalhes, mas demoram mais para codificar, têm tamanhos de arquivo maiores e podem reduzir a capacidade de resposta do aplicativo.", - "job_concurrency": "{job} simultâneo", - "job_not_concurrency_safe": "Este trabalho não é compatível com simultaneidade.", - "job_settings": "Configurações de trabalho", - "job_settings_description": "Gerenciar simultaneidade dos trabalhos", - "job_status": "Status do trabalho", + "image_thumbnail_resolution_description": "Utilizado ao visualizar grupos de fotos (linha do tempo principal, visualização de álbum, etc.). Resoluções mais altas podem preservar mais detalhes, mas demoram mais para codificar, têm tamanhos de ficheiro maiores e podem reduzir a capacidade de resposta da aplicação.", + "job_concurrency": "{job} em simultâneo", + "job_created": "Tarefa criada", + "job_not_concurrency_safe": "Esta tarefa não pode ser executada em simultâneo.", + "job_settings": "Definições de Tarefas", + "job_settings_description": "Gerir tarefas em simultâneo", + "job_status": "Estado das Tarefas", "jobs_delayed": "{jobCount, plural, one {# adiado} other {# adiados}}", "jobs_failed": "{jobCount, plural, one {# falhou} other {# falharam}}", - "library_created": "Criado biblioteca: {library}", + "library_created": "Criada biblioteca: {library}", "library_cron_expression": "Expressão Cron", "library_cron_expression_description": "Defina o intervalo de procura utilizando o formato cron. Para mais informações consulte Guru Crontab", "library_cron_expression_presets": "Predefinições de expressão Cron", - "library_deleted": "Biblioteca excluída", - "library_import_path_description": "Especifique uma pasta para importar. Esta pasta, incluindo subpastas, será escaneada em busca de imagens e vídeos.", - "library_scanning": "Escanear periódicamente", - "library_scanning_description": "Configurar o escaneamento periódico da biblioteca", - "library_scanning_enable_description": "Habilitar escaneamento periódico da biblioteca", + "library_deleted": "Biblioteca eliminada", + "library_import_path_description": "Especifique uma pasta para importar. Esta pasta, incluindo sub-pastas, será analisada por imagens e vídeos.", + "library_scanning": "Análise periódica", + "library_scanning_description": "Configurar a análise periódica da biblioteca", + "library_scanning_enable_description": "Ativar análise periódica da biblioteca", "library_settings": "Biblioteca Externa", - "library_settings_description": "Gerenciar configurações de biblioteca externa", - "library_tasks_description": "Execute tarefas de biblioteca", - "library_watching_enable_description": "Observe bibliotecas externas para alterações de arquivos", - "library_watching_settings": "Observação de biblioteca (EXPERIMENTAL)", - "library_watching_settings_description": "Observe automaticamente os arquivos alterados", - "logging_enable_description": "Habilitar registro", - "logging_level_description": "Quando ativado, qual nível de log usar.", - "logging_settings": "Registros", + "library_settings_description": "Gerir definições de biblioteca externa", + "library_tasks_description": "Executa tarefas de biblioteca", + "library_watching_enable_description": "Analisar bibliotecas externas por alterações de ficheiros", + "library_watching_settings": "Análise de biblioteca (EXPERIMENTAL)", + "library_watching_settings_description": "Analise automaticamente por ficheiros alterados", + "logging_enable_description": "Ativar registo", + "logging_level_description": "Quando ativado, qual o nível de log a usar.", + "logging_settings": "Registo", "machine_learning_clip_model": "Modelo CLIP", - "machine_learning_clip_model_description": "O nome do modelo CLIP definido aqui. Note que é necessário voltar a executar a \"Pesquisa Inteligente\" para todas as imagens depois de alterar um modelo.", - "machine_learning_duplicate_detection": "Detecção de duplicidade", - "machine_learning_duplicate_detection_enabled": "Habilitar detecção de duplicidade", - "machine_learning_duplicate_detection_enabled_description": "Se desativado, arquivos exatamente idênticos ainda serão desduplicados.", - "machine_learning_duplicate_detection_setting_description": "Use embeddings CLIP para encontrar prováveis duplicidades", - "machine_learning_enabled": "Habilitar o aprendizado da máquina", - "machine_learning_enabled_description": "Se desativado, todos os recursos de ML serão desativados, independentemente das configurações abaixo.", + "machine_learning_clip_model_description": "O nome do modelo CLIP definido aqui. Tome nota de que é necessário voltar a executar a tarefa de \"Pesquisa Inteligente\" para todas as imagens depois de alterar o modelo.", + "machine_learning_duplicate_detection": "Deteção de Itens Duplicados", + "machine_learning_duplicate_detection_enabled": "Ativar deteção de itens duplicados", + "machine_learning_duplicate_detection_enabled_description": "Se desativado, ficheiros exatamente idênticos serão desduplicados na mesma.", + "machine_learning_duplicate_detection_setting_description": "Utilizar embeddings CLIP para encontrar itens provavelmente duplicados", + "machine_learning_enabled": "Ativar a aprendizagem de máquina", + "machine_learning_enabled_description": "Se desativado, todos as funcionalidades de ML serão desativados, independentemente das definições abaixo.", "machine_learning_facial_recognition": "Reconhecimento Facial", - "machine_learning_facial_recognition_description": "Deteta, reconhece e agrupa rostos em imagens", + "machine_learning_facial_recognition_description": "Detetar, reconhecer e agrupar rostos em imagens", "machine_learning_facial_recognition_model": "Modelo de reconhecimento facial", - "machine_learning_facial_recognition_model_description": "Os modelos estão listados em ordem decrescente de tamanho. Modelos maiores são mais lentos e utilizam mais memória, mas produzem melhores resultados. Observe que ao alterar um modelo, você deve executar novamente o trabalho de Detecção de faces para todas as imagens.", + "machine_learning_facial_recognition_model_description": "Os modelos estão ordenados por ordem decrescente de tamanho. Modelos maiores são mais lentos e utilizam mais memória, mas produzem melhores resultados. Tome conta de que ao alterar um modelo, deve executar novamente a tarefa de \"Deteção de Rostos\" para todas as imagens.", "machine_learning_facial_recognition_setting": "Ativar reconhecimento facial", - "machine_learning_facial_recognition_setting_description": "Se desativado, as imagens não serão codificadas para reconhecimento facial e não preencherão a seção Pessoas na página Explorar.", - "machine_learning_max_detection_distance": "Distância máxima de detecção", - "machine_learning_max_detection_distance_description": "Distância máxima entre duas imagens para considerá-las duplicadas, variando de 0,001 a 0,1. Valores mais altos detectarão mais duplicidades, mas poderão resultar em falsos positivos.", + "machine_learning_facial_recognition_setting_description": "Se desativado, as imagens não serão codificadas para reconhecimento facial e não preencherão a secção Pessoas na página Explorar.", + "machine_learning_max_detection_distance": "Distância máxima de deteção", + "machine_learning_max_detection_distance_description": "Distância máxima entre duas imagens para considerá-las duplicadas, variando de 0,001 a 0,1. Valores mais altos detetarão mais duplicidades, mas poderão resultar em falsos positivos.", "machine_learning_max_recognition_distance": "Distância máxima de reconhecimento", - "machine_learning_max_recognition_distance_description": "Distância máxima entre duas faces para ser considerada a mesma pessoa, variando de 0 a 2. Valores menores evitam rotular duas faces como a mesma pessoa, enquanto valores maiores evitam rotular a mesma face como duas pessoas diferentes. Observe que é mais fácil mesclar duas pessoas do que dividir uma pessoa em duas, portanto tenha preferência por valores mais baixos quando possível.", - "machine_learning_min_detection_score": "Pontuação mínima de detecção", - "machine_learning_min_detection_score_description": "Pontuação mínima de confiança para uma face ser detectada, de 0 a 1. Valores mais baixos detectam mais rostos, mas poderão resultar em falsos positivos.", - "machine_learning_min_recognized_faces": "Mínimo de faces reconhecidas", - "machine_learning_min_recognized_faces_description": "O número mínimo de faces reconhecidas para uma pessoa ser criada na lista. Aumentar isso torna o Reconhecimento Facial mais preciso, ao custo de aumentar a chance de um rosto não ser atribuído a uma pessoa.", - "machine_learning_settings": "Configurações de aprendizado de máquina (Machine Learning)", - "machine_learning_settings_description": "Gerenciar recursos e configurações de aprendizado de máquina", - "machine_learning_smart_search": "Busca inteligente", - "machine_learning_smart_search_description": "Pesquise imagens semanticamente usando embeddings CLIP", - "machine_learning_smart_search_enabled": "Habilite a pesquisa inteligente", - "machine_learning_smart_search_enabled_description": "Se desativado, as imagens não serão codificadas para pesquisa inteligente.", - "machine_learning_url_description": "URL do servidor de aprendizado de máquina", - "manage_concurrency": "Gerenciar simultaneidade", - "manage_log_settings": "Gerenciar configurações de registro", + "machine_learning_max_recognition_distance_description": "Distância máxima entre dois rostos para serem considerados a mesma pessoa, variando de 0 a 2. Valores menores evitam rotular dois rostos como a mesma pessoa, enquanto valores maiores evitam rotular o mesmo rosto como duas pessoas diferentes. Tenha em conta de que é mais fácil unir duas pessoas do que dividir uma pessoa em duas, portanto tenha preferência por valores mais baixos quando possível.", + "machine_learning_min_detection_score": "Pontuação mínima de deteção", + "machine_learning_min_detection_score_description": "Pontuação mínima de confiança para um rosto ser detetado, de 0 a 1. Valores mais baixos detetam mais rostos, mas poderão resultar em falsos positivos.", + "machine_learning_min_recognized_faces": "Mínimo de rostos reconhecidos", + "machine_learning_min_recognized_faces_description": "O número mínimo de faces reconhecidas para uma pessoa ser criada na lista. Aumentar isto torna o Reconhecimento Facial mais preciso, no entanto aumenta a probabilidade de um rosto não ser atribuído a uma pessoa.", + "machine_learning_settings": "Definições de aprendizagem de máquina (Machine Learning)", + "machine_learning_settings_description": "Gerir funcionalidades e definições de aprendizagem de máquina", + "machine_learning_smart_search": "Pesquisa Inteligente", + "machine_learning_smart_search_description": "Pesquise imagens semanticamente utilizando embeddings CLIP", + "machine_learning_smart_search_enabled": "Ativar a Pesquisa Inteligente", + "machine_learning_smart_search_enabled_description": "Se desativado, as imagens não serão codificadas para Pesquisa Inteligente.", + "machine_learning_url_description": "URL do servidor de aprendizagem de máquina", + "manage_concurrency": "Gerir simultaneidade", + "manage_log_settings": "Gerir definições de registo", "map_dark_style": "Tema Escuro", - "map_enable_description": "Ativar recursos do mapa", + "map_enable_description": "Ativar funcionalidades de mapa", "map_gps_settings": "Mapas e Definições de GPS", - "map_gps_settings_description": "Configurações de mapas e GPS (Geocoding inverso)", - "map_implications": "A funcionalidade do mapa necessita um servico externo (tiles.immich.cloud)", + "map_gps_settings_description": "Gerir Definições de Mapas e GPS (Geocodificação Reversa)", + "map_implications": "A funcionalidades do mapa necessita um serviço externo (tiles.immich.cloud)", "map_light_style": "Tema Claro", - "map_manage_reverse_geocoding_settings": "Gerir definições de Geocoding inverso", - "map_reverse_geocoding": "Geocodificação reversa", - "map_reverse_geocoding_enable_description": "Ativar geocodificação reversa", - "map_reverse_geocoding_settings": "Configurações de geocodificação reversa", + "map_manage_reverse_geocoding_settings": "Gerir definições de Geocodificação Reversa", + "map_reverse_geocoding": "Geocodificação Reversa", + "map_reverse_geocoding_enable_description": "Ativar Geocodificação Reversa", + "map_reverse_geocoding_settings": "Definições de Geocodificação Reversa", "map_settings": "Mapa", - "map_settings_description": "Gerenciar configurações do mapa", + "map_settings_description": "Gerir definições do mapa", "map_style_description": "URL para um tema de mapa style.json", "metadata_extraction_job": "Extrair metadados", - "metadata_extraction_job_description": "Extrai informações de metadados de cada ativo, como GPS e resolução", + "metadata_extraction_job_description": "Extrai informações de metadados de cada ficheiro, como GPS, rostos e resolução", "metadata_faces_import_setting": "Ativar a importação facial", + "metadata_faces_import_setting_description": "Importar rostos a partir dos dados EXIF da imagem e ficheiros anexos", + "metadata_settings": "Definições de metadados", + "metadata_settings_description": "Gerir definições de metadados", "migration_job": "Migração", - "migration_job_description": "Migre miniaturas de arquivos e rostos para a estrutura de pastas mais recente", + "migration_job_description": "Migra miniaturas de ficheiros e rostos para a estrutura de pastas mais recente", "no_paths_added": "Nenhum caminho adicionado", "no_pattern_added": "Nenhum padrão adicionado", - "note_apply_storage_label_previous_assets": "Observação: Para aplicar o rótulo de armazenamento a arquivos carregados anteriormente, execute o", + "note_apply_storage_label_previous_assets": "Observação: Para aplicar o Rótulo de Armazenamento a ficheiros carregados anteriormente, execute o", "note_cannot_be_changed_later": "NOTA: Isto não pode ser alterado posteriormente!", - "note_unlimited_quota": "Observação: insira 0 para cota ilimitada", + "note_unlimited_quota": "Observação: insira 0 para quota ilimitada", "notification_email_from_address": "A partir do endereço", - "notification_email_from_address_description": "Endereço de e-mail do remetente, por exemplo: \"Immich Photo Server \"", + "notification_email_from_address_description": "Endereço de e-mail do remetente, por exemplo: \"Servidor de Fotos Immich \"", "notification_email_host_description": "Host do servidor de e-mail (por exemplo, smtp.immich.app)", "notification_email_ignore_certificate_errors": "Ignorar erros de certificado", "notification_email_ignore_certificate_errors_description": "Ignorar erros de validação de certificado TLS (não recomendado)", - "notification_email_password_description": "Senha a ser usada ao autenticar no servidor de e-mail", + "notification_email_password_description": "Palavra-passe a ser usada ao autenticar no servidor de e-mail", "notification_email_port_description": "Porta do servidor de e-mail (por exemplo, 25, 465 ou 587)", - "notification_email_sent_test_email_button": "Envie e-mail de teste e salve", - "notification_email_setting_description": "Configurações para envio de notificações por e-mail", + "notification_email_sent_test_email_button": "Enviar e-mail de teste e gravar", + "notification_email_setting_description": "Definições para envio de notificações por e-mail", "notification_email_test_email": "Enviar e-mail de teste", - "notification_email_test_email_failed": "Falha ao enviar e-mail de teste. Verifique seus valores", - "notification_email_test_email_sent": "Um email de teste foi enviado para {email}. Por favor, verifique sua caixa de entrada.", + "notification_email_test_email_failed": "Falha ao enviar e-mail de teste, verifique os valores", + "notification_email_test_email_sent": "Um email de teste foi enviado para {email}. Por favor, verifique a sua caixa de entrada.", "notification_email_username_description": "Nome de utilizador a ser usado ao autenticar com o servidor de e-mail", - "notification_enable_email_notifications": "Habilitar notificações por e-mail", - "notification_settings": "Configurações de notificação", - "notification_settings_description": "Gerenciar configurações de notificação, incluindo e-mail", - "oauth_auto_launch": "Inicialização automática", - "oauth_auto_launch_description": "Inicie o fluxo de login do OAuth automaticamente ao navegar até a página de login", - "oauth_auto_register": "Registro automático", - "oauth_auto_register_description": "Registre automaticamente novos utilizadores após fazer login com OAuth", - "oauth_button_text": "Botão de texto", + "notification_enable_email_notifications": "Ativar notificações por e-mail", + "notification_settings": "Definições de notificações", + "notification_settings_description": "Gerir definições de notificações, incluindo e-mail", + "oauth_auto_launch": "Arranque automático", + "oauth_auto_launch_description": "Iniciar o fluxo de login do OAuth automaticamente ao navegar até a página de inicio de sessão", + "oauth_auto_register": "Registo automático", + "oauth_auto_register_description": "Registar automaticamente novos utilizadores após iniciarem sessão com o OAuth", + "oauth_button_text": "Texto do botão", "oauth_client_id": "ID do Cliente", "oauth_client_secret": "Segredo do cliente", - "oauth_enable_description": "Faça login com OAuth", + "oauth_enable_description": "Iniciar sessão com o OAuth", "oauth_issuer_url": "URL do emissor", "oauth_mobile_redirect_uri": "URI de redirecionamento móvel", "oauth_mobile_redirect_uri_override": "Substituição de URI de redirecionamento móvel", - "oauth_mobile_redirect_uri_override_description": "Ative quando 'app.immich:/' for um URI de redirecionamento inválido.", + "oauth_mobile_redirect_uri_override_description": "Ative quando o provedor do OAuth não permite um URI móvel, como '{callback}'", "oauth_profile_signing_algorithm": "Algoritmo de assinatura de perfis", "oauth_profile_signing_algorithm_description": "Algoritmo utilizado para assinar o perfil de utilizador.", "oauth_scope": "Escopo", "oauth_settings": "OAuth", - "oauth_settings_description": "Gerenciar configurações de login do OAuth", + "oauth_settings_description": "Gerir definições de inicio de sessão do OAuth", "oauth_settings_more_details": "Para mais informações sobre esta funcionalidade, veja a documentação.", "oauth_signing_algorithm": "Algoritmo de assinatura", - "oauth_storage_label_claim": "Reivindicação de rótulo de armazenamento", - "oauth_storage_label_claim_description": "Defina automaticamente o rótulo de armazenamento do utilizador para o valor desta declaração.", - "oauth_storage_quota_claim": "Reivindicação de cota de armazenamento", - "oauth_storage_quota_claim_description": "Defina automaticamente a cota de armazenamento do utilizador para o valor desta declaração.", - "oauth_storage_quota_default": "Cota de armazenamento padrão (GiB)", - "oauth_storage_quota_default_description": "Cota em GiB a ser usada quando nenhuma reivindicação for fornecida (insira 0 para cota ilimitada).", - "offline_paths": "Caminhos off-line", - "offline_paths_description": "Esses resultados podem ser devidos à exclusão manual de arquivos que não fazem parte de uma biblioteca externa.", - "password_enable_description": "Login com e-mail e senha", - "password_settings": "Senha de acesso", - "password_settings_description": "Gerenciar configurações de login e senha", + "oauth_storage_label_claim": "Reivindicação de Rótulo de Armazenamento", + "oauth_storage_label_claim_description": "Definir automaticamente o Rótulo de Armazenamento do utilizador para o valor desta declaração.", + "oauth_storage_quota_claim": "Reivindicação de quota de armazenamento", + "oauth_storage_quota_claim_description": "Definir automaticamente a quota de armazenamento do utilizador para o valor desta declaração.", + "oauth_storage_quota_default": "Quota de armazenamento padrão (GiB)", + "oauth_storage_quota_default_description": "Quota em GiB a ser usada quando nenhuma reivindicação for fornecida (insira 0 para quota ilimitada).", + "offline_paths": "Caminhos Offline", + "offline_paths_description": "Estes resultados podem ser devidos à eliminação manual de ficheiros que não fazem parte de uma biblioteca externa.", + "password_enable_description": "Iniciar sessão com e-mail e palavra-passe", + "password_settings": "Palavra-passe de acesso", + "password_settings_description": "Gerir definições de inicio de sessão e palavra-passe", "paths_validated_successfully": "Todos os caminhos validados com sucesso", - "quota_size_gib": "Tamanho da cota (GiB)", - "refreshing_all_libraries": "Atualizando todas as bibliotecas", - "registration": "Registo de Admin", + "person_cleanup_job": "Limpeza de pessoas", + "quota_size_gib": "Tamanho da quota (GiB)", + "refreshing_all_libraries": "A atualizar todas as bibliotecas", + "registration": "Registo de Administrador", "registration_description": "Como é o primeiro utilizador no sistema, será marcado como administrador, e será responsável pelas tarefas administrativas, sendo que utilizadores adicionais serão criados por si.", - "removing_offline_files": "Removendo arquivos offline", + "removing_offline_files": "A remover ficheiros offline", "repair_all": "Reparar tudo", - "repair_matched_items": "Encontrado {count, plural, one {# item} other {# itens}}", - "repaired_items": "Reparado {count, plural, one {# item} other {# itens}}", - "require_password_change_on_login": "Exigir que o utilizador altere a senha no primeiro início de sessão", - "reset_settings_to_default": "Redefinir as configurações para o padrão", - "reset_settings_to_recent_saved": "Redefinir as configurações para as configurações salvas recentemente", - "scanning_library_for_changed_files": "Escaneando a biblioteca em busca de arquivos alterados", - "scanning_library_for_new_files": "Escaneando a biblioteca em busca de novos arquivos", + "repair_matched_items": "Encontrado(s) {count, plural, one {# item} other {# itens}}", + "repaired_items": "Reparado(s) {count, plural, one {# item} other {# itens}}", + "require_password_change_on_login": "Exigir que o utilizador altere a palavra-passe no primeiro início de sessão", + "reset_settings_to_default": "Redefinir as definições para o padrão", + "reset_settings_to_recent_saved": "Redefinir as definições para as guardadas mais recentemente", + "scanning_library_for_changed_files": "A analisar a biblioteca por ficheiros alterados", + "scanning_library_for_new_files": "A analisar a biblioteca por ficheiros novos", + "search_jobs": "Pesquisar tarefas...", "send_welcome_email": "Enviar e-mail de boas-vindas", "server_external_domain_settings": "Domínio externo", - "server_external_domain_settings_description": "Domínio para links públicos compartilhados, incluindo http(s)://", - "server_settings": "Configurações do servidor", - "server_settings_description": "Gerenciar configurações do servidor", + "server_external_domain_settings_description": "Domínio para links públicos partilhados, incluindo http(s)://", + "server_settings": "Definições do Servidor", + "server_settings_description": "Gerir definições do servidor", "server_welcome_message": "Mensagem de boas-vindas", - "server_welcome_message_description": "Uma mensagem exibida na página de login.", + "server_welcome_message_description": "Uma mensagem que é exibida na página de inicio de sessão.", "sidecar_job": "Metadados secundários", - "sidecar_job_description": "Descubra ou sincronize metadados secundários do sistema de arquivos", + "sidecar_job_description": "Descobrir ou sincronizar metadados secundários a partir do sistema de ficheiros", "slideshow_duration_description": "Tempo em segundos para exibir cada imagem", - "smart_search_job_description": "Execute a aprendizagem automática em arquivos para oferecer suporte à pesquisa inteligente", - "storage_template_date_time_description": "O registro de data e hora da criação é usado para fornecer essas informações", + "smart_search_job_description": "Execute a aprendizagem automática em ficheiros para oferecer apoio à Pesquisa Inteligente", + "storage_template_date_time_description": "O registo de data e hora de criação do ficheiro é usado para fornecer essas informações", "storage_template_date_time_sample": "Exemplo de tempo {date}", - "storage_template_enable_description": "Habilitar mecanismo de modelo de armazenamento", + "storage_template_enable_description": "Ativar mecanismo de modelo de armazenamento", "storage_template_hash_verification_enabled": "Verificação de hash ativada", - "storage_template_hash_verification_enabled_description": "Ativa a verificação de hash, não desative esta opção a menos que tenha certeza das implicações", + "storage_template_hash_verification_enabled_description": "Ativa a verificação de hash, não desative esta opção a menos que tenha a certeza das implicações", "storage_template_migration": "Migração de modelo de armazenamento", - "storage_template_migration_description": "Aplicar o {template} atual para arquivos previamente carregados", - "storage_template_migration_info": "As mudanças do modelo apenas se aplicarão a novos arquivos. Para aplicar o modelo retroativamente para os arquivos carregados anteriormente, execute o {job}.", - "storage_template_migration_job": "Trabalho de migração do modelo de armazenamento", - "storage_template_more_details": "Para mais informações sobre esta funcionalidade, dirija-se a Modelo de Armazenamento e as suas implicações", - "storage_template_onboarding_description": "Quando ativada, esta funcionalidade irá organizar os ficheiros automaticamente baseando-se num modelo definido pelo utilizador. Devido a problemas de estabilidade esta funcionalidade está desativada por defeito. Para mais informações, por favor leia a documentação.", + "storage_template_migration_description": "Aplica o {template} atual para ficheiros previamente carregados", + "storage_template_migration_info": "As mudanças do modelo apenas se aplicarão a novos ficheiros. Para aplicar o modelo retroativamente para os ficheiros carregados anteriormente, execute o {job}.", + "storage_template_migration_job": "Tarefa de Migração do Modelo de Armazenamento", + "storage_template_more_details": "Para mais informações sobre esta funcionalidade, dirija-se a Modelo de Armazenamento e às suas implicações", + "storage_template_onboarding_description": "Quando ativada, esta funcionalidade irá organizar os ficheiros automaticamente baseando-se num modelo definido pelo utilizador. Devido a problemas de estabilidade esta funcionalidade está desativada por padrão. Para mais informações, por favor leia a documentação.", "storage_template_path_length": "Limite aproximado do tamanho do caminho: {length, number}{limit, number}", - "storage_template_settings": "Modelo de armazenamento", - "storage_template_settings_description": "Gerenciar a estrutura de pastas e o nome do arquivo dos ativos carregados", + "storage_template_settings": "Modelo de Armazenamento", + "storage_template_settings_description": "Gerir a estrutura de pastas e o nome do ficheiro carregado", "storage_template_user_label": "{label} é o Rótulo do Armazenamento do utilizador", - "system_settings": "Configurações de Sistema", - "theme_custom_css_settings": "CSS customizado", - "theme_custom_css_settings_description": "Folhas de estilo em cascata permitem que o design do Immich seja personalizado.", - "theme_settings": "Configurações de tema", - "theme_settings_description": "Gerencie a personalização da interface web do Immich", - "these_files_matched_by_checksum": "Esses arquivos são correspondidos por seus checksum", + "system_settings": "Definições de Sistema", + "tag_cleanup_job": "Limpeza de etiquetas", + "theme_custom_css_settings": "CSS Personalizado", + "theme_custom_css_settings_description": "Folhas de estilo em cascata (CSS) permitem que o design do Immich seja personalizado.", + "theme_settings": "Definições de Tema", + "theme_settings_description": "Gerir a personalização da interface web do Immich", + "these_files_matched_by_checksum": "Estes ficheiros são correspondidos pelas suas somas de verificação", "thumbnail_generation_job": "Gerar miniaturas", - "thumbnail_generation_job_description": "Gere miniaturas grandes, pequenas e desfocadas para cada ativo, bem como miniaturas para cada pessoa", + "thumbnail_generation_job_description": "Gera miniaturas grandes, pequenas e desfocadas para cada ficheiro, bem como miniaturas para cada pessoa", "transcode_policy_description": "", "transcoding_acceleration_api": "API de aceleração", - "transcoding_acceleration_api_description": "A API que irá interagir com o seu dispositivo para acelerar a transcodificação. Esta configuração é a 'melhor opção': ela retornará à transcodificação de software em caso de falha. O VP9 pode não funcionar dependendo do seu hardware.", + "transcoding_acceleration_api_description": "A API que irá interagir com o seu dispositivo para acelerar a transcodificação. Esta definição é a 'melhor opção': ela voltará à transcodificação de software em caso de falha. O VP9 pode não funcionar dependendo do seu hardware.", "transcoding_acceleration_nvenc": "NVENC (requer GPU NVIDIA)", "transcoding_acceleration_qsv": "Quick Sync (requer CPU Intel de 7ª geração ou posterior)", "transcoding_acceleration_rkmpp": "RKMPP (apenas em SOCs Rockchip)", "transcoding_acceleration_vaapi": "VAAPI", - "transcoding_accepted_audio_codecs": "Codecs de áudio aceitos", - "transcoding_accepted_audio_codecs_description": "Selecione quais codecs de áudio não precisam ser transcodificados. Usado apenas para determinadas políticas de transcodificação.", + "transcoding_accepted_audio_codecs": "Codecs de áudio aceites", + "transcoding_accepted_audio_codecs_description": "Selecione os codecs de áudio que não precisam de ser transcodificados. Usado apenas para determinadas políticas de transcodificação.", "transcoding_accepted_containers": "Contentores aceites", - "transcoding_accepted_containers_description": "Selecione os formatos de contentores que não precisam de ser remuxed para MP4. Apenas usados para algumas políticas de transcodificação.", + "transcoding_accepted_containers_description": "Selecione os formatos de contentores que não precisam de ser remisturados para MP4. Usado apenas para algumas políticas de transcodificação.", "transcoding_accepted_video_codecs": "Codecs de vídeo aceitos", - "transcoding_accepted_video_codecs_description": "Selecione quais codecs de vídeo não precisam ser transcodificados. Usado apenas para determinadas políticas de transcodificação.", - "transcoding_advanced_options_description": "Opções que a maioria dos utilizadores não deveria precisar alterar", + "transcoding_accepted_video_codecs_description": "Selecione quais os codecs de vídeo que não precisam de ser transcodificados. Usado apenas para determinadas políticas de transcodificação.", + "transcoding_advanced_options_description": "Opções que a maioria dos utilizadores não deverá precisar de alterar", "transcoding_audio_codec": "Codec de áudio", - "transcoding_audio_codec_description": "Opus é a opção de mais alta qualidade, mas tem menor compatibilidade com dispositivos ou softwares antigos.", - "transcoding_bitrate_description": "Vídeos com taxa de bits superior à máxima ou que não estão em um formato aceito", - "transcoding_codecs_learn_more": "Para aprender mais sobre as terminologias utilizadas aqui, consulte a documentação do FFmpeg para o codec H.264, codec HEVC e codec VP9.", + "transcoding_audio_codec_description": "Opus é a opção de mais alta qualidade, mas tem menor compatibilidade com dispositivos ou software antigos.", + "transcoding_bitrate_description": "Vídeos com taxa de bits superior à máxima ou que não estão num formato aceite", + "transcoding_codecs_learn_more": "Para saber mais sobre as terminologias utilizadas aqui, consulte a documentação do FFmpeg para o codec H.264, codec HEVC e codec VP9.", "transcoding_constant_quality_mode": "Modo de qualidade constante", "transcoding_constant_quality_mode_description": "ICQ é melhor que CQP, mas alguns dispositivos de aceleração de hardware não suportam este modo. Definir esta opção dará preferência ao modo especificado ao usar codificação baseada em qualidade. Ignorado pelo NVENC porque não suporta ICQ.", "transcoding_constant_rate_factor": "Fator de taxa constante (-crf)", - "transcoding_constant_rate_factor_description": "Nível de qualidade do vídeo. Os valores típicos são 23 para H.264, 28 para HEVC, 31 para VP9 e 35 para AV1. Menor é melhor, mas produz arquivos maiores.", - "transcoding_disabled_description": "Não transcodifique nenhum vídeo, pois pode interromper a reprodução em alguns clientes", + "transcoding_constant_rate_factor_description": "Nível de qualidade do vídeo. Os valores típicos são 23 para H.264, 28 para HEVC, 31 para VP9 e 35 para AV1. Menor é melhor, mas produz ficheiros maiores.", + "transcoding_disabled_description": "Não transcodificar nenhum vídeo, no entanto pode causar erros de reprodução em alguns clientes", "transcoding_hardware_acceleration": "Aceleração de hardware", "transcoding_hardware_acceleration_description": "Experimental; muito mais rápido, mas terá qualidade inferior com a mesma taxa de bits", "transcoding_hardware_decoding": "Decodificação de hardware", "transcoding_hardware_decoding_setting_description": "Aplica-se apenas a NVENC, QSV e RKMPP. Permite aceleração ponta a ponta em vez de apenas acelerar a codificação. Pode não funcionar em todos os vídeos.", "transcoding_hevc_codec": "Codec HEVC", "transcoding_max_b_frames": "Máximo de quadros B", - "transcoding_max_b_frames_description": "Valores mais altos melhoram a eficiência da compactação, mas retardam a codificação. Pode não ser compatível com aceleração de hardware em dispositivos mais antigos. 0 desativa os quadros B, enquanto -1 define esse valor automaticamente.", + "transcoding_max_b_frames_description": "Valores mais altos melhoram a eficiência da compressão, mas tornam a codificação mais lenta. Pode não ser compatível com aceleração de hardware em dispositivos mais antigos. 0 desativa os quadros B, enquanto -1 define esse valor automaticamente.", "transcoding_max_bitrate": "Taxa de bits máxima", - "transcoding_max_bitrate_description": "Definir uma taxa de bits máxima pode tornar os tamanhos dos arquivos mais previsíveis com um custo menor de qualidade. Em 720p, os valores típicos são 2.600k para VP9 ou HEVC, ou 4.500k para H.264. Desativado se definido como 0.", + "transcoding_max_bitrate_description": "Definir uma taxa de bits máxima pode tornar os tamanhos dos ficheiros mais previsíveis com um custo menor de qualidade. Em 720p, os valores típicos são 2.600k para VP9 ou HEVC, ou 4.500k para H.264. Desativado se definido como 0.", "transcoding_max_keyframe_interval": "Intervalo máximo de quadro-chave", - "transcoding_max_keyframe_interval_description": "Define a distância máxima do quadro entre os quadros-chave. Valores mais baixos pioram a eficiência da compressão, mas melhoram os tempos de busca e podem melhorar a qualidade em cenas com movimento rápido. 0 define esse valor automaticamente.", - "transcoding_optimal_description": "Vídeos com resolução superior à desejada ou em formato não aceito", + "transcoding_max_keyframe_interval_description": "Define a distância máxima do quadro entre os quadros-chave. Valores mais baixos pioram a eficiência da compressão, mas melhoram os tempos de procura e podem melhorar a qualidade em cenas com movimento rápido. 0 define esse valor automaticamente.", + "transcoding_optimal_description": "Vídeos com resolução superior à desejada ou num formato não aceite", "transcoding_preferred_hardware_device": "Dispositivo de hardware preferido", "transcoding_preferred_hardware_device_description": "Aplica-se apenas a VAAPI e QSV. Define o nó dri usado para transcodificação de hardware.", - "transcoding_preset_preset": "Predefinido (-preset)", - "transcoding_preset_preset_description": "Velocidade de compressão. Predefinições mais lentas produzem arquivos menores e aumentam a qualidade ao atingir uma determinada taxa de bits. VP9 ignora velocidades acima de \"mais rápidas\".", + "transcoding_preset_preset": "Predefinição (-preset)", + "transcoding_preset_preset_description": "Velocidade de compressão. Predefinições mais lentas produzem ficheiros menores e aumentam a qualidade ao atingir uma determinada taxa de bits. VP9 ignora velocidades acima de \"mais rápido\".", "transcoding_reference_frames": "Quadros de referência", - "transcoding_reference_frames_description": "O número de quadros a serem referenciados ao compactar um determinado quadro. Valores mais altos melhoram a eficiência da compactação, mas retardam a codificação. 0 define esse valor automaticamente.", - "transcoding_required_description": "Somente vídeos que não estejam em um formato aceito", - "transcoding_settings": "Configurações de transcodificação de vídeo", - "transcoding_settings_description": "Gerencie as informações de resolução e codificação dos arquivos de vídeo", + "transcoding_reference_frames_description": "O número de quadros a serem referenciados ao comprimir um determinado quadro. Valores mais altos melhoram a eficiência da compressão, mas tornam a codificação mais lenta. 0 define esse valor automaticamente.", + "transcoding_required_description": "Apenas vídeos que não estejam num formato aceite", + "transcoding_settings": "Definições de transcodificação de vídeo", + "transcoding_settings_description": "Gerir as informações de resolução e codificação dos ficheiros de vídeo", "transcoding_target_resolution": "Resolução desejada", - "transcoding_target_resolution_description": "Resoluções mais altas podem preservar mais detalhes, mas demoram mais para codificar, têm tamanhos de arquivo maiores e podem reduzir a capacidade de resposta do aplicativo.", + "transcoding_target_resolution_description": "Resoluções mais altas podem preservar mais detalhes, mas demoram mais para codificar, têm tamanhos de ficheiro maiores e podem reduzir a capacidade de resposta da aplicação.", "transcoding_temporal_aq": "QA temporal", "transcoding_temporal_aq_description": "Aplica-se apenas ao NVENC. Aumenta a qualidade de cenas com alto detalhe e pouco movimento. Pode não ser compatível com dispositivos mais antigos.", "transcoding_threads": "Threads", - "transcoding_threads_description": "Valores mais altos levam a uma codificação mais rápida, mas deixam menos espaço para o servidor processar outras tarefas enquanto estiver ativo. Este valor não deve ser superior ao número de núcleos da CPU. Maximiza a utilização se definido como 0.", + "transcoding_threads_description": "Valores mais altos levam a uma codificação mais rápida, mas deixam menos espaço para o servidor processar outras tarefas enquanto estiver ativo. Este valor não deve ser superior ao número de núcleos do CPU. Maximiza a utilização se definido como 0.", "transcoding_tone_mapping": "Mapeamento de tons", "transcoding_tone_mapping_description": "Tenta preservar a aparência dos vídeos HDR quando convertidos para SDR. Cada algoritmo faz compensações diferentes em termos de cor, detalhes e brilho. Hable preserva os detalhes, Mobius preserva as cores e Reinhard preserva o brilho.", "transcoding_tone_mapping_npl": "NPL de mapeamento de tons", - "transcoding_tone_mapping_npl_description": "As cores serão ajustadas para parecerem normais para uma exibição com esse brilho. Contra-intuitivamente, valores mais baixos aumentam o brilho do vídeo e vice-versa, uma vez que compensam o brilho da tela. 0 define esse valor automaticamente.", + "transcoding_tone_mapping_npl_description": "As cores serão ajustadas para parecerem normais para uma exibição com esse brilho. Contra-intuitivamente, valores mais baixos aumentam o brilho do vídeo e vice-versa, uma vez que compensam o brilho do ecrã. 0 define esse valor automaticamente.", "transcoding_transcode_policy": "Política de transcodificação", - "transcoding_transcode_policy_description": "Política para quando um vídeo deve ser transcodificado. Os vídeos HDR sempre serão transcodificados (exceto se a transcodificação estiver desativada).", - "transcoding_two_pass_encoding": "Codificação de duas passagens", - "transcoding_two_pass_encoding_setting_description": "Transcodifique em duas passagens para produzir vídeos melhor codificados. Quando a taxa de bits máxima está habilitada (necessária para funcionar com H.264 e HEVC), este modo usa um intervalo de taxa de bits baseado na taxa de bits máxima e ignora o CRF. Para VP9, o CRF pode ser usado se a taxa de bits máxima estiver desabilitada.", + "transcoding_transcode_policy_description": "Política para quando um vídeo deve ser transcodificado. Os vídeos HDR serão sempre transcodificados (exceto se a transcodificação estiver desativada).", + "transcoding_two_pass_encoding": "Codificação em duas passagens", + "transcoding_two_pass_encoding_setting_description": "Transcodificar em duas passagens para produzir vídeos melhor codificados. Quando a taxa de bits máxima está ativada (necessário para funcionar com H.264 e HEVC), este modo usa um intervalo de taxa de bits baseado na taxa de bits máxima e ignora o CRF. Para VP9, o CRF pode ser usado se a taxa de bits máxima estiver desativada.", "transcoding_video_codec": "Codec de vídeo", - "transcoding_video_codec_description": "O VP9 tem alta eficiência e compatibilidade com a web, mas leva mais tempo para transcodificar. HEVC tem desempenho semelhante, mas tem menor compatibilidade com a web. H.264 é amplamente compatível e rápido de transcodificar, mas produz arquivos muito maiores. AV1 é o codec mais eficiente, mas não possui suporte em dispositivos mais antigos.", - "trash_enabled_description": "Ativar recursos da Lixeira", + "transcoding_video_codec_description": "O VP9 tem alta eficiência e compatibilidade com a web, mas leva mais tempo para transcodificar. HEVC tem desempenho semelhante, mas tem menor compatibilidade com a web. H.264 é amplamente compatível e rápido de transcodificar, mas produz ficheiros muito maiores. AV1 é o codec mais eficiente, mas não possui suporte em dispositivos mais antigos.", + "trash_enabled_description": "Ativar funcionalidade da Reciclagem", "trash_number_of_days": "Número de dias", - "trash_number_of_days_description": "Número de dias para manter os arquivos na lixeira antes de eliminar permanentemente", - "trash_settings": "Configurações da Lixeira", - "trash_settings_description": "Gerenciar configurações da lixeira", - "untracked_files": "Arquivos não rastreados", - "untracked_files_description": "Esses arquivos não são rastreados pelo aplicativo. Eles podem ser o resultado de movimentos malsucedidos, carregamentos interrompidos ou deixados para trás devido a um bug", - "user_delete_delay": "A conta e os arquivos de {user} serão agendados para eliminação permanente em {delay, plural, one {# dia} other {# dias}}.", - "user_delete_delay_settings": "Excluir atraso", - "user_delete_delay_settings_description": "Número de dias após a remoção para excluir permanentemente a conta e os arquivos de um utilizador. O trabalho de exclusão de utilizadores é executado à meia-noite para verificar utilizadores que estão prontos para exclusão. As alterações nesta configuração serão avaliadas na próxima execução.", - "user_delete_immediately": "A conta e os arquivos de {user} serão enfileirados para exclusão permanente imediatamente.", - "user_delete_immediately_checkbox": "Adicionar utilizador e arquivos à fila para eliminação imediata", - "user_management": "Gerenciamento de utilizadores", - "user_password_has_been_reset": "A senha do utilizador foi redefinida:", - "user_password_reset_description": "Forneça a senha temporária ao utilizador e informe que ele precisará alterar a senha no próximo início de sessão.", + "trash_number_of_days_description": "Número de dias para manter os ficheiros na reciclagem antes de os eliminar permanentemente", + "trash_settings": "Definições da Reciclagem", + "trash_settings_description": "Gerir definições da reciclagem", + "untracked_files": "Ficheiros não monitorizados", + "untracked_files_description": "Estes ficheiros não são monitorizados pela aplicação. Eles podem ser o resultado de transferências mal-sucedidas, carregamentos interrompidos ou deixados para trás devido a um problema", + "user_cleanup_job": "Limpeza de utilizadores", + "user_delete_delay": "A conta e os ficheiros de {user} serão agendados para eliminação permanente dentro de {delay, plural, one {# dia} other {# dias}}.", + "user_delete_delay_settings": "Atraso de eliminação", + "user_delete_delay_settings_description": "Número de dias após a remoção para excluir permanentemente a conta e os ficheiros de um utilizador. A tarefa de eliminação de utilizadores é executada à meia-noite para verificar utilizadores que estão prontos para eliminação. As alterações a esta definição serão avaliadas na próxima execução.", + "user_delete_immediately": "A conta e os ficheiros de {user} serão colocados em fila para eliminação permanente de imediato.", + "user_delete_immediately_checkbox": "Adicionar utilizador e ficheiros à fila para eliminação imediata", + "user_management": "Gestão de utilizadores", + "user_password_has_been_reset": "A palavra-passe do utilizador foi redefinida:", + "user_password_reset_description": "Por favor forneça a palavra-passe temporária ao utilizador e informe-o(a) de que será necessário alterá-la próximo início de sessão.", "user_restore_description": "A conta de {user} será restaurada.", - "user_restore_scheduled_removal": "Restaurar usuário - planejar remoção em {date, date, long}", - "user_settings": "Configurações do Utilizador", - "user_settings_description": "Gerenciar configurações do utilizador", + "user_restore_scheduled_removal": "Restaurar utilizador - remoção agendada em {date, date, long}", + "user_settings": "Definições do Utilizador", + "user_settings_description": "Gerir definições do utilizador", "user_successfully_removed": "O utilizador {email} foi removido com sucesso.", "version_check_enabled_description": "Ativa verificação de novas versões", - "version_check_implications": "A funcionalidade de verificação da versão necessita comunicação periodica com github.com", + "version_check_implications": "A funcionalidade de verificação da versão necessita de comunicação periódica com o github.com", "version_check_settings": "Verificação de versão", "version_check_settings_description": "Ativar/desativar a notificação de nova versão", "video_conversion_job": "Transcodificar vídeos", - "video_conversion_job_description": "Transcodifique vídeos para maior compatibilidade com navegadores e dispositivos" + "video_conversion_job_description": "Transcodificar vídeos para maior compatibilidade com navegadores e dispositivos" }, "admin_email": "E-mail do administrador", - "admin_password": "Senha do administrador", + "admin_password": "Palavra-passe do administrador", "administration": "Administração", "advanced": "Avançado", "age_months": "Idade {months, plural, one {# mês} other {# meses}}", "age_year_months": "Idade 1 ano, {months, plural, one {# mês} other {# meses}}", - "age_years": "Idade {years, plural, one{# ano} other {# anos}}", + "age_years": "{years, plural, one{# ano} other {# anos}}", "album_added": "Álbum adicionado", - "album_added_notification_setting_description": "Receba uma notificação por e-mail quando você for adicionado a um álbum compartilhado", + "album_added_notification_setting_description": "Receber uma notificação por e-mail quando for adicionado a um álbum partilhado", "album_cover_updated": "Capa do álbum atualizada", - "album_delete_confirmation": "Tem a certeza que quer apagar o álbum {album}? Se o álbum for partilhado, os outros utilizadores não poderão aceder-lhe novamente.", - "album_delete_confirmation_description": "Se este álbum for partilhado, os outros utilizadores deixam de poder aceder.", + "album_delete_confirmation": "Tem a certeza de que quer eliminar o álbum {album}?", + "album_delete_confirmation_description": "Se este álbum for partilhado, os outros utilizadores deixam de o poder aceder.", "album_info_updated": "Informações do álbum atualizadas", "album_leave": "Sair do álbum?", - "album_leave_confirmation": "Tem a certeza que quer sair de {album}?", + "album_leave_confirmation": "Tem a certeza de que quer sair de {album}?", "album_name": "Nome do álbum", "album_options": "Opções de álbum", "album_remove_user": "Remover utilizador?", - "album_remove_user_confirmation": "Tem a certeza que quer remover {user}?", - "album_share_no_users": "Parece que tem este álbum partilhado com todos os utilizadores ou que não existem utilizadores para o partilhar.", + "album_remove_user_confirmation": "Tem a certeza de que quer remover {user}?", + "album_share_no_users": "Parece que tem este álbum partilhado com todos os utilizadores ou que não existem utilizadores com quem o partilhar.", "album_updated": "Álbum atualizado", - "album_updated_setting_description": "Receba uma notificação por e-mail quando um álbum compartilhado tiver novos arquivos", - "album_user_left": "Saída {album}", + "album_updated_setting_description": "Receber uma notificação por e-mail quando um álbum partilhado tiver novos ficheiros", + "album_user_left": "Saíu do {album}", "album_user_removed": "Utilizador {user} removido", - "album_with_link_access": "Permite acesso a fotos e pessoas deste album por qualquer pessoa com o link.", + "album_with_link_access": "Permite o acesso a fotos e pessoas deste álbum por qualquer pessoa com o link.", "albums": "Álbuns", "albums_count": "{count, plural, one {{count, number} Álbum} other {{count, number} Álbuns}}", "all": "Todos", @@ -362,68 +371,69 @@ "all_videos": "Todos os vídeos", "allow_dark_mode": "Permitir modo escuro", "allow_edits": "Permitir edições", - "allow_public_user_to_download": "Permit acesso de download ao user publico", - "allow_public_user_to_upload": "Permite acesso de upload ao user publico", + "allow_public_user_to_download": "Permitir que utilizadores públicos façam transferências", + "allow_public_user_to_upload": "Permitir que utilizadores públicos façam carregamentos", "anti_clockwise": "Sentido anti-horário", "api_key": "Chave de API", "api_key_description": "Este valor será apresentado apenas uma única vez. Por favor, certifique-se que o copiou antes de fechar a janela.", - "api_key_empty": "O nome da API Key não pode ser vazio", + "api_key_empty": "O nome da chave a API não pode estar vazio", "api_keys": "Chaves de API", - "app_settings": "Configurações do Aplicativo", + "app_settings": "Definições da Aplicação", "appears_in": "Aparece em", "archive": "Arquivo", "archive_or_unarchive_photo": "Arquivar ou desarquivar foto", "archive_size": "Tamanho do arquivo", - "archive_size_description": "Configure o tamanho do arquivo para downloads (em GiB)", + "archive_size_description": "Configure o tamanho do arquivo para transferências (em GiB)", "archived": "Arquivado", "archived_count": "{count, plural, other {Arquivado #}}", - "are_these_the_same_person": "São a mesma pessoa?", - "are_you_sure_to_do_this": "Tem a certeza que quer fazer isto?", + "are_these_the_same_person": "Estas pessoas são a mesma pessoa?", + "are_you_sure_to_do_this": "Tem a certeza de que quer fazer isto?", "asset_added_to_album": "Adicionado ao álbum", "asset_adding_to_album": "A adicionar ao álbum...", - "asset_description_updated": "A descrição do arquivo foi atualizada", - "asset_filename_is_offline": "O arquivo {filename} está offline", - "asset_has_unassigned_faces": "O arquivo tem rostos sem atribuição", - "asset_hashing": "Hashing...", - "asset_offline": "Ativo off-line", - "asset_offline_description": "Este arquivo está offline. Immich não consegue acessar o local do arquivo. Certifique-se de que o arquivo esteja disponível e, em seguida, escaneie a biblioteca novamente.", + "asset_description_updated": "A descrição do ficheiro foi atualizada", + "asset_filename_is_offline": "O ficheiro {filename} não está disponível", + "asset_has_unassigned_faces": "O ficheiro tem rostos não atribuídas", + "asset_hashing": "A criar hash...", + "asset_offline": "Ficheiro indisponível", + "asset_offline_description": "Este ficheiro está indisponível. O Immich não consegue aceder ao local do local. Certifique-se de que o ficheiro está disponível e, em seguida, analise a biblioteca novamente.", "asset_skipped": "Ignorado", + "asset_skipped_in_trash": "Na reciclagem", "asset_uploaded": "Enviado", - "asset_uploading": "Em upload...", - "assets": "Arquivos", - "assets_added_count": "{count, plural, one {# arquivo adicionado} other {# arquivos adicionados}}", - "assets_added_to_album_count": "{count, plural, one {# arquivo adicionado} other {# arquivos adicionados}} ao álbum", - "assets_added_to_name_count": "{count, plural, one {# arquivo adicionado} other {# arquivos adicionados}} a {hasName, select, true {{name}} other {novo álbum}}", - "assets_count": "{count, plural, one {# arquivo} other {# arquivos}}", + "asset_uploading": "A enviar...", + "assets": "Ficheiros", + "assets_added_count": "{count, plural, one {# ficheiro adicionado} other {# ficheiros adicionados}}", + "assets_added_to_album_count": "{count, plural, one {# ficheiro adicionado} other {# ficheiros adicionados}} ao álbum", + "assets_added_to_name_count": "{count, plural, one {# ficheiro adicionado} other {# ficheiros adicionados}} a {hasName, select, true {{name}} other {novo álbum}}", + "assets_count": "{count, plural, one {# ficheiro} other {# ficheiros}}", "assets_moved_to_trash": "{count, plural, one {# ativo enviado} other {# ativos enviados}} para a lixeira", - "assets_moved_to_trash_count": "{count, plural, one {# arquivo movido} other {# arquivos movidos}} para a lixeira", - "assets_permanently_deleted_count": "{count, plural, one {# arquivo} other {# arquivos}} excluídos permanentemente", - "assets_removed_count": "{count, plural, one {# arquivo excluído} other {# arquivos excluídos}}", - "assets_restore_confirmation": "Tem a certeza que quer recuperar todos os artigos apagados? Não é possivel voltar atrás nesta acção!", - "assets_restored_count": "{count, plural, one {# arquivo restaurado} other {# arquivos restaurados}}", - "assets_trashed_count": "{count, plural, one {# arquivo enviado} other {# arquivos enviados}} para a lixeira", - "assets_were_part_of_album_count": "{count, plural, one {Arquivo já era} other {Os arquivos já eram}} parte do álbum", + "assets_moved_to_trash_count": "{count, plural, one {# ficheiro movido} other {# aficheiros movidos}} para a reciclagem", + "assets_permanently_deleted_count": "{count, plural, one {# ficheiro} other {# ficheiros}} eliminados permanentemente", + "assets_removed_count": "{count, plural, one {# ficheiro eliminado} other {# ficheiros eliminados}}", + "assets_restore_confirmation": "Tem a certeza de que quer recuperar todos os ficheiros apagados? Não é possível anular esta ação!", + "assets_restored_count": "{count, plural, one {# ficheiro restaurado} other {# ficheiros restaurados}}", + "assets_trashed_count": "{count, plural, one {# ficheiro enviado} other {# ficheiros enviados}} para a reciclagem", + "assets_were_part_of_album_count": "{count, plural, one {O ficheiro já fazia} other {Os ficheiros já faziam}} parte do álbum", "authorized_devices": "Dispositivos Autorizados", "back": "Voltar", "back_close_deselect": "Voltar, fechar ou desmarcar", "backward": "Para trás", "birthdate_saved": "Data de nascimento guardada com sucesso", - "birthdate_set_description": "A data de nascimento é usada para calcular a idade desta pessoa no momento em que uma fotografia foi tirada.", + "birthdate_set_description": "A data de nascimento é utilizada para calcular a idade desta pessoa no momento em que uma fotografia foi tirada.", "blurred_background": "Fundo desfocado", - "build": "Construir", - "build_image": "Construir Imagem", - "bulk_delete_duplicates_confirmation": "Tem a certeza de que deseja excluir {count, plural, one {# arquivo duplicado} other {# arquivos duplicados}}? Esta ação mantém o maior arquivo de cada grupo e exclui permanentemente todas as outras duplicidades. Você não pode desfazer esta ação!", - "bulk_keep_duplicates_confirmation": "Tem certeza de que deseja manter {count, plural, one {# arquivo duplicado} other {# arquivos duplicados}}? Isso resolverá todos os grupos duplicados sem excluir nada.", - "bulk_trash_duplicates_confirmation": "Tem a certeza de que deseja mover para a lixeira {count, plural, one {# arquivo duplicado} other {# arquivos duplicados}}? Isso manterá o maior arquivo de cada grupo e moverá para a lixeira todas as outras duplicidades.", + "build": "Versão de compilação", + "build_image": "Imagem de compilação", + "bulk_delete_duplicates_confirmation": "Tem a certeza de que deseja eliminar {count, plural, one {# ficheiro duplicado} other {# ficheiros duplicados}}? Esta ação mantém o maior ficheiro de cada grupo e elimina permanentemente todos os outros duplicados. Não é possível anular esta ação!", + "bulk_keep_duplicates_confirmation": "Tem a certeza de que deseja manter {count, plural, one {# ficheiro duplicado} other {# ficheiros duplicados}}? Isto resolverá todos os grupos duplicados sem eliminar nada.", + "bulk_trash_duplicates_confirmation": "Tem a certeza de que deseja mover para a recicalgem {count, plural, one {# ficheiro duplicado} other {# ficheiros duplicados}}? Isto manterá o maior ficheiro de cada grupo e irá mover para a reciclagem todos os outros duplicados.", "buy": "Comprar Immich", - "camera": "Câmera", - "camera_brand": "Marca da câmera", - "camera_model": "Modelo da câmera", + "camera": "Câmara", + "camera_brand": "Marca da câmara", + "camera_model": "Modelo da câmara", "cancel": "Cancelar", "cancel_search": "Cancelar pesquisa", - "cannot_merge_people": "Não é possível mesclar pessoas", - "cannot_undo_this_action": "Não pode voltar atrás nesta ação!", - "cannot_update_the_description": "Não é possível atualizar a descrição", + "cannot_merge_people": "Não foi possível unir pessoas", + "cannot_undo_this_action": "Não é possível anular esta ação!", + "cannot_update_the_description": "Não foi possível atualizar a descrição", "cant_apply_changes": "Não é possível aplicar alterações", "cant_get_faces": "Não foi possível obter faces", "cant_search_people": "Não foi possível pesquisar pessoas", @@ -433,13 +443,13 @@ "change_location": "Alterar localização", "change_name": "Alterar nome", "change_name_successfully": "Nome alterado com sucesso", - "change_password": "Mudar a senha", - "change_password_description": "Esta é a primeira vez que você está entrando no sistema ou uma solicitação foi feita para alterar sua senha. Insira a nova senha abaixo.", - "change_your_password": "Alterar sua senha", + "change_password": "Alterar a palavra-passe", + "change_password_description": "Esta é a primeira vez que está a entrar no sistema ou um pedido foi feito para alterar a sua palavra-passe. Insira a nova palavra-passe abaixo.", + "change_your_password": "Alterar a sua palavra-passe", "changed_visibility_successfully": "Visibilidade alterada com sucesso", "check_all": "Verificar tudo", - "check_logs": "Verificar registros", - "choose_matching_people_to_merge": "Escolha pessoas correspondentes para mesclar", + "check_logs": "Verificar registos", + "choose_matching_people_to_merge": "Escolha pessoas correspondentes para unir", "city": "Cidade", "clear": "Limpar", "clear_all": "Limpar tudo", @@ -450,26 +460,27 @@ "close": "Fechar", "collapse": "Colapsar", "collapse_all": "Colapsar tudo", - "color_theme": "Tema de cores", + "color": "Cor", + "color_theme": "Esquema de cores", "comment_deleted": "Comentário eliminado", "comment_options": "Opções de comentário", "comments_and_likes": "Comentários e gostos", "comments_are_disabled": "Comentários estão desativados", "confirm": "Confirmar", - "confirm_admin_password": "Confirmar senha de administrador", - "confirm_delete_shared_link": "Tem certeza de que deseja excluir este link compartilhado?", - "confirm_password": "Confirme a senha", - "contain": "Caber", + "confirm_admin_password": "Confirmar palavra-passe de administrador", + "confirm_delete_shared_link": "Tem certeza de que deseja eliminar este link partilhado?", + "confirm_password": "Confirmar a palavra-passe", + "contain": "Ajustar", "context": "Contexto", "continue": "Continuar", "copied_image_to_clipboard": "Imagem copiada para a área de transferência.", "copied_to_clipboard": "Copiado para a área de transferência!", "copy_error": "Copiar erro", - "copy_file_path": "Copiar caminho do arquivo", + "copy_file_path": "Copiar caminho do ficheiro", "copy_image": "Copiar Imagem", "copy_link": "Copiar link", "copy_link_to_clipboard": "Copiar link para a área de transferência", - "copy_password": "Copiar senha", + "copy_password": "Copiar palavra-passe", "copy_to_clipboard": "Copiar para a área de transferência", "country": "País", "cover": "Preencher", @@ -479,15 +490,17 @@ "create_library": "Criar biblioteca", "create_link": "Criar link", "create_link_to_share": "Criar link para partilhar", - "create_link_to_share_description": "Permiter a visualização desta imagem(s) a qualquer pessoa com este link", + "create_link_to_share_description": "Permitir a visualização desta(s) imagem(s) a qualquer pessoa com o link", "create_new_person": "Criar nova pessoa", - "create_new_person_hint": "Associe os arquivos para uma nova pessoa", + "create_new_person_hint": "Associe os ficheiros a uma nova pessoa", "create_new_user": "Criar novo utilizador", + "create_tag": "Criar etiqueta", + "create_tag_description": "Criar uma nova etiqueta. Para etiquetas compostas, introduza o caminho completo, incluindo as barras.", "create_user": "Criar utilizador", "created": "Criado", "current_device": "Dispositivo atual", - "custom_locale": "Localização Customizada", - "custom_locale_description": "Formatar datas e números baseados na linguagem e região", + "custom_locale": "Localização Personalizada", + "custom_locale_description": "Formatar datas e números baseados na língua e na região", "dark": "Escuro", "date_after": "Data após", "date_and_time": "Data e Hora", @@ -495,19 +508,21 @@ "date_of_birth_saved": "Data de nascimento guardada com sucesso", "date_range": "Intervalo de datas", "day": "Dia", - "deduplicate_all": "Limpar todas Duplicidades", + "deduplicate_all": "Limpar todos os itens duplicados", "default_locale": "Localização Padrão", "default_locale_description": "Formatar datas e números baseados na linguagem do seu navegador", - "delete": "Excluir", - "delete_album": "Excluir álbum", - "delete_api_key_prompt": "Tem certeza de que deseja excluir esta chave de API?", - "delete_duplicates_confirmation": "Tem certeza de que deseja excluir permanentemente estas duplicidades?", - "delete_key": "Excluir chave", - "delete_library": "Excluir biblioteca", - "delete_link": "Excluir link", - "delete_shared_link": "Excluir link de compartilhamento", - "delete_user": "Excluir utilizador", - "deleted_shared_link": "Link de compartilhamento excluído", + "delete": "Eliminar", + "delete_album": "Eliminar álbum", + "delete_api_key_prompt": "Tem certeza de que deseja eliminar esta chave de API?", + "delete_duplicates_confirmation": "Tem certeza de que deseja eliminar permanentemente estes itens duplicados?", + "delete_key": "Eliminar chave", + "delete_library": "Eliminar biblioteca", + "delete_link": "Eliminar link", + "delete_shared_link": "Eliminar link de partilha", + "delete_tag": "Eliminar etiqueta", + "delete_tag_confirmation_prompt": "Tem a certeza de que pretende eliminar a etiqueta {tagName} ?", + "delete_user": "Eliminar utilizador", + "deleted_shared_link": "Link de partilha eliminado", "description": "Descrição", "details": "Detalhes", "direction": "Direção", @@ -519,19 +534,19 @@ "display_options": "Opções de exibição", "display_order": "Ordem de exibição", "display_original_photos": "Exibir fotos originais", - "display_original_photos_setting_description": "Prefira exibir a foto original ao visualizar um ativo em vez de miniaturas quando o ativo original é compatível com a web. Isso pode diminuir a velocidade de exibição das fotos.", + "display_original_photos_setting_description": "Preferir a exibição da foto original ao visualizar um ficheiro em vez de miniaturas quando o ficheiro original é compatível com a web. Isso pode diminuir a velocidade de exibição das fotos.", "do_not_show_again": "Não mostrar esta mensagem novamente", "done": "Feito", "download": "Transferir", "download_include_embedded_motion_videos": "Vídeos incorporados", - "download_include_embedded_motion_videos_description": "Incluir vídeos incorporados em fotos em movimento como um arquivo separado", + "download_include_embedded_motion_videos_description": "Incluir vídeos incorporados em fotos em movimento como um ficheiro separado", "download_settings": "Transferir", - "download_settings_description": "Gerenciar configurações relacionadas a transferir ativos", - "downloading": "Baixando", - "downloading_asset_filename": "A transferir o arquivo {filename}", - "drop_files_to_upload": "Coloque os ficheiros em qualquer lugar para fazer o upload", - "duplicates": "Duplicados", - "duplicates_description": "Marque cada grupo indicando quais arquivos, se algum, são duplicados", + "download_settings_description": "Gerir definições relacionadas com a transferência de ficheiros", + "downloading": "A transferir", + "downloading_asset_filename": "A transferir o ficheiro {filename}", + "drop_files_to_upload": "Solte os ficheiros em qualquer lugar para os enviar", + "duplicates": "Itens duplicados", + "duplicates_description": "Marque cada grupo indicando quais ficheiros, se algum, são duplicados", "duration": "Duração", "durations": { "days": "", @@ -542,11 +557,11 @@ }, "edit": "Editar", "edit_album": "Editar álbum", - "edit_avatar": "Editar foto de perfil", + "edit_avatar": "Editar imagem de perfil", "edit_date": "Editar data", "edit_date_and_time": "Editar data e hora", "edit_exclusion_pattern": "Editar o padrão de exclusão", - "edit_faces": "Editar faces", + "edit_faces": "Editar rostos", "edit_import_path": "Editar caminho de importação", "edit_import_paths": "Editar caminhos de importação", "edit_key": "Editar chave", @@ -554,150 +569,153 @@ "edit_location": "Editar Localização", "edit_name": "Editar nome", "edit_people": "Editar pessoas", + "edit_tag": "Editar etiqueta", "edit_title": "Editar Título", "edit_user": "Editar utilizador", "edited": "Editado", - "editor": "Editar", - "editor_close_without_save_prompt": "As alterações não serão salvas", + "editor": "Editor", + "editor_close_without_save_prompt": "As alterações não serão guardadas", "editor_close_without_save_title": "Fechar editor?", - "editor_crop_tool_h2_aspect_ratios": "Proporções de aspecto", + "editor_crop_tool_h2_aspect_ratios": "Relação de aspeto", "editor_crop_tool_h2_rotation": "Rotação", "email": "E-mail", "empty": "", "empty_album": "", - "empty_trash": "Esvaziar lixo", - "empty_trash_confirmation": "Tem certeza de que deseja esvaziar a lixeira? Isso removerá todos os arquivos da lixeira do Immich permanentemente.\nVocê não pode desfazer esta ação!", + "empty_trash": "Esvaziar reciclagem", + "empty_trash_confirmation": "Tem certeza de que deseja esvaziar a reciclagem? Isto removerá todos os ficheiros da reciclagem do Immich permanentemente.\nNão é possível anular esta ação!", "enable": "Ativar", "enabled": "Ativado", "end_date": "Data final", "error": "Erro", - "error_loading_image": "Erro ao carregar a página", + "error_loading_image": "Erro ao carregar a imagem", "error_title": "Erro - Algo correu mal", "errors": { - "cannot_navigate_next_asset": "Não pode navegar para o proximo artigo", - "cannot_navigate_previous_asset": "Não pode navegar para o artigo anterior", + "cannot_navigate_next_asset": "Não foi possível navegar para o próximo ficheiro", + "cannot_navigate_previous_asset": "Não foi possível navegar para o ficheiro anterior", "cant_apply_changes": "Não foi possível aplicar as alterações", - "cant_change_activity": "Não é possível {enabled, select, true {desativar} other {ativar}} atividade", - "cant_change_asset_favorite": "Não pode alterar o favorito deste artigo", - "cant_change_metadata_assets_count": "Não foi possível alterar os metadados de {count, plural, one {# arquivo} other {# arquivos}}", + "cant_change_activity": "Não foi possível {enabled, select, true {desativar} other {ativar}} atividade", + "cant_change_asset_favorite": "Não foi possível alterar o favorito deste ficheiro", + "cant_change_metadata_assets_count": "Não foi possível alterar os metadados de {count, plural, one {# ficheiro} other {# ficheiros}}", "cant_get_faces": "Não foi possível obter os rostos", "cant_get_number_of_comments": "Não foi possível obter o número de comentários", "cant_search_people": "Não foi possível pesquisar pessoas", "cant_search_places": "Não foi possível pesquisar locais", - "cleared_jobs": "Trabalhos eliminados para: {job}", - "error_adding_assets_to_album": "Erro ao adicionar arquivos ao álbum", - "error_adding_users_to_album": "Erro a adicionar utilizador ao album", - "error_deleting_shared_user": "Error a apagar o utilizador partilhado", - "error_downloading": "Erro a transferir {filename}", + "cleared_jobs": "Tarefas eliminadas para: {job}", + "error_adding_assets_to_album": "Erro ao adicionar ficheiros ao álbum", + "error_adding_users_to_album": "Erro ao adicionar utilizador ao álbum", + "error_deleting_shared_user": "Erro ao apagar o utilizador partilhado", + "error_downloading": "Erro ao transferir {filename}", "error_hiding_buy_button": "Erro ao esconder botão de compra", - "error_removing_assets_from_album": "Erro a eliminar artigos do album, verifique a consola para mais detalhes", - "error_selecting_all_assets": "Erro ao selecionar todos os arquivos", + "error_removing_assets_from_album": "Erro ao eliminar ficheiros do álbum, verifique a consola para mais detalhes", + "error_selecting_all_assets": "Erro ao selecionar todos os ficheiros", "exclusion_pattern_already_exists": "Este padrão de exclusão já existe.", - "failed_job_command": "Comando {command} falhou para o trabalho: {job}", - "failed_to_create_album": "Falha ao criar álbum", - "failed_to_create_shared_link": "Falhou a criar um link partilhado", - "failed_to_edit_shared_link": "Falhou a editar o link partilhado", - "failed_to_get_people": "Falha na obtenção de pessoas", - "failed_to_load_asset": "Falha ao carregar arquivo", - "failed_to_load_assets": "Falha ao carregar arquivos", - "failed_to_load_people": "Falha ao carregar pessoas", - "failed_to_remove_product_key": "Falha ao remover chave de produto", - "failed_to_stack_assets": "Falha ao empilhar os arquivos", - "failed_to_unstack_assets": "Falha ao desempilhar arquivos", + "failed_job_command": "Comando {command} falhou para a tarefa: {job}", + "failed_to_create_album": "Não foi possível criar álbum", + "failed_to_create_shared_link": "Não foi possível criar o link partilhado", + "failed_to_edit_shared_link": "Não foi possível editar o link partilhado", + "failed_to_get_people": "Não foi possível obter pessoas", + "failed_to_load_asset": "Não foi possível ler o ficheiro", + "failed_to_load_assets": "Não foi possível ler ficheiros", + "failed_to_load_people": "Não foi possível carregar pessoas", + "failed_to_remove_product_key": "Não foi possível remover chave de produto", + "failed_to_stack_assets": "Não foi possível empilhar os ficheiros", + "failed_to_unstack_assets": "Não foi possível desempilhar ficheiros", "import_path_already_exists": "Este caminho de importação já existe.", - "incorrect_email_or_password": "Email ou password incorretos", - "paths_validation_failed": "a validação de {paths, plural, one {# caminho falhou} other {# caminhos falharam}}", - "profile_picture_transparent_pixels": "Imagem de perfil não pode ter pixels transparentes. Por favor faça zoom in e/ou mova a imagem.", - "quota_higher_than_disk_size": "Você definiu uma cota maior do que o tamanho do disco", + "incorrect_email_or_password": "Email ou palavra-passe incorretos", + "paths_validation_failed": "A validação de {paths, plural, one {# caminho falhou} other {# caminhos falharam}}", + "profile_picture_transparent_pixels": "Imagem de perfil não pode ter pixeis transparentes. Por favor amplie e/ou mova a imagem.", + "quota_higher_than_disk_size": "Definiu uma quota maior do que o tamanho do disco", "repair_unable_to_check_items": "Não foi possível verificar {count, select, one {um item} other {alguns itens}}", "unable_to_add_album_users": "Não foi possível adicionar utilizadores ao álbum", - "unable_to_add_assets_to_shared_link": "Não foi possivel adicionar os artigos ao link partilhado", + "unable_to_add_assets_to_shared_link": "Não foi possível adicionar os ficheiros ao link partilhado", "unable_to_add_comment": "Não foi possível adicionar o comentário", "unable_to_add_exclusion_pattern": "Não foi possível adicionar o padrão de exclusão", "unable_to_add_import_path": "Não foi possível adicionar o caminho de importação", "unable_to_add_partners": "Não foi possível adicionar parceiros", - "unable_to_add_remove_archive": "Não é possível {archived, select, true {remover o arquivo de} other {adicionar o arquivo}}", - "unable_to_add_remove_favorites": "Não foi possível {favorite, select, true {adicionar arquivo aos} other {remover arquivo dos}} favoritos", - "unable_to_archive_unarchive": "Não é possível {archived, select, true {arquivar} other {desarquivar}}", + "unable_to_add_remove_archive": "Não foi possível {archived, select, true {remover o ficheiro de} other {adicionar o ficheiro}}", + "unable_to_add_remove_favorites": "Não foi possível {favorite, select, true {adicionar ficheiro aos} other {remover ficheiro dos}} favoritos", + "unable_to_archive_unarchive": "Não foi possível {archived, select, true {arquivar} other {desarquivar}}", "unable_to_change_album_user_role": "Não foi possível alterar a permissão do utilizador no álbum", "unable_to_change_date": "Não foi possível alterar a data", - "unable_to_change_favorite": "Não foi possivel mudar o favorito do artigo", + "unable_to_change_favorite": "Não foi possível mudar o favorito do ficheiro", "unable_to_change_location": "Não foi possível alterar a localização", - "unable_to_change_password": "Não foi possível alterar a senha", + "unable_to_change_password": "Não foi possível alterar a palavra-passe", "unable_to_change_visibility": "Não é possível alterar a visibilidade de {count, plural, one {# pessoa} other {# pessoas}}", "unable_to_check_item": "", "unable_to_check_items": "", - "unable_to_complete_oauth_login": "Não foi possível completar início de sessão com OAuth", - "unable_to_connect": "Não é possível conectar", + "unable_to_complete_oauth_login": "Não foi possível completar o início de sessão com OAuth", + "unable_to_connect": "Não é possível ligar", "unable_to_connect_to_server": "Não foi possível ligar ao servidor", - "unable_to_copy_to_clipboard": "Não é possível copiar para a área de transferência, certifique-se que está acessando a pagina através de https", + "unable_to_copy_to_clipboard": "Não foi possível copiar para a área de transferência, certifique-se de que está a aceder à pagina através de https", "unable_to_create_admin_account": "Não foi possível criar conta de administrador", "unable_to_create_api_key": "Não foi possível criar uma nova Chave de API", "unable_to_create_library": "Não foi possível criar a biblioteca", "unable_to_create_user": "Não foi possível criar o utilizador", - "unable_to_delete_album": "Não foi possível deletar o álbum", - "unable_to_delete_asset": "Não foi possível deletar o ativo", - "unable_to_delete_assets": "Erro ao eliminar arquivos", - "unable_to_delete_exclusion_pattern": "Não foi possível deletar o padrão de exclusão", - "unable_to_delete_import_path": "Não foi possível deletar o caminho de importação", - "unable_to_delete_shared_link": "Não foi possível deletar o link compartilhado", - "unable_to_delete_user": "Não foi possível deletar o utilizador", + "unable_to_delete_album": "Não foi possível eliminar o álbum", + "unable_to_delete_asset": "Não foi possível eliminar o ficheiro", + "unable_to_delete_assets": "Erro ao eliminar ficheiros", + "unable_to_delete_exclusion_pattern": "Não foi possível eliminar o padrão de exclusão", + "unable_to_delete_import_path": "Não foi possível eliminar o caminho de importação", + "unable_to_delete_shared_link": "Não foi possível eliminar o link compartilhado", + "unable_to_delete_user": "Não foi possível eliminar o utilizador", "unable_to_download_files": "Não foi possível transferir ficheiros", "unable_to_edit_exclusion_pattern": "Não foi possível editar o padrão de exclusão", "unable_to_edit_import_path": "Não foi possível editar o caminho de importação", - "unable_to_empty_trash": "Não foi possível esvaziar a lixeira", - "unable_to_enter_fullscreen": "Não foi possível entrar em modo de tela cheia", - "unable_to_exit_fullscreen": "Não foi possível sair do modo de tela cheia", + "unable_to_empty_trash": "Não foi possível esvaziar a reciclagem", + "unable_to_enter_fullscreen": "Não foi possível entrar em modo de ecrã inteiro", + "unable_to_exit_fullscreen": "Não foi possível sair do modo de ecrã inteiro", "unable_to_get_comments_number": "Não foi possível obter número de comentários", - "unable_to_get_shared_link": "Falha ao obter link compartilhado", + "unable_to_get_shared_link": "Não foi possível obter link partilhado", "unable_to_hide_person": "Não foi possível esconder a pessoa", + "unable_to_link_motion_video": "Não foi possível relacionar o video animado", "unable_to_link_oauth_account": "Não foi possível associar a conta OAuth", "unable_to_load_album": "Não foi possível carregar o álbum", - "unable_to_load_asset_activity": "Não foi possível carregar as atividades do ativo", - "unable_to_load_items": "Não foi possível carregar os items", - "unable_to_load_liked_status": "Não foi possível carregar os status de gostei", + "unable_to_load_asset_activity": "Não foi possível carregar a atividade do ficheiro", + "unable_to_load_items": "Não foi possível carregar os itens", + "unable_to_load_liked_status": "Não foi possível carregar o estado de gostos", "unable_to_log_out_all_devices": "Não foi possível terminar a sessão em todos os dispositivos", "unable_to_log_out_device": "Não foi possível terminar a sessão no dispositivo", "unable_to_login_with_oauth": "Não foi possível iniciar sessão com OAuth", "unable_to_play_video": "Não foi possível reproduzir o vídeo", - "unable_to_reassign_assets_existing_person": "Não é possível reatribuir arquivos para {name, select, null {uma pessoa existente} other {{name}}}", - "unable_to_reassign_assets_new_person": "Não é possível reatribuir os arquivos a uma nova pessoa", - "unable_to_refresh_user": "Não foi possível atualizar o utilizador", + "unable_to_reassign_assets_existing_person": "Não foi possível reatribuir ficheiros para {name, select, null {uma pessoa existente} other {{name}}}", + "unable_to_reassign_assets_new_person": "Não foi possível reatribuir os ficheiros a uma nova pessoa", + "unable_to_refresh_user": "Não foi possível recarregar o utilizador", "unable_to_remove_album_users": "Não foi possível remover utilizador do álbum", - "unable_to_remove_api_key": "Não foi possível a Chave de API", - "unable_to_remove_assets_from_shared_link": "Não é possível remover os arquivos do link compartilhado", + "unable_to_remove_api_key": "Não foi possível remover a Chave de API", + "unable_to_remove_assets_from_shared_link": "Não foi possível remover os ficheiros do link partilhado", "unable_to_remove_comment": "", "unable_to_remove_library": "Não foi possível remover a biblioteca", - "unable_to_remove_offline_files": "Não foi possível remover arquivos offline", + "unable_to_remove_offline_files": "Não foi possível remover ficheiros indisponíveis", "unable_to_remove_partner": "Não foi possível remover parceiro", "unable_to_remove_reaction": "Não foi possível remover a reação", "unable_to_remove_user": "", "unable_to_repair_items": "Não foi possível reparar os itens", - "unable_to_reset_password": "Não foi possível resetar a senha", - "unable_to_resolve_duplicate": "Não foi possível resolver a duplicidade", - "unable_to_restore_assets": "Não foi possível restaurar arquivos", - "unable_to_restore_trash": "Não foi possível restaurar itens da lixeira", + "unable_to_reset_password": "Não foi possível redefinir a palavra-passe", + "unable_to_resolve_duplicate": "Não foi possível resolver as duplicidades", + "unable_to_restore_assets": "Não foi possível restaurar ficheiros", + "unable_to_restore_trash": "Não foi possível restaurar itens da reciclagem", "unable_to_restore_user": "Não foi possível restaurar utilizador", - "unable_to_save_album": "Não foi possível salvar o álbum", - "unable_to_save_api_key": "Não foi possível salvar a Chave de API", + "unable_to_save_album": "Não foi possível guardar o álbum", + "unable_to_save_api_key": "Não foi possível guardar a Chave de API", "unable_to_save_date_of_birth": "Não foi possível guardar a data de nascimento", - "unable_to_save_name": "Não foi possível salvar o nome", - "unable_to_save_profile": "Não foi possível salvar o perfil", - "unable_to_save_settings": "Não foi possível salvar as configurações", - "unable_to_scan_libraries": "Não foi possível escanear as bibliotecas", - "unable_to_scan_library": "Não foi possível escanear a biblioteca", - "unable_to_set_feature_photo": "Não é possível definir a foto do recurso", + "unable_to_save_name": "Não foi possível guardar o nome", + "unable_to_save_profile": "Não foi possível guardar o perfil", + "unable_to_save_settings": "Não foi possível guardar as definições", + "unable_to_scan_libraries": "Não foi possível analisar as bibliotecas", + "unable_to_scan_library": "Não foi possível analisar a biblioteca", + "unable_to_set_feature_photo": "Não foi possível definir a foto de destaque", "unable_to_set_profile_picture": "Não foi possível definir a foto de perfil", - "unable_to_submit_job": "Não foi possível enviar o trabalho", - "unable_to_trash_asset": "Não foi possível enviar o ativo para a lixeira", + "unable_to_submit_job": "Não foi possível enviar a tarefa", + "unable_to_trash_asset": "Não foi possível enviar o ficheiro para a reciclagem", "unable_to_unlink_account": "Não foi possível desvincular conta", + "unable_to_unlink_motion_video": "Não foi possível remover a relação com o video animado", "unable_to_update_album_cover": "Não foi possível atualizar a capa do álbum", "unable_to_update_album_info": "Não foi possível atualizar informações do álbum", "unable_to_update_library": "Não foi possível atualizar a biblioteca", "unable_to_update_location": "Não foi possível atualizar a localização", - "unable_to_update_settings": "Não foi possível atualizar as configurações", + "unable_to_update_settings": "Não foi possível atualizar as definições", "unable_to_update_timeline_display_status": "Não foi possível atualizar o modo de visualização da linha do tempo", - "unable_to_update_user": "Não foi possível atualizar o usuário", + "unable_to_update_user": "Não foi possível atualizar o utilizador", "unable_to_upload_file": "Não foi possível carregar o ficheiro" }, "every_day_at_onepm": "", @@ -707,7 +725,7 @@ "exif": "Exif", "exit_slideshow": "Sair da apresentação", "expand_all": "Expandir tudo", - "expire_after": "Expira depois", + "expire_after": "Expira depois de", "expired": "Expirou", "expires_date": "Expira em {date}", "explore": "Explorar", @@ -720,38 +738,41 @@ "face_unassigned": "Sem atribuição", "failed_to_get_people": "Falha ao carregar as pessoas", "favorite": "Favorito", - "favorite_or_unfavorite_photo": "Marque ou desmarque a foto como favorita", + "favorite_or_unfavorite_photo": "Marcar ou desmarcar a foto como favorita", "favorites": "Favoritos", "feature": "", "feature_photo_updated": "Foto principal atualizada", "featurecollection": "", - "file_name": "Nome do arquivo", - "file_name_or_extension": "Nome do arquivo ou extensão", - "filename": "Nome do arquivo", + "features": "Funcionalidades", + "features_setting_description": "Configurar as funcionalidades da aplicação", + "file_name": "Nome do ficheiro", + "file_name_or_extension": "Nome do ficheiro ou extensão", + "filename": "Nome do ficheiro", "files": "", - "filetype": "Tipo de arquivo", + "filetype": "Tipo de ficheiro", "filter_people": "Filtrar pessoas", - "find_them_fast": "Encontre pelo nome em uma pesquisa", + "find_them_fast": "Encontre-as mais rapidamente pelo nome numa pesquisa", "fix_incorrect_match": "Corrigir correspondência incorreta", "folders": "Pastas", - "force_re-scan_library_files": "Força escanear novamente todos os arquivos da biblioteca", - "forward": "Para frente", + "folders_feature_description": "A navegar na vista de pastas pelas fotos e vídeos no sistema de ficheiros", + "force_re-scan_library_files": "Forçar uma nova análise de todos os ficheiros da biblioteca", + "forward": "Para a frente", "general": "Geral", "get_help": "Obter Ajuda", - "getting_started": "Primeiros passos", - "go_back": "Voltar", + "getting_started": "Primeiros Passos", + "go_back": "Regressar", "go_to_search": "Ir para a pesquisa", "go_to_share_page": "Ir para a página de compartilhamento", "group_albums_by": "Agrupar álbuns por...", "group_no": "Sem agrupamento", "group_owner": "Agrupar por dono", "group_year": "Agrupar por ano", - "has_quota": "Há cota", + "has_quota": "Tem quota", "hi_user": "Olá {name} ({email})", "hide_all_people": "Ocultar todas as pessoas", "hide_gallery": "Ocultar galeria", "hide_named_person": "Ocultar pessoa {name}", - "hide_password": "Ocultar senha", + "hide_password": "Ocultar palavra-passe", "hide_person": "Ocultar pessoa", "hide_unnamed_people": "Ocultar pessoas sem nome", "host": "Host", @@ -768,33 +789,33 @@ "image_alt_text_date_place_3_people": "{isVideo, select, true {Vídeo gravado} other {Foto tirada}} em {city}, {country} com {person1}, {person2}, e {person3} em {date}", "image_alt_text_date_place_4_or_more_people": "{isVideo, select, true {Vídeo gravado} other {Foto tirada}} em {city}, {country} com {person1}, {person2}, e outras {additionalCount, number} pessoas em {date}", "img": "", - "immich_logo": "Logo do Immich", - "immich_web_interface": "Interface web do Immich", - "import_from_json": "Importar do JSON", + "immich_logo": "Logotipo do Immich", + "immich_web_interface": "Interface Web do Immich", + "import_from_json": "Importar a partir de JSON", "import_path": "Caminho de importação", "in_albums": "Em {count, plural, one {# álbum} other {# álbuns}}", "in_archive": "Arquivado", "include_archived": "Incluir arquivados", - "include_shared_albums": "Incluir álbuns compartilhados", - "include_shared_partner_assets": "Incluir arquivos compartilhados por parceiros", - "individual_share": "Compartilhamento único", + "include_shared_albums": "Incluir álbuns partilhados", + "include_shared_partner_assets": "Incluir ficheiros partilhados por parceiros", + "individual_share": "Partilha individual", "info": "Informações", "interval": { - "day_at_onepm": "Todo dia, 1pm", + "day_at_onepm": "Todos os dias, às 13:00", "hours": "A cada {hours, plural, one {hora} other {{hours, number} horas}}", - "night_at_midnight": "Toda noite, meia noite", - "night_at_twoam": "Toda noite, 2am" + "night_at_midnight": "Todas as noites, à meia noite", + "night_at_twoam": "Todas as noites, às 02:00" }, "invite_people": "Convidar Pessoas", "invite_to_album": "Convidar para o álbum", "items_count": "{count, plural, one {item #} other {itens #}}", "job_settings_description": "", - "jobs": "Trabalhos", + "jobs": "Tarefas", "keep": "Manter", "keep_all": "Manter Todos", "keyboard_shortcuts": "Atalhos do teclado", "language": "Idioma", - "language_setting_description": "Selecione seu Idioma preferido", + "language_setting_description": "Selecione o seu Idioma preferido", "last_seen": "Visto pela ultima vez", "latest_version": "Versão mais recente", "latitude": "Latitude", @@ -804,64 +825,65 @@ "library": "Biblioteca", "library_options": "Opções da biblioteca", "light": "Claro", - "like_deleted": "Curtida removida", + "like_deleted": "Gosto removido", + "link_motion_video": "Relacionar video animado", "link_options": "Opções do Link", "link_to_oauth": "Link do OAuth", - "linked_oauth_account": "Conta OAuth Vinculada", + "linked_oauth_account": "Conta OAuth Associada", "list": "Lista", - "loading": "Carregando", - "loading_search_results_failed": "Falha ao carregar os resultados da pesquisa", + "loading": "A Carregar", + "loading_search_results_failed": "Não foi possível carregar os resultados da pesquisa", "log_out": "Sair", - "log_out_all_devices": "Sair de todos dispositivos", + "log_out_all_devices": "Terminar a sessão de todos os dispositivos", "logged_out_all_devices": "Sessão terminada em todos os dispositivos", "logged_out_device": "Sessão terminada no dispositivo", "login": "Iniciar sessão", - "login_has_been_disabled": "Login foi desativado.", - "logout_all_device_confirmation": "Tem certeza de que deseja desconectar todos os dispositivos?", - "logout_this_device_confirmation": "Tem certeza de que deseja sair deste dispositivo?", + "login_has_been_disabled": "Início de sessão foi desativado.", + "logout_all_device_confirmation": "Tem certeza de que deseja terminar a sessão em todos os dispositivos?", + "logout_this_device_confirmation": "Tem certeza de que deseja terminar a sessão deste dispositivo?", "longitude": "Longitude", "look": "Estilo", "loop_videos": "Repetir vídeos", - "loop_videos_description": "Ative para repetir os vídeos automaticamente durante a exibição.", + "loop_videos_description": "Ativar para repetir os vídeos automaticamente durante a exibição.", "make": "Marca", "manage_shared_links": "Gerir links partilhados", - "manage_sharing_with_partners": "Gerenciar compartilhamento com parceiros", - "manage_the_app_settings": "Gerenciar configurações do app", - "manage_your_account": "Gerenciar sua conta", - "manage_your_api_keys": "Gerenciar suas Chaves de API", - "manage_your_devices": "Gerenciar seus dispositivos logados", - "manage_your_oauth_connection": "Gerenciar sua conexão OAuth", + "manage_sharing_with_partners": "Gerir partilha com parceiros", + "manage_the_app_settings": "Gerir definições da aplicação", + "manage_your_account": "Gerir a sua conta", + "manage_your_api_keys": "Gerir as suas Chaves de API", + "manage_your_devices": "Gerir os seus dispositivos com sessão iniciada", + "manage_your_oauth_connection": "Gerir a sua ligação ao OAuth", "map": "Mapa", "map_marker_for_images": "Marcador no mapa para fotos tiradas em {city}, {country}", "map_marker_with_image": "Marcador de mapa com imagem", "map_settings": "Definições do mapa", "matches": "Correspondências", - "media_type": "Tipo de mídia", + "media_type": "Tipo de média", "memories": "Memórias", - "memories_setting_description": "Gerencie o que vê em suas memórias", + "memories_setting_description": "Gerir o que vê nas suas memórias", "memory": "Memória", "memory_lane_title": "Memórias {title}", "menu": "Menu", - "merge": "Mesclar", - "merge_people": "Mesclar pessoas", - "merge_people_limit": "Só é possível mesclar até 5 faces de uma só vez", - "merge_people_prompt": "Tem certeza que deseja mesclar estas pessoas? Esta ação é irreversível.", - "merge_people_successfully": "Pessoas mescladas com sucesso", - "merged_people_count": "Mesclada {count, plural, one {1 pessoa} other {# pessoas}}", + "merge": "Unir", + "merge_people": "Unir pessoas", + "merge_people_limit": "Só é possível unir até 5 rostos de cada vez", + "merge_people_prompt": "Tem a certeza de que deseja unir estas pessoas? Esta ação é irreversível.", + "merge_people_successfully": "Pessoas unidas com sucesso", + "merged_people_count": "Unidas {count, plural, one {# pessoa} other {# pessoas}}", "minimize": "Minimizar", "minute": "Minuto", - "missing": "Faltando", + "missing": "Em falta", "model": "Modelo", "month": "Mês", "more": "Mais", - "moved_to_trash": "Enviado para a lixeira", - "my_albums": "Meus Álbuns", + "moved_to_trash": "Enviado para a reciclagem", + "my_albums": "Os meus álbuns", "name": "Nome", - "name_or_nickname": "Nome ou apelido", + "name_or_nickname": "Nome ou alcunha", "never": "Nunca", "new_album": "Novo Álbum", "new_api_key": "Nova Chave de API", - "new_password": "Nova senha", + "new_password": "Nova palavra-passe", "new_person": "Nova Pessoa", "new_user_created": "Novo utilizador criado", "new_version_available": "NOVA VERSÃO DISPONÍVEL", @@ -869,48 +891,48 @@ "next": "Avançar", "next_memory": "Próxima memória", "no": "Não", - "no_albums_message": "Crie um álbum para organizar suas fotos e vídeos", - "no_albums_with_name_yet": "Parece que você ainda não tem nenhum álbum com este nome.", - "no_albums_yet": "Parece que você ainda não tem nenhum álbum.", + "no_albums_message": "Crie um álbum para organizar as suas fotos e vídeos", + "no_albums_with_name_yet": "Parece que ainda não tem nenhum álbum com este nome.", + "no_albums_yet": "Parece que ainda não tem nenhum álbum.", "no_archived_assets_message": "Arquive fotos e vídeos para os ocultar da sua visualização de fotos", - "no_assets_message": "CLIQUE PARA CARREGAR SUA PRIMEIRA FOTO", - "no_duplicates_found": "Nenhuma duplicidade foi encontrada.", + "no_assets_message": "FAÇA CLIQUE PARA CARREGAR A SUA PRIMEIRA FOTO", + "no_duplicates_found": "Nenhum item duplicado foi encontrado.", "no_exif_info_available": "Sem informações exif disponíveis", - "no_explore_results_message": "Carregue mais fotos para explorar sua coleção.", - "no_favorites_message": "Adicione aos favoritos para encontrar suas melhores fotos e vídeos rapidamente", - "no_libraries_message": "Crie uma biblioteca externa para ver suas fotos e vídeos", + "no_explore_results_message": "Carregue mais fotos para explorar a sua coleção.", + "no_favorites_message": "Adicione aos favoritos para encontrar as suas melhores fotos e vídeos rapidamente", + "no_libraries_message": "Crie uma biblioteca externa para ver as suas fotos e vídeos", "no_name": "Sem nome", "no_places": "Sem lugares", "no_results": "Sem resultados", - "no_results_description": "Tente um sinônimo ou uma palavra-chave mais comum", - "no_shared_albums_message": "Crie um álbum para compartilhar fotos e vídeos com pessoas em sua rede", - "not_in_any_album": "Fora de álbum", - "note_apply_storage_label_to_previously_uploaded assets": "Nota: Para aplicar o rótulo de armazenamento a arquivos carregados anteriormente, execute o", - "note_unlimited_quota": "Nota: Digite 0 para cota ilimitada", + "no_results_description": "Tente um sinónimo ou uma palavra-chave mais comum", + "no_shared_albums_message": "Crie um álbum para partilhar fotos e vídeos com pessoas na sua rede", + "not_in_any_album": "Não está em nenhum álbum", + "note_apply_storage_label_to_previously_uploaded assets": "Nota: Para aplicar o Rótulo de Armazenamento a ficheiros carregados anteriormente, execute o", + "note_unlimited_quota": "Nota: Escreva 0 para quota ilimitada", "notes": "Notas", - "notification_toggle_setting_description": "Habilitar notificações por e-mail", + "notification_toggle_setting_description": "Ativar notificações por e-mail", "notifications": "Notificações", - "notifications_setting_description": "Gerenciar notificações", + "notifications_setting_description": "Gerir notificações", "oauth": "OAuth", "offline": "Offline", "offline_paths": "Caminhos offline", - "offline_paths_description": "Estes resultados podem ser devidos a arquivos deletados manualmente e que não são parte de uma biblioteca externa.", + "offline_paths_description": "Estes resultados podem ser devidos a ficheiros eliminados manualmente e que não fazem parte de uma biblioteca externa.", "ok": "Ok", "oldest_first": "Mais antigo primeiro", "onboarding": "Integração", - "onboarding_privacy_description": "Os seguintes recursos (opcionais) dependem de serviços externos e podem ser desabilitados a qualquer momento nas configurações de administração.", - "onboarding_theme_description": "Escolha um tema de cor para sua instância. Você pode alterar isso mais tarde em suas configurações.", - "onboarding_welcome_description": "Vamos configurar sua instância com algumas configurações comuns.", + "onboarding_privacy_description": "As seguintes funcionalidades opcionais dependem de serviços externos e podem ser desativados a qualquer momento nas definições de administração.", + "onboarding_theme_description": "Escolha um tema de cor para sua instância. Pode alterar isto mais tarde nas suas definições.", + "onboarding_welcome_description": "Vamos configurar a sua instância com algumas definições comuns.", "onboarding_welcome_user": "Bem-vindo(a), {user}", "online": "Online", - "only_favorites": "Somente favoritos", - "only_refreshes_modified_files": "Somente atualize arquivos modificados", - "open_in_map_view": "Abrir na visualização do mapa", + "only_favorites": "Apenas favoritos", + "only_refreshes_modified_files": "Apenas recarrega ficheiros modificados", + "open_in_map_view": "Abrir na visualização de mapa", "open_in_openstreetmap": "Abrir no OpenStreetMap", - "open_the_search_filters": "Abre os filtros de pesquisa", + "open_the_search_filters": "Abrir os filtros de pesquisa", "options": "Opções", "or": "ou", - "organize_your_library": "Organize sua biblioteca", + "organize_your_library": "Organizar a sua biblioteca", "original": "original", "other": "Outro", "other_devices": "Outros dispositivos", @@ -918,15 +940,15 @@ "owned": "Seu", "owner": "Dono", "partner": "Parceiro", - "partner_can_access": "{partner} pode acessar", - "partner_can_access_assets": "Todas as suas fotos e vídeos, exceto os Arquivados ou Excluídos", + "partner_can_access": "{partner} pode aceder", + "partner_can_access_assets": "Todas as suas fotos e vídeos, exceto os Arquivados ou Eliminados", "partner_can_access_location": "A localização onde as fotos foram tiradas", - "partner_sharing": "Compartilhamento com Parceiro", + "partner_sharing": "Partilha com Parceiro", "partners": "Parceiros", - "password": "Senha", - "password_does_not_match": "As senhas não são iguais", - "password_required": "A senha é obrigatório", - "password_reset_success": "Senha resetada com sucesso", + "password": "Palavra-passe", + "password_does_not_match": "As palavras-passe não condizem", + "password_required": "A palavra-passe é obrigatória", + "password_reset_success": "Palavra-passe redefinida com sucesso", "past_durations": { "days": "{days, plural, one {Último dia} other {# últimos dias}}", "hours": "Últimas {hours, plural, one {horas} other {# horas}}", @@ -934,25 +956,26 @@ }, "path": "Caminho", "pattern": "Padrão", - "pause": "Interromper", - "pause_memories": "Interromper memórias", - "paused": "Interrompido", + "pause": "Pausa", + "pause_memories": "Pausar memórias", + "paused": "Em Pausa", "pending": "Pendente", "people": "Pessoas", "people_edits_count": "{count, plural, one {# pessoa editada} other {# pessoas editadas}}", - "people_sidebar_description": "Exibe o link Pessoas na barra lateral", + "people_feature_description": "A navegar fotos e vídeos agrupados por pessoas", + "people_sidebar_description": "Exibir o link Pessoas na barra lateral", "perform_library_tasks": "", - "permanent_deletion_warning": "Aviso para deletar permanentemente", - "permanent_deletion_warning_setting_description": "Exibe um aviso ao excluir arquivos de forma permanente", - "permanently_delete": "Deletar permanentemente", - "permanently_delete_assets_count": "Excluir permanentemente {count, plural, one {arquivo} other {arquivos}}", - "permanently_delete_assets_prompt": "Tem certeza que deseja excluir permanentemente {count, plural, one {esse arquivo?} other {estes # arquivos?}} Essa ação também removerá {count, plural, one {isto do} other {isto dos}} álbum(s).", - "permanently_deleted_asset": "Ativo deletado permanentemente", + "permanent_deletion_warning": "Aviso de eliminação permanente", + "permanent_deletion_warning_setting_description": "Exibir um aviso ao eliminar ficheiros de forma permanente", + "permanently_delete": "Eliminar permanentemente", + "permanently_delete_assets_count": "Eliminar permanentemente {count, plural, one {ficheiro} other {ficheiros}}", + "permanently_delete_assets_prompt": "Tem a certeza de que deseja eliminar permanentemente {count, plural, one {este ficheiro?} other {estes # ficheiros?}} Esta ação também removerá {count, plural, one {isto do álbum} other {isto dos álbuns}}.", + "permanently_deleted_asset": "Ficheiro eliminado permanentemente", "permanently_deleted_assets": "{count, plural, one {# ativo deletado} other {# ativos deletados}} permanentemente", - "permanently_deleted_assets_count": "{count, plural, one {# arquivo excluído} other {# arquivos excluídos}} permanentemente", + "permanently_deleted_assets_count": "{count, plural, one {# Ficheiro eliminado} other {# Ficheiros eliminados}} permanentemente", "person": "Pessoa", "person_hidden": "{name}{hidden, select, true { (oculto)} other {}}", - "photo_shared_all_users": "Parece que você compartilhou suas fotos com todos os usuários ou não tem nenhum usuário para compartilhar.", + "photo_shared_all_users": "Parece que partilhou as suas fotos com todos os utilizadores ou não tem nenhum utilizador para partilhar.", "photos": "Fotos", "photos_and_videos": "Fotos & Vídeos", "photos_count": "{count, plural, one {{count, number} Foto} other {{count, number} Fotos}}", @@ -976,179 +999,183 @@ "profile_image_of_user": "Imagem de perfil de {user}", "profile_picture_set": "Foto de perfil definida.", "public_album": "Álbum público", - "public_share": "Compartilhar Publicamente", - "purchase_account_info": "Apoiador", + "public_share": "Partilhar Publicamente", + "purchase_account_info": "Apoiante", "purchase_activated_subtitle": "Agradecemos por apoiar o Immich e software de código aberto", "purchase_activated_time": "Ativado em {date, date}", - "purchase_activated_title": "Sua chave foi ativada com sucesso", + "purchase_activated_title": "A sua chave foi ativada com sucesso", "purchase_button_activate": "Ativar", "purchase_button_buy": "Comprar", "purchase_button_buy_immich": "Comprar Immich", - "purchase_button_never_show_again": "Nunca mostrar novamente", + "purchase_button_never_show_again": "Não mostrar de novo", "purchase_button_reminder": "Relembrar-me daqui a 30 dias", "purchase_button_remove_key": "Remover chave", "purchase_button_select": "Selecionar", - "purchase_failed_activation": "Falha ao ativar! Verifique seu e-mail para obter a chave de produto correta!", - "purchase_individual_description_1": "Para uma pessoa", - "purchase_individual_description_2": "Status de apoiador", + "purchase_failed_activation": "Não foi possível ativar! Verifique o seu e-mail para obter a chave de produto correta!", + "purchase_individual_description_1": "Para uma pessoa individual", + "purchase_individual_description_2": "Status de apoiante", "purchase_individual_title": "Particular", "purchase_input_suggestion": "Tem uma chave de produto? Insira a chave abaixo", - "purchase_license_subtitle": "Compre Immich para apoiar o desenvolvimento contínuo do serviço", + "purchase_license_subtitle": "Compre o Immich para apoiar o desenvolvimento contínuo do serviço", "purchase_lifetime_description": "Compra vitalícia", "purchase_option_title": "OPÇÕES DE COMPRA", "purchase_panel_info_1": "O desenvolvimento do Immich requer muito tempo e esforço, e temos engenheiros a tempo inteiro a trabalhar nele para melhorá-lo quanto possível. A nossa missão é para que o software de código aberto e práticas de negócio éticas se tornem numa fonte de rendimento sustentável para os desenvolvedores e criar um ecossistema que respeite a privacidade dos utilizadores e que ofereça alternativas reais a serviços cloud explorativos.", - "purchase_panel_info_2": "Como estamos comprometidos em não adicionar acesso pago, esta compra não lhe dará nenhum recurso adicional no Immich. Contamos com usuários como você para dar suporte ao desenvolvimento contínuo do Immich.", + "purchase_panel_info_2": "Como estamos comprometidos a não adicionar acesso pago, esta compra não lhe dará acesso a nenhuma funcionalidade adicional do Immich. Contamos com utilizadores como você para dar suporte ao desenvolvimento contínuo do Immich.", "purchase_panel_title": "Apoie o projeto", "purchase_per_server": "Por servidor", "purchase_per_user": "Por utilizador", "purchase_remove_product_key": "Remover chave de produto", - "purchase_remove_product_key_prompt": "Tem certeza de que deseja remover a chave do produto?", + "purchase_remove_product_key_prompt": "Tem a certeza de que deseja remover a chave do produto?", "purchase_remove_server_product_key": "Remover chave do produto do servidor", - "purchase_remove_server_product_key_prompt": "Tem certeza de que deseja remover a chave do produto do servidor?", + "purchase_remove_server_product_key_prompt": "Tem a certeza de que deseja remover a chave do produto do servidor?", "purchase_server_description_1": "Para o servidor inteiro", - "purchase_server_description_2": "Status de apoiador", + "purchase_server_description_2": "Status de apoiante", "purchase_server_title": "Servidor", - "purchase_settings_server_activated": "A chave de produto para servidor é gerida pelo administrador", + "purchase_settings_server_activated": "A chave de produto do servidor é gerida pelo administrador", "range": "", "rating": "Classificação por estrelas", "rating_clear": "Limpar classificação", - "rating_count": "{contar, plural, um {# estrela} outro {# estrelas}}", - "rating_description": "Exibir a classificação exif no painel de informações", + "rating_count": "{count, plural, one {# estrela} other {# estrelas}}", + "rating_description": "Mostrar a classificação EXIF no painel de informações", "raw": "", "reaction_options": "Opções de reação", "read_changelog": "Ler Novidades", "reassign": "Reatribuir", - "reassigned_assets_to_existing_person": "Reatribuir {count, plural, one {# arquivo} other {# arquivos}} PARA {name, select, null {uma pessoa existente} other {{name}}}", - "reassigned_assets_to_new_person": "Reatribuir {count, plural, one {# arquivo} other {# arquivos}} a uma nova pessoa", - "reassing_hint": "Atribuir ativos selecionados a uma pessoa existente", - "recent": "Recente", + "reassigned_assets_to_existing_person": "Reatribuir {count, plural, one {# ficheiro} other {# ficheiros}} para {name, select, null {uma pessoa existente} other {{name}}}", + "reassigned_assets_to_new_person": "Reatribuído {count, plural, one {# ficheiro} other {# ficheiros}} a uma nova pessoa", + "reassing_hint": "Atribuir ficheiros selecionados a uma pessoa existente", + "recent": "Recentes", "recent_searches": "Pesquisas recentes", "refresh": "Atualizar", "refresh_encoded_videos": "Atualizar vídeos codificados", "refresh_metadata": "Atualizar metadados", "refresh_thumbnails": "Atualizar miniaturas", "refreshed": "Atualizado", - "refreshes_every_file": "Atualiza todos arquivos", - "refreshing_encoded_video": "Atualizando vídeo codificado", + "refreshes_every_file": "Atualiza todos os ficheiros", + "refreshing_encoded_video": "A atualizar vídeo codificado", "refreshing_metadata": "A atualizar metadados", "regenerating_thumbnails": "A atualizar miniaturas", "remove": "Remover", - "remove_assets_album_confirmation": "Tem certeza que deseja remover {count, plural, one {# arquivo} other {# arquivos}} do álbum?", - "remove_assets_shared_link_confirmation": "Tem certeza que deseja remover {count, plural, one {# arquivo} other {# arquivos}} desse link compartilhado?", - "remove_assets_title": "Remover arquivos?", + "remove_assets_album_confirmation": "Tem a certeza de que deseja remover {count, plural, one {# ficheiro} other {# ficheiros}} do álbum?", + "remove_assets_shared_link_confirmation": "Tem certeza de que deseja remover {count, plural, one {# ficheiro} other {# ficheiros}} deste link partilhado?", + "remove_assets_title": "Remover ficheiros?", "remove_custom_date_range": "Remover intervalo de datas personalizado", "remove_from_album": "Remover do álbum", "remove_from_favorites": "Remover dos favoritos", - "remove_from_shared_link": "Remover do link compartilhado", - "remove_offline_files": "Remover arquivos offline", + "remove_from_shared_link": "Remover do link partilhado", + "remove_offline_files": "Remover ficheiros offline", "remove_user": "Remover utilizador", - "removed_api_key": "Removido a Chave de API: {name}", + "removed_api_key": "Foi removida a Chave de API: {name}", "removed_from_archive": "Removido do arquivo", "removed_from_favorites": "Removido dos favoritos", - "removed_from_favorites_count": "{count, plural, other {Removido #}} dos favoritos", - "rename": "Renomear", + "removed_from_favorites_count": "{count, plural, other {Removidos #}} dos favoritos", + "removed_tagged_assets": "Removida a etiqueta de {count, plural, one {# ficheiro} other {# ficheiros}}", + "rename": "Mudar o nome", "repair": "Reparar", - "repair_no_results_message": "Arquivos perdidos ou não rastreados aparecem aqui", - "replace_with_upload": "Substituir", + "repair_no_results_message": "Ficheiros perdidos ou não rastreados irão aparecer aqui", + "replace_with_upload": "Substituir pelo ficheiro carregado", "repository": "Repositório", - "require_password": "Proteger com senha", - "require_user_to_change_password_on_first_login": "Obrigar utilizador a alterar a senha após primeiro início de sessão", - "reset": "Resetar", - "reset_password": "Resetar senha", - "reset_people_visibility": "Resetar pessoas ocultas", + "require_password": "Proteger com palavra-passe", + "require_user_to_change_password_on_first_login": "Obrigar utilizador a alterar a palavra-passe após o primeiro início de sessão", + "reset": "Redefinir", + "reset_password": "Redefinir palavra-passe", + "reset_people_visibility": "Redefinir pessoas ocultas", "reset_settings_to_default": "", "reset_to_default": "Repor predefinições", "resolve_duplicates": "Resolver itens duplicados", - "resolved_all_duplicates": "Todas duplicidades resolvidas", + "resolved_all_duplicates": "Todos os itens duplicados resolvidos", "restore": "Restaurar", "restore_all": "Restaurar tudo", "restore_user": "Restaurar utilizador", - "restored_asset": "Arquivo restaurado", + "restored_asset": "Ficheiro restaurado", "resume": "Continuar", "retry_upload": "Tentar carregar novamente", - "review_duplicates": "Revisar duplicidade", + "review_duplicates": "Rever itens duplicados", "role": "Função", "role_editor": "Editor", "role_viewer": "Visualizador", "save": "Guardar", - "saved_api_key": "Chave de API salva", - "saved_profile": "Perfil Salvo", - "saved_settings": "Configurações salvas", - "say_something": "Diga algo", - "scan_all_libraries": "Escanear Todas Bibliotecas", - "scan_all_library_files": "Re-escanear todos arquivos da biblioteca", - "scan_new_library_files": "Escanear novos arquivos na biblioteca", - "scan_settings": "Opções de escanear", - "scanning_for_album": "Escaneando por álbum...", + "saved_api_key": "Chave de API guardada", + "saved_profile": "Perfil guardado", + "saved_settings": "Definições guardadas", + "say_something": "Diga alguma coisa", + "scan_all_libraries": "Analisar todas as bibliotecas", + "scan_all_library_files": "Re-analisar todos os ficheiros da biblioteca", + "scan_new_library_files": "Analisar novos ficheiros na biblioteca", + "scan_settings": "Opções de análise", + "scanning_for_album": "A analisar por álbum...", "search": "Pesquisar", "search_albums": "Pesquisar álbuns", "search_by_context": "Pesquisar por contexto", "search_by_filename": "Pesquisar por nome de ficheiro ou extensão", "search_by_filename_example": "por exemplo, IMG_1234.JPG ou PNG", - "search_camera_make": "Pesquisar câmeras da marca...", - "search_camera_model": "Pesquisar câmera do modelo...", + "search_camera_make": "Pesquisar por marca da câmara...", + "search_camera_model": "Pesquisar por modelo da câmara...", "search_city": "Pesquisar cidade...", "search_country": "Pesquisar país...", - "search_for_existing_person": "Pesquisar por pessoas", - "search_no_people": "Nenhuma pessoa", + "search_for_existing_person": "Pesquisar por pessoas existentes", + "search_no_people": "Sem pessoas", "search_no_people_named": "Nenhuma pessoa chamada \"{name}\"", + "search_options": "Opções de pesquisa", "search_people": "Pesquisar pessoas", "search_places": "Pesquisar lugares", - "search_state": "Pesquisar estado...", + "search_settings": "Definições de pesquisa", + "search_state": "Pesquisar estado/distrito...", + "search_tags": "Pesquisar etiquetas...", "search_timezone": "Pesquisar fuso horário...", - "search_type": "Pesquisar tipo", + "search_type": "Tipo de pesquisa", "search_your_photos": "Pesquisar fotos", - "searching_locales": "Pesquisar Lugares....", + "searching_locales": "A pesquisar Lugares....", "second": "Segundo", "see_all_people": "Ver todas as pessoas", "select_album_cover": "Escolher capa do álbum", "select_all": "Selecionar todos", "select_all_duplicates": "Selecionar todos os itens duplicados", "select_avatar_color": "Selecionar cor do avatar", - "select_face": "Selecionar face", + "select_face": "Selecionar rosto", "select_featured_photo": "Selecionar foto principal", - "select_from_computer": "Selecionar do computador", - "select_keep_all": "Marcar manter em todos", - "select_library_owner": "Selecione o dono da biblioteca", - "select_new_face": "Selecionar nova face", + "select_from_computer": "Selecionar a partir do computador", + "select_keep_all": "Selecionar manter todos", + "select_library_owner": "Selecionar o dono da biblioteca", + "select_new_face": "Selecionar novo rosto", "select_photos": "Selecionar fotos", - "select_trash_all": "Marcar lixo em todos", + "select_trash_all": "Selecionar todos para reciclagem", "selected": "Selecionados", - "selected_count": "{count, plural, other {# selecionado}}", + "selected_count": "{count, plural, other {# selecionados}}", "send_message": "Enviar mensagem", "send_welcome_email": "Enviar E-mail de boas vindas", "server": "Servidor", "server_offline": "Servidor Offline", "server_online": "Servidor Online", - "server_stats": "Status do servidor", + "server_stats": "Estado do servidor", "server_version": "Versão do servidor", "set": "Definir", "set_as_album_cover": "Definir como capa do álbum", "set_as_profile_picture": "Definir como foto de perfil", "set_date_of_birth": "Definir data de nascimento", "set_profile_picture": "Definir foto de perfil", - "set_slideshow_to_fullscreen": "Apresentação em tela cheia", - "settings": "Configurações", - "settings_saved": "Configurações salvas", - "share": "Compartilhar", - "shared": "Compartilhado", - "shared_by": "Compartilhado por", + "set_slideshow_to_fullscreen": "Apresentação em ecrã inteiro", + "settings": "Definições", + "settings_saved": "Definições guardadas", + "share": "Partilhar", + "shared": "Partilhado", + "shared_by": "Partilhado por", "shared_by_user": "Partilhado por {user}", - "shared_by_you": "Compartilhado por você", + "shared_by_you": "Partilhado por si", "shared_from_partner": "Fotos de {partner}", - "shared_link_options": "Opções de link compartilhado", - "shared_links": "Links compartilhados", - "shared_photos_and_videos_count": "{assetCount, plural, other {# Fotos & videos compartilhados.}}", - "shared_with_partner": "Compartilhado com {partner}", - "sharing": "Compartilhar", - "sharing_enter_password": "Por favor, digite a senha para visualizar esta página.", - "sharing_sidebar_description": "Exibe o link Compartilhar na barra lateral", - "shift_to_permanent_delete": "Pressione ⇧ para excluir o arquivo permanentemente", + "shared_link_options": "Opções de link partilhado", + "shared_links": "Links partilhados", + "shared_photos_and_videos_count": "{assetCount, plural, other {# Fotos & videos partilhados.}}", + "shared_with_partner": "Partilhado com {partner}", + "sharing": "Partilha", + "sharing_enter_password": "Por favor, insira a palavra-passe para ver esta página.", + "sharing_sidebar_description": "Exibe o link para Partilhar na barra lateral", + "shift_to_permanent_delete": "Pressione ⇧ para eliminar o ficheiro permanentemente", "show_album_options": "Exibir opções do álbum", "show_albums": "Mostrar álbuns", "show_all_people": "Mostrar todas as pessoas", "show_and_hide_people": "Mostrar & ocultar pessoas", - "show_file_location": "Exibir local do arquivo", + "show_file_location": "Exibir localização do ficheiro", "show_gallery": "Exibir galeria", "show_hidden_people": "Exibir pessoas ocultadas", "show_in_timeline": "Exibir na linha do tempo", @@ -1156,19 +1183,23 @@ "show_keyboard_shortcuts": "Exibir atalhos do teclado", "show_metadata": "Mostrar metadados", "show_or_hide_info": "Exibir ou ocultar informações", - "show_password": "Exibir senha", + "show_password": "Mostrar palavra-passe", "show_person_options": "Exibir opções da pessoa", "show_progress_bar": "Exibir barra de progresso", "show_search_options": "Exibir opções de pesquisa", - "show_supporter_badge": "Emblema de apoiador", - "show_supporter_badge_description": "Mostrar um emblema de apoiador", + "show_supporter_badge": "Emblema de apoiante", + "show_supporter_badge_description": "Mostrar um emblema de apoiante", "shuffle": "Aleatório", - "sign_out": "Sair", - "sign_up": "Registrar", + "sidebar": "Barra lateral", + "sidebar_display_description": "Mostrar um link para a vista na barra lateral", + "sign_out": "Terminar sessão", + "sign_up": "Criar conta", "size": "Tamanho", - "skip_to_content": "Pular para o conteúdo", + "skip_to_content": "Saltar para o conteúdo", + "skip_to_folders": "Saltar para pastas", + "skip_to_tags": "Saltar para as etiquetas", "slideshow": "Apresentação", - "slideshow_settings": "Opções de apresentação", + "slideshow_settings": "Definições de apresentação", "sort_albums_by": "Ordenar álbuns por...", "sort_created": "Data de criação", "sort_items": "Número de itens", @@ -1178,49 +1209,58 @@ "sort_title": "Título", "source": "Fonte", "stack": "Empilhar", - "stack_duplicates": "Empilhar duplicados", + "stack_duplicates": "Empilhar itens duplicados", "stack_select_one_photo": "Selecione uma foto principal para a pilha", "stack_selected_photos": "Empilhar fotos selecionadas", - "stacked_assets_count": "Empilhado {count, plural, one {# arquivo} other {# arquivos}}", + "stacked_assets_count": "Empilhado {count, plural, one {# ficheiro} other {# ficheiros}}", "stacktrace": "Stacktrace", - "start": "Início", - "start_date": "Data inicial", + "start": "Iniciar", + "start_date": "Data de início", "state": "Estado", - "status": "Status", + "status": "Estado", "stop_motion_photo": "Parar foto em movimento", - "stop_photo_sharing": "Parar de partilhar as suas fotos?", - "stop_photo_sharing_description": "{partner} não terá mais acesso às suas fotos.", - "stop_sharing_photos_with_user": "Parar de compartilhar as fotos com este utilizador", + "stop_photo_sharing": "Deixar de partilhar as suas fotos?", + "stop_photo_sharing_description": "{partner} deixará de ter acesso às suas fotos.", + "stop_sharing_photos_with_user": "Deixar de partilhar as fotos com este utilizador", "storage": "Espaço de armazenamento", - "storage_label": "Rótulo de armazenamento", - "storage_usage": "utilizado {used} de {available}", + "storage_label": "Rótulo de Armazenamento", + "storage_usage": "Utilizado {used} de {available}", "submit": "Enviar", "suggestions": "Sugestões", "sunrise_on_the_beach": "Nascer do sol na praia", - "swap_merge_direction": "Alternar direção da mesclagem", + "swap_merge_direction": "Alternar direção da união", "sync": "Sincronizar", + "tag": "Etiqueta", + "tag_assets": "Etiquetar ficheiros", + "tag_created": "Criada a etiqueta {tag}", + "tag_feature_description": "A mostrar fotos e videos agrupados por tópicos lógicos de etiquetas", + "tag_not_found_question": "Não consegue encontrar a etiqueta? Crie uma aqui", + "tag_updated": "Atualizada a etiqueta: {tag}", + "tagged_assets": "Etiquetado {count, plural, one {# ficheiros} other {# ficheiros}}", + "tags": "Etiquetas", "template": "Modelo", "theme": "Tema", "theme_selection": "Selecionar tema", - "theme_selection_description": "Defina automaticamente o tema como claro ou escuro com base na preferência do sistema do seu navegador", - "they_will_be_merged_together": "Eles serão mesclados", - "time_based_memories": "Memórias baseada no tempo", + "theme_selection_description": "Definir automaticamente o tema como claro ou escuro com base na preferência do sistema do seu navegador", + "they_will_be_merged_together": "Eles serão unidos", + "time_based_memories": "Memórias baseadas no tempo", "timezone": "Fuso horário", "to_archive": "Arquivar", - "to_change_password": "Alterar senha", + "to_change_password": "Alterar palavra-passe", "to_favorite": "Favorito", - "to_login": "Iniciar sessão", - "to_trash": "Lixo", + "to_login": "Iniciar Sessão", + "to_parent": "Ir para o pai", + "to_trash": "Reciclagem", "toggle_settings": "Alternar configurações", - "toggle_theme": "Alternar tema", + "toggle_theme": "Ativar modo escuro", "toggle_visibility": "Alternar visibilidade", "total_usage": "Total utilizado", - "trash": "Lixeira", - "trash_all": "Todos para o lixo", - "trash_count": "Lixeira {count, number}", - "trash_delete_asset": "Excluir arquivo", - "trash_no_results_message": "Fotos e vídeos enviados para o lixo aparecem aqui.", - "trashed_items_will_be_permanently_deleted_after": "Os itens da lixeira são deletados permanentemente após {days, plural, one {# dia} other {# dias}}.", + "trash": "Reciclagem", + "trash_all": "Mover todos para a reciclagem", + "trash_count": "Reciclar {count, number}", + "trash_delete_asset": "Eliminar ficheiro", + "trash_no_results_message": "Fotos e vídeos enviados para a reciclagem aparecem aqui.", + "trashed_items_will_be_permanently_deleted_after": "Os itens da reciclagem são eliminados permanentemente após {days, plural, one {# dia} other {# dias}}.", "type": "Tipo", "unarchive": "Desarquivar", "unarchived": "Restaurado do arquivo", @@ -1231,70 +1271,72 @@ "unknown_album": "", "unknown_year": "Ano desconhecido", "unlimited": "Ilimitado", + "unlink_motion_video": "Remover relação com video animado", "unlink_oauth": "Desvincular OAuth", "unlinked_oauth_account": "Conta OAuth desvinculada", "unnamed_album": "Álbum sem nome", - "unnamed_album_delete_confirmation": "Tem a certeza que pretende remover este album?", - "unnamed_share": "Compartilhamento sem nome", + "unnamed_album_delete_confirmation": "Tem a certeza de que pretende eliminar este álbum?", + "unnamed_share": "Partilha sem nome", "unsaved_change": "Alteração não guardada", "unselect_all": "Limpar seleção", - "unselect_all_duplicates": "Remover seleção de todos os duplicados", + "unselect_all_duplicates": "Remover seleção de todos os itens duplicados", "unstack": "Desempilhar", - "unstacked_assets_count": "Desempilhar {count, plural, one {# arquivo} other {# arquivos}}", - "untracked_files": "Arquivos não monitorados", - "untracked_files_decription": "Estes arquivos não são monitorados pela aplicação. Podem ser resultados de falhas em uma movimentação, carregamentos interrompidos, ou deixados para trás por causa de um problema", + "unstacked_assets_count": "Desempilhados {count, plural, one {# ficheiro} other {# ficheiros}}", + "untracked_files": "Ficheiros não monitorizados", + "untracked_files_decription": "Estes ficheiros não são monitorizados pela aplicação. Podem ser resultados de falhas numa movimentação, carregamentos interrompidos, ou deixados para trás por causa de um problema", "up_next": "A seguir", - "updated_password": "Senha atualizada", + "updated_password": "Palavra-passe atualizada", "upload": "Carregar", - "upload_concurrency": "Carregar simultâneo", - "upload_errors": "Envio completo com {count, plural, one {# erro} other {# erros}}, atualize a página para ver novos arquivos enviados.", + "upload_concurrency": "Carregamentos em simultâneo", + "upload_errors": "Envio completo com {count, plural, one {# erro} other {# erros}}, atualize a página para ver os novos ficheiros enviados.", "upload_progress": "Restante(s) {remaining, number} - Processado(s) {processed, number}/{total, number}", - "upload_skipped_duplicates": "Ignorado {count, plural, one {# arquivo duplicado} other {# arquivos duplicados}}", + "upload_skipped_duplicates": "{count, plural, one {# Ignorado ficheiro duplicado} other {# Ignorados ficheiros duplicados}}", "upload_status_duplicates": "Duplicados", "upload_status_errors": "Erros", "upload_status_uploaded": "Enviado", - "upload_success": "Upload realizado com sucesso, atualize a página para ver os novos ativos de upload.", + "upload_success": "Carregamento realizado com sucesso, atualize a página para ver os novos ficheiros carregados.", "url": "URL", - "usage": "Uso", - "use_custom_date_range": "Usar um intervalo de datas personalizado", + "usage": "Utilização", + "use_custom_date_range": "Utilizar um intervalo de datas personalizado", "user": "Utilizador", "user_id": "ID do utilizador", - "user_liked": "{user} gostou {type, select, photo {dessa foto} video {deste video} asset {deste arquivo} other {disto}}", - "user_purchase_settings": "Compra", - "user_purchase_settings_description": "Gerencie sua compra", + "user_liked": "{user} gostou {type, select, photo {desta fotografia} video {deste video} asset {deste ficheiro} other {disto}}", + "user_purchase_settings": "Comprar", + "user_purchase_settings_description": "Gerir a sua compra", "user_role_set": "Definir {user} como {role}", - "user_usage_detail": "Detalhes de uso do utilizador", - "username": "Nome do utilizador", + "user_usage_detail": "Detalhes de utilização do utilizador", + "username": "Nome de utilizador", "users": "Utilizadores", - "utilities": "Utilitários", + "utilities": "Ferramentas", "validate": "Validar", "variables": "Variáveis", "version": "Versão", - "version_announcement_closing": "Seu amigo, Alex", - "version_announcement_message": "Olá amigo, há uma nova versão do aplicativo. Reserve um tempo para visitar as histórico de mudanças e garantir que suas configurações docker-compose.yml e .env estejam atualizadas para evitar qualquer configuração incorreta, especialmente se você usar o WatchTower ou qualquer mecanismo que lide com a atualização do seu aplicativo automaticamente.", + "version_announcement_closing": "O seu amigo, Alex", + "version_announcement_message": "Olá amigo, há uma nova versão da aplicação. Reserve algum tempo para visitar o histórico de mudanças e garantir que as suas configurações do docker-compose.yml e .env estão atualizadas para evitar qualquer configuração incorreta, especialmente se usar o WatchTower ou qualquer mecanismo que lide com a atualização automática da aplicação.", "video": "Vídeo", - "video_hover_setting": "Reproduzir vídeo em miniatura quando passar por cima", - "video_hover_setting_description": "Reproduzir vídeo em miniatura quando o mouse está sobre o item. Mesmo quando desativado, a reprodução ainda pode ser iniciada passando sobre o ícone.", + "video_hover_setting": "Reproduzir vídeo em miniatura quando passar com o cursor por cima", + "video_hover_setting_description": "Reproduzir vídeo em miniatura quando o cursor está sobre o item. Mesmo quando está desativado, a reprodução ainda pode ser iniciada passando sobre o ícone de reproduzir.", "videos": "Vídeos", "videos_count": "{count, plural, one {# Vídeo} other {# Vídeos}}", "view": "Ver", "view_album": "Ver Álbum", "view_all": "Ver tudo", "view_all_users": "Ver todos os utilizadores", + "view_in_timeline": "Ver na linha do tempo", "view_links": "Ver links", - "view_next_asset": "Ver próximo ativo", - "view_previous_asset": "Ver ativo anterior", - "view_stack": "Visualizar pilha", + "view_next_asset": "Ver próximo ficheiro", + "view_previous_asset": "Ver ficheiro anterior", + "view_stack": "Ver pilha", "viewer": "Visualizar", "visibility_changed": "Visibilidade alterada para {count, plural, one {# pessoa} other {# pessoas}}", - "waiting": "Aguardando", + "waiting": "Em fila", "warning": "Aviso", "week": "Semana", - "welcome": "Bem-vindo", - "welcome_to_immich": "Bem-vindo ao Immich", + "welcome": "Bem-vindo(a)", + "welcome_to_immich": "Bem-vindo(a) ao Immich", "year": "Ano", - "years_ago": "Há {years, plural, one {# ano} other {# anos}}", + "years_ago": "Há {years, plural, one {# ano} other {# anos}} atrás", "yes": "Sim", - "you_dont_have_any_shared_links": "Não há links compartilhados", - "zoom_image": "Ampliar imagem" + "you_dont_have_any_shared_links": "Não tem links partilhados", + "zoom_image": "Ampliar/Reduzir imagem" } diff --git a/web/src/lib/i18n/ro.json b/web/src/lib/i18n/ro.json index 02022569cd12a..195c33c943c30 100644 --- a/web/src/lib/i18n/ro.json +++ b/web/src/lib/i18n/ro.json @@ -41,6 +41,7 @@ "confirm_email_below": "Pentru a confirma, tastați „{email}” mai jos", "confirm_reprocess_all_faces": "Sigur doriți să reprocesați toate fețele? Acest lucru va șterge și persoanele cu nume.", "confirm_user_password_reset": "Sigur doriți să resetați parola utilizatorului {user}?", + "create_job": "Creează sarcină", "crontab_guru": "", "disable_login": "Dezactivați autentificarea", "disabled": "", @@ -70,6 +71,7 @@ "image_thumbnail_resolution": "Rezoluție imagini miniatură", "image_thumbnail_resolution_description": "Folosit la vizualizarea unor grupuri de fotografii (cronologie principală, vizualizare album etc.). Rezoluțiile mai mari pot păstra mai multe detalii, dar codarea durează mai mult, au dimensiuni mai mari ale fișierelor și pot reduce capacitatea de răspuns a aplicației.", "job_concurrency": "concurență {job}", + "job_created": "Sarcină creată", "job_not_concurrency_safe": "Acest job nu este sigur pentru a rula în concurență.", "job_settings": "Setări sarcină", "job_settings_description": "Administrează concurența sarcinilor", @@ -238,6 +240,7 @@ "storage_template_settings_description": "Gestionează structura folderelor și numele fișierelor pentru activele încărcate", "storage_template_user_label": "{label} este eticheta de stocare a utilizatorului", "system_settings": "Setǎri de sistem", + "tag_cleanup_job": "Curățare etichete", "theme_custom_css_settings": "CSS personalizat", "theme_custom_css_settings_description": "Foile de stil în cascadă (CSS) permit personalizarea designului Immich.", "theme_settings": "Setări temă", @@ -273,7 +276,7 @@ "transcoding_hardware_decoding": "Decodare hardware", "transcoding_hardware_decoding_setting_description": "Se aplică doar pentru NVENC, QSV și RKMPP. Activează accelerarea completă în loc de doar accelerarea codificării. S-ar putea să nu funcționeze pentru toate videoclipurile.", "transcoding_hevc_codec": "codec HEVC", - "transcoding_max_b_frames": "", + "transcoding_max_b_frames": "Număr maxim de cadre B", "transcoding_max_b_frames_description": "Valorile mai mari îmbunătățesc eficiența compresiei, dar încetinesc codarea. Este posibil să nu fie compatibile cu accelerarea hardware pe dispozitivele mai vechi. 0 dezactivează cadrele B, în timp ce -1 setează această valoare automat.", "transcoding_max_bitrate": "Bitrate maxim", "transcoding_max_bitrate_description": "Setarea unei rate maxime de biți poate face dimensiunile fișierelor mai previzibile, cu un cost minor asupra calității. La 720p, valorile tipice sunt 2600k pentru VP9 sau HEVC, sau 4500k pentru H.264. Dezactivat dacă este setat la 0.", @@ -312,6 +315,7 @@ "trash_settings_description": "Gestioneazǎ setǎrile coșului de gunoi", "untracked_files": "Fișiere neurmărite", "untracked_files_description": "Aceste fișiere nu sunt urmărite de aplicație. Ele pot fi rezultatul unor mutări eșuate, încărcări întrerupte sau pot rămâne în urmă din cauza unei erori", + "user_cleanup_job": "Curățare utilizator", "user_delete_delay": "Contul și resursele utilizatorului {user} vor fi programate pentru ștergere permanentă în {delay, plural, one {# zi} other {# zile}}.", "user_delete_delay_settings": "Întârziere la ștergere", "user_delete_delay_settings_description": "Numărul de zile după eliminare până la ștergerea permanentă a contului și a resurselor unui utilizator. Procesul de ștergere a utilizatorului rulează la miezul nopții pentru a verifica utilizatorii care sunt pregătiți pentru ștergere. Modificările aduse acestei setări vor fi evaluate la următoarea execuție.", @@ -338,6 +342,7 @@ "advanced": "Avansat", "age_months": "Vârstă {months, plural, one {# lună} other {# luni}}", "age_year_months": "Vârstă de 1 an, {months, plural, one {# lună} other {# luni}}", + "age_years": "{years, plural, other {Vârstă #}}", "album_added": "Album adăugat", "album_added_notification_setting_description": "Primiți o notificare prin e-mail când sunteți adăugat la un album partajat", "album_cover_updated": "Coperta albumului a fost actualizată", @@ -561,10 +566,15 @@ "edit_location": "Editează locație", "edit_name": "Editează nume", "edit_people": "Editează persoane", + "edit_tag": "Modifică etichetă", "edit_title": "Editează Titlul", - "edit_user": "", + "edit_user": "Modifică utilizator", "edited": "Editat", - "editor": "", + "editor": "Editor", + "editor_close_without_save_prompt": "Schimbările nu vor fi salvate", + "editor_close_without_save_title": "Închizi editorul?", + "editor_crop_tool_h2_aspect_ratios": "Raporturi de aspect", + "editor_crop_tool_h2_rotation": "Rotire", "email": "Email", "empty": "", "empty_album": "", @@ -580,7 +590,9 @@ "cannot_navigate_next_asset": "Nu se poate naviga către următorul activ", "cannot_navigate_previous_asset": "Nu se poate naviga la activul anterior", "cant_apply_changes": "Nu se pot aplica schimbări", + "cant_change_activity": "Nu se poate {enabled, select, true {dezactiva} other {activa}} activitatea", "cant_change_asset_favorite": "Nu pot schimba favoritul pentru activ", + "cant_change_metadata_assets_count": "Nu se pot modifica metadatele pentru {count, plural, one {# element} other {# elemente}}", "cant_get_faces": "Nu pot obține fețe", "cant_get_number_of_comments": "Nu pot obține numărul de comentarii", "cant_search_people": "Nu pot căuta oameni", @@ -598,12 +610,34 @@ "failed_to_create_album": "A eșuat crearea albumului", "failed_to_create_shared_link": "A eșuat crearea legăturii partajate", "failed_to_edit_shared_link": "A eșuat editarea legăturii partajate", - "unable_to_add_album_users": "", - "unable_to_add_comment": "", - "unable_to_add_partners": "", - "unable_to_change_album_user_role": "", - "unable_to_change_date": "", - "unable_to_change_location": "", + "failed_to_get_people": "Eșec la obținerea persoanelor", + "failed_to_load_asset": "Eșec la încărcarea resursei", + "failed_to_load_assets": "Eșec la încărcarea resurselor", + "failed_to_load_people": "Eșec la încărcarea oamenilor", + "failed_to_remove_product_key": "Eșec la eliminarea cheii de produs", + "failed_to_stack_assets": "Eșec la combinarea resurselor", + "failed_to_unstack_assets": "Eșec la desfășurarea resurselor", + "import_path_already_exists": "Această cale de import există deja.", + "incorrect_email_or_password": "E-mail sau parolă incorect/ă", + "paths_validation_failed": "{paths, plural, one {# cale} other {# căi}} nu a trecut validarea", + "profile_picture_transparent_pixels": "Pozele de profil nu pot avea pixeli transparenți. Te rugăm să mărești imaginea și/sau să o muți.", + "quota_higher_than_disk_size": "Ai stabilit o cotă mai mare decât dimensiunea discului", + "repair_unable_to_check_items": "Imposibil de verificat {count, select, one {element} other {elemente}}", + "unable_to_add_album_users": "Imposibil de adăugat utilizatori în album", + "unable_to_add_assets_to_shared_link": "Imposibil de adăugat resurse la link-ul partajat", + "unable_to_add_comment": "Imposibil de adăugat comentariu", + "unable_to_add_exclusion_pattern": "Nu se poate adăuga modelul de excluziune", + "unable_to_add_import_path": "Imposibil de adăugat calea de import", + "unable_to_add_partners": "Nu se poate de adăuga parteneri", + "unable_to_add_remove_archive": "Nu se poate {archived, select, true {îndepărta resursa din} other {adăuga resursa în}} arhivă", + "unable_to_add_remove_favorites": "Nu se poate {favorite, select, true {adăuga resursa în} other {îndepărta resursa din}} favorite", + "unable_to_archive_unarchive": "Nu se poate {archived, select, true {arhiva} other {dezarhiva}}", + "unable_to_change_album_user_role": "Nu se poate schimba rolul utilizatorului de album", + "unable_to_change_date": "Imposibil de schimbat data", + "unable_to_change_favorite": "Nu se poate modifica favoritele pentru resursă", + "unable_to_change_location": "Imposibil de schimbat locația", + "unable_to_change_password": "Imposibil de schimbat parola", + "unable_to_change_visibility": "Nu se poate schimba vizibilitatea pentru {count, plural, one {# persoană} other {# persoane}}", "unable_to_check_item": "", "unable_to_check_items": "", "unable_to_create_admin_account": "", @@ -625,22 +659,26 @@ "unable_to_remove_album_users": "", "unable_to_remove_comment": "", "unable_to_remove_library": "", - "unable_to_remove_partner": "", - "unable_to_remove_reaction": "", + "unable_to_remove_offline_files": "Nu se pot șterge fișierele offline", + "unable_to_remove_partner": "Imposibil de eliminat partenerul", + "unable_to_remove_reaction": "Nu se poate elimina reația", "unable_to_remove_user": "", - "unable_to_repair_items": "", - "unable_to_reset_password": "", - "unable_to_resolve_duplicate": "", - "unable_to_restore_assets": "", - "unable_to_restore_trash": "", - "unable_to_restore_user": "", - "unable_to_save_album": "", - "unable_to_save_name": "", - "unable_to_save_profile": "", - "unable_to_save_settings": "", - "unable_to_scan_libraries": "", - "unable_to_scan_library": "", - "unable_to_set_profile_picture": "", + "unable_to_repair_items": "Imposibil de a repara elementele", + "unable_to_reset_password": "Imposibil de a reseta parola", + "unable_to_resolve_duplicate": "Nu se poate de rezolvat duplicatul", + "unable_to_restore_assets": "Nu se pot restaura resursele", + "unable_to_restore_trash": "Nu se poate restaura coșul de gunoi", + "unable_to_restore_user": "Nu se poate restaura utilizatorul", + "unable_to_save_album": "Imposibil de salvat albumul", + "unable_to_save_api_key": "Imposibil de salvat cheia API", + "unable_to_save_date_of_birth": "Imposibil de a salva data de naștere", + "unable_to_save_name": "Imposibil de a salva numele", + "unable_to_save_profile": "Imposibil de a salva profilul", + "unable_to_save_settings": "Nu se pot salva setările", + "unable_to_scan_libraries": "Nu se pot scana librăriile", + "unable_to_scan_library": "Nu se poate de scanat librăria", + "unable_to_set_feature_photo": "Nu se poate seta fotografia principală", + "unable_to_set_profile_picture": "Nu se poate seta fotografia de profil", "unable_to_submit_job": "", "unable_to_trash_asset": "", "unable_to_unlink_account": "", diff --git a/web/src/lib/i18n/ru.json b/web/src/lib/i18n/ru.json index 44b9e48f954f5..c6d4cf9481ad9 100644 --- a/web/src/lib/i18n/ru.json +++ b/web/src/lib/i18n/ru.json @@ -41,6 +41,7 @@ "confirm_email_below": "Чтобы подтвердить, введите \"{email}\" ниже", "confirm_reprocess_all_faces": "Вы уверены, что хотите повторно определить все лица? Будут также удалены имена со всех лиц.", "confirm_user_password_reset": "Вы уверены, что хотите сбросить пароль пользователя {user}?", + "create_job": "Создать задание", "crontab_guru": "Crontab Guru", "disable_login": "Отключить вход", "disabled": "Выключено", @@ -70,6 +71,7 @@ "image_thumbnail_resolution": "Разрешение миниатюр", "image_thumbnail_resolution_description": "Используется при просмотре групп фотографий (на временной шкале, при просмотре альбомов и т.д.). Миниатюры с более высоким разрешением сохраняют больше деталей, но требуют больше времени для кодирования, имеют больший вес и могут снизить скорость отклика приложения.", "job_concurrency": "Параллельная обработка задания - {job}", + "job_created": "Задание создано", "job_not_concurrency_safe": "Эта задача не обеспечивает безопасность параллельности выполнения.", "job_settings": "Настройки заданий", "job_settings_description": "Управление параллельной обработкой заданий", @@ -198,6 +200,7 @@ "password_settings": "Настройки входа с паролем", "password_settings_description": "Управление настройками входа по паролю", "paths_validated_successfully": "Все пути успешно прошли проверку", + "person_cleanup_job": "Очистка персоны", "quota_size_gib": "Размер квоты (ГБ)", "refreshing_all_libraries": "Обновление всех библиотек", "registration": "Регистрация Администратора", @@ -211,6 +214,7 @@ "reset_settings_to_recent_saved": "Сбросьте настройки к последним сохраненным настройкам", "scanning_library_for_changed_files": "Поиск измененных файлов", "scanning_library_for_new_files": "Поиск новых файлов", + "search_jobs": "Поиск заданий...", "send_welcome_email": "Отправить приветственное письмо", "server_external_domain_settings": "Внешний домен", "server_external_domain_settings_description": "Домен для общедоступных ссылок, включая http(s)://", @@ -238,6 +242,7 @@ "storage_template_settings_description": "Управление структурой папок и именем загружаемого файла", "storage_template_user_label": "{label} - это метка хранилища пользователя", "system_settings": "Системные настройки", + "tag_cleanup_job": "Очистка тега", "theme_custom_css_settings": "Пользовательские CSS", "theme_custom_css_settings_description": "Каскадные таблицы стилей позволяют настраивать дизайн Immich.", "theme_settings": "Настройки темы", @@ -312,6 +317,7 @@ "trash_settings_description": "Управление настройками корзины", "untracked_files": "НЕОТСЛЕЖИВАЕМЫЕ ФАЙЛЫ", "untracked_files_description": "Приложение не отслеживает эти файлы. Они могут быть результатом неудачных перемещений, прерванных загрузок или пропущены из-за ошибки", + "user_cleanup_job": "Очистка пользователя", "user_delete_delay": "Аккаунт и ресурсы пользователя {user} будут запланированы для окончательного удаления через {delay, plural, one {# день} few {# дня} many {# дней} other {# дня}}.", "user_delete_delay_settings": "Отложенное удаление", "user_delete_delay_settings_description": "Срок в днях, по истечение которого происходит окончательное удаление учетной записи пользователя и его ресурсов после удаления учётной записи. Задача по удалению пользователей выполняется в полночь. Изменения этой настройки будут учтены при следующем запуске задачи.", @@ -1141,6 +1147,7 @@ "search_options": "Параметры поиска", "search_people": "Поиск людей", "search_places": "Поиск мест", + "search_settings": "Настройки поиска", "search_state": "Поиск региона...", "search_tags": "Поиск по тегам...", "search_timezone": "Поиск часового пояса...", diff --git a/web/src/lib/i18n/sr_Cyrl.json b/web/src/lib/i18n/sr_Cyrl.json index 1241ad72fe7b3..b9908b78f0f79 100644 --- a/web/src/lib/i18n/sr_Cyrl.json +++ b/web/src/lib/i18n/sr_Cyrl.json @@ -41,6 +41,7 @@ "confirm_email_below": "Да бисте потврдили, унесите \"{email}\" испод", "confirm_reprocess_all_faces": "Да ли сте сигурни да желите да поново обрадите сва лица? Ово ће такође обрисати именоване особе.", "confirm_user_password_reset": "Да ли сте сигурни да желите да ресетујете лозинку корисника {user}?", + "create_job": "Креирајте посао", "crontab_guru": "Guru servisnih zadataka", "disable_login": "oneмогући пријаву", "disabled": "", @@ -70,6 +71,7 @@ "image_thumbnail_resolution": "Резолуција сличице", "image_thumbnail_resolution_description": "Користи се приликом прегледа група фотографија (главна временска линија, приказ албума, итд.). Веће резолуције могу да сачувају више детаља, али им је потребно више времена за кодирање, имају веће величине датотека и могу да смање брзину апликације.", "job_concurrency": "{job} паралелност", + "job_created": "Посао креиран", "job_not_concurrency_safe": "Овај посао није безбедан да буде паралелно активан.", "job_settings": "Подешавања посла", "job_settings_description": "Управљајте паралелношћу послова", @@ -198,6 +200,7 @@ "password_settings": "Лозинка за пријаву", "password_settings_description": "Управљајте подешавањима за пријаву лозинком", "paths_validated_successfully": "Све путање су успешно потврђене", + "person_cleanup_job": "Чишћење особа", "quota_size_gib": "Величина квоте (ГиБ)", "refreshing_all_libraries": "Освежавање свих библиотека", "registration": "Регистрација администратора", @@ -211,6 +214,7 @@ "reset_settings_to_recent_saved": "Ресетујте подешавања на недавно сачувана подешавања", "scanning_library_for_changed_files": "Скенирање библиотеке за промењене датотеке", "scanning_library_for_new_files": "Скенирање библиотеке за нове датотеке", + "search_jobs": "Тражи послове...", "send_welcome_email": "Пошаљите е-пошту добродошлице", "server_external_domain_settings": "Екстерни домаин", "server_external_domain_settings_description": "Домаин за јавне дељене везе, укључујући http(s)://", @@ -238,6 +242,7 @@ "storage_template_settings_description": "Управљајте структуром директоријума и именом датотеке средства за отпремање", "storage_template_user_label": "{label} је ознака за складиштење корисника", "system_settings": "Подешавања система", + "tag_cleanup_job": "Чишћење ознака (tags)", "theme_custom_css_settings": "Прилагођени CSS", "theme_custom_css_settings_description": "Каскадни листови стилова (CSS) омогућавају прилагођавање дизајна Immich-a.", "theme_settings": "Подешавање тема", @@ -312,6 +317,7 @@ "trash_settings_description": "Управљајте подешавањима смећа", "untracked_files": "Непраћене датотеке", "untracked_files_description": "Апликација не прати ове датотеке. one могу настати због неуспешних премештења, због прекинутих отпремања или као преостатак због грешке", + "user_cleanup_job": "Чишћење корисника", "user_delete_delay": "Налог и датотеке {user} биће заказани за трајно брисање за {delay, plural, one {# дан} other {# дана}}.", "user_delete_delay_settings": "Избриши уз кашњење", "user_delete_delay_settings_description": "Број дана након уклањања за трајно брисање корисничког налога и датотека. Посао брисања корисника се покреће у поноћ да би се проверили корисници који су спремни за брисање. Промене ове поставке ће бити процењене при следећем извршењу.", @@ -1141,6 +1147,7 @@ "search_options": "Опције претраге", "search_people": "Претражи особе", "search_places": "Претражи места", + "search_settings": "Претрага подешавања", "search_state": "Тражи регион...", "search_tags": "Претражи ознаке (tags)...", "search_timezone": "Претражи временску зону...", diff --git a/web/src/lib/i18n/sr_Latn.json b/web/src/lib/i18n/sr_Latn.json index 26f5483c69ac6..9a32824835d35 100644 --- a/web/src/lib/i18n/sr_Latn.json +++ b/web/src/lib/i18n/sr_Latn.json @@ -41,6 +41,7 @@ "confirm_email_below": "Da biste potvrdili, unesite \"{email}\" ispod", "confirm_reprocess_all_faces": "Da li ste sigurni da želite da ponovo obradite sva lica? Ovo će takođe obrisati imenovane osobe.", "confirm_user_password_reset": "Da li ste sigurni da želite da resetujete lozinku korisnika {user}?", + "create_job": "Kreirajte posao", "crontab_guru": "Guru servisnih zadataka", "disable_login": "Onemogući prijavu", "disabled": "", @@ -70,6 +71,7 @@ "image_thumbnail_resolution": "Rezolucija sličice", "image_thumbnail_resolution_description": "Koristi se prilikom pregleda grupa fotografija (glavna vremenska linija, prikaz albuma, itd.). Veće rezolucije mogu da sačuvaju više detalja, ali im je potrebno više vremena za kodiranje, imaju veće veličine datoteka i mogu da smanje brzinu aplikacije.", "job_concurrency": "{job} paralelnost", + "job_created": "Posao kreiran", "job_not_concurrency_safe": "Ovaj posao nije bezbedan da bude paralelno aktivan.", "job_settings": "Podešavanja posla", "job_settings_description": "Upravljajte paralelnošću poslova", @@ -198,6 +200,7 @@ "password_settings": "Lozinka za prijavu", "password_settings_description": "Upravljajte podešavanjima za prijavu lozinkom", "paths_validated_successfully": "Sve putanje su uspešno potvrđene", + "person_cleanup_job": "Čišćenje osoba", "quota_size_gib": "Veličina kvote (GiB)", "refreshing_all_libraries": "Osvežavanje svih biblioteka", "registration": "Registracija administratora", @@ -211,6 +214,7 @@ "reset_settings_to_recent_saved": "Resetujte podešavanja na nedavno sačuvana podešavanja", "scanning_library_for_changed_files": "Skeniranje biblioteke za promenjene datoteke", "scanning_library_for_new_files": "Skeniranje biblioteke za nove datoteke", + "search_jobs": "Traži poslove...", "send_welcome_email": "Pošaljite e-poštu dobrodošlice", "server_external_domain_settings": "Eksterni domain", "server_external_domain_settings_description": "Domain za javne deljene veze, uključujući http(s)://", @@ -238,6 +242,7 @@ "storage_template_settings_description": "Upravljajte strukturom direktorijuma i imenom datoteke sredstva za otpremanje", "storage_template_user_label": "{label} je oznaka za skladištenje korisnika", "system_settings": "Podešavanja sistema", + "tag_cleanup_job": "Čišćenje oznaka (tags)", "theme_custom_css_settings": "Prilagođeni CSS", "theme_custom_css_settings_description": "Kaskadni listovi stilova (CSS) omogućavaju prilagođavanje dizajna Immich-a.", "theme_settings": "Podešavanje tema", @@ -312,6 +317,7 @@ "trash_settings_description": "Upravljajte podešavanjima smeća", "untracked_files": "Nepraćene datoteke", "untracked_files_description": "Aplikacija ne prati ove datoteke. One mogu nastati zbog neuspešnih premeštenja, zbog prekinutih otpremanja ili kao preostatak zbog greške", + "user_cleanup_job": "Čišćenje korisnika", "user_delete_delay": "Nalog i datoteke {user} biće zakazani za trajno brisanje za {delay, plural, one {# dan} other {# dana}}.", "user_delete_delay_settings": "Izbriši uz kašnjenje", "user_delete_delay_settings_description": "Broj dana nakon uklanjanja za trajno brisanje korisničkog naloga i datoteka. Posao brisanja korisnika se pokreće u ponoć da bi se proverili korisnici koji su spremni za brisanje. Promene ove postavke će biti procenjene pri sledećem izvršenju.", @@ -702,7 +708,7 @@ "unable_to_submit_job": "Nije moguće predati zadatak", "unable_to_trash_asset": "Nije moguće izbaciti materijal u otpad", "unable_to_unlink_account": "Nije moguće raskinuti profil", - "unable_to_unlink_motion_video": "Nije moguće odvezati video sa slikom", + "unable_to_unlink_motion_video": "Nije moguće odvezati video od slike", "unable_to_update_album_cover": "Nije moguće ažurirati naslovnicu albuma", "unable_to_update_album_info": "Nije moguće ažurirati informacije o albumu", "unable_to_update_library": "Nije moguće ažurirati biblioteku", @@ -1141,6 +1147,7 @@ "search_options": "Opcije pretrage", "search_people": "Pretraži osobe", "search_places": "Pretraži mesta", + "search_settings": "Pretraga podešavanja", "search_state": "Traži region...", "search_tags": "Pretraži oznake (tags)...", "search_timezone": "Pretraži vremensku zonu...", diff --git a/web/src/lib/i18n/uk.json b/web/src/lib/i18n/uk.json index 3e24ccacc458d..5c55b6fe838ea 100644 --- a/web/src/lib/i18n/uk.json +++ b/web/src/lib/i18n/uk.json @@ -41,6 +41,7 @@ "confirm_email_below": "Для підтвердження введіть \"{email}\" нижче", "confirm_reprocess_all_faces": "Ви впевнені, що хочете повторно визначити всі обличчя? Це також призведе до видалення імен з усіх облич.", "confirm_user_password_reset": "Ви впевнені, що хочете скинути пароль користувача {user}?", + "create_job": "Створити завдання", "crontab_guru": "", "disable_login": "Вимкнути вхід", "disabled": "", @@ -70,6 +71,7 @@ "image_thumbnail_resolution": "Розмір ескізу", "image_thumbnail_resolution_description": "Використовується при перегляді груп фотографій (основна стрічка, перегляд альбому тощо). Вища роздільна здатність може зберегти більше деталей, але вимагає більше часу для кодування, має більший розмір файлів і може знижувати чутливість додатку.", "job_concurrency": "{job} одночасно", + "job_created": "Завдання створено", "job_not_concurrency_safe": "Це завдання не є безпечним для одночасного виконання.", "job_settings": "Налаштування завдань", "job_settings_description": "Управління паралельністю завдань", @@ -198,6 +200,7 @@ "password_settings": "Налаштування входу з паролем", "password_settings_description": "Керування налаштуваннями входу за паролем", "paths_validated_successfully": "Усі шляхи успішно перевірено", + "person_cleanup_job": "Очищення особи", "quota_size_gib": "Розмір квоти (GiB)", "refreshing_all_libraries": "Оновлення всіх бібліотек", "registration": "Реєстрація адміністратора", @@ -211,6 +214,7 @@ "reset_settings_to_recent_saved": "Скинути налаштування до недавно збережених налаштувань", "scanning_library_for_changed_files": "Сканування бібліотеки на наявність змінених файлів", "scanning_library_for_new_files": "Сканування бібліотеки на наявність нових файлів", + "search_jobs": "Пошук завдань...", "send_welcome_email": "Надіслати лист з вітанням", "server_external_domain_settings": "Зовнішній домен", "server_external_domain_settings_description": "Домен для публічних загальнодоступних посилань, включаючи http(s)://", @@ -238,6 +242,7 @@ "storage_template_settings_description": "Керуйте структурою тек та іменем завантаженого файлу", "storage_template_user_label": "{label} - це мітка зберігання користувача", "system_settings": "Системні налаштування", + "tag_cleanup_job": "Очистити тег", "theme_custom_css_settings": "Власний CSS", "theme_custom_css_settings_description": "Каскадні таблиці стилів дозволяють настроювати дизайн Immich.", "theme_settings": "Налаштування теми", @@ -312,6 +317,7 @@ "trash_settings_description": "Керування налаштуваннями кошика", "untracked_files": "Невідстежувані файли", "untracked_files_description": "Ці файли не відстежуються програмою. Вони можуть бути результатом невдалого переміщення, перерваного завантаження або залишитися через помилку програми", + "user_cleanup_job": "Очищення користувача", "user_delete_delay": "Акаунт {user} і його ресурси будуть заплановані для остаточного видалення через {delay, plural, one {# день} few {# дні} many {# днів} other {# днів}}.", "user_delete_delay_settings": "Видалити затримку", "user_delete_delay_settings_description": "Кількість днів після видалення для остаточного видалення акаунта користувача та його ресурсів. Задача видалення користувача запускається опівночі для перевірки користувачів, готових до видалення. Зміни цього налаштування будуть оцінені під час наступного виконання.", @@ -1139,6 +1145,7 @@ "search_options": "Опції пошуку", "search_people": "Шукати людей", "search_places": "Пошук місць", + "search_settings": "Налаштування пошуку", "search_state": "Пошук регіону...", "search_tags": "Пошук тегів...", "search_timezone": "Пошук часового поясу...", diff --git a/web/src/lib/i18n/vi.json b/web/src/lib/i18n/vi.json index ec8c8d4e7f61a..405c5da442239 100644 --- a/web/src/lib/i18n/vi.json +++ b/web/src/lib/i18n/vi.json @@ -41,6 +41,7 @@ "confirm_email_below": "Để xác nhận, nhập \"{email}\" bên dưới", "confirm_reprocess_all_faces": "Bạn có chắc chắn muốn xử lý lại tất cả các khuôn mặt? Thao tác này sẽ xoá tên người đã được gán.", "confirm_user_password_reset": "Bạn có chắc chắn muốn đặt lại mật khẩu của {user}?", + "create_job": "Tạo tác vụ", "crontab_guru": "Crontab Guru", "disable_login": "Vô hiệu hoá đăng nhập", "disabled": "", @@ -70,6 +71,7 @@ "image_thumbnail_resolution": "Độ phân giải ảnh thu nhỏ", "image_thumbnail_resolution_description": "Dùng khi xem một nhóm các ảnh (dòng thời gian chính, xem album, v.v.). Độ phân giải cao hơn có thể giữ lại nhiều chi tiết hơn nhưng mất nhiều thời gian mã hóa, có kích thước lớn hơn và có thể làm giảm khả năng phản hồi của ứng dụng.", "job_concurrency": "{job} thực hiện đồng thời", + "job_created": "Tác vụ đã được tạo", "job_not_concurrency_safe": "Tác vụ này không an toàn để thực hiện đồng thời.", "job_settings": "Tác vụ", "job_settings_description": "Quản lý mức độ thực hiện đồng thời của tác vụ", @@ -198,6 +200,7 @@ "password_settings": "Mật khẩu đăng nhập", "password_settings_description": "Quản lý cài đặt mật khẩu đăng nhập", "paths_validated_successfully": "Tất cả các đường dẫn được xác minh thành công", + "person_cleanup_job": "Dọn dẹp người", "quota_size_gib": "Hạn mức (GiB)", "refreshing_all_libraries": "Làm mới tất cả các thư viện", "registration": "Đăng ký Quản trị viên", @@ -211,6 +214,7 @@ "reset_settings_to_recent_saved": "Đặt lại cài đặt về cài đặt trước đó", "scanning_library_for_changed_files": "Đang quét thư viện để tìm các tập tin đã thay đổi", "scanning_library_for_new_files": "Đang quét thư viện để tìm các tập tin mới", + "search_jobs": "Tìm kiếm tác vụ...", "send_welcome_email": "Gửi email chào mừng", "server_external_domain_settings": "Tên miền công khai", "server_external_domain_settings_description": "Tên miền dành cho các liên kết chia sẻ công khai, bao gồm http(s)://", @@ -238,6 +242,7 @@ "storage_template_settings_description": "Quản lý cấu trúc thư mục và tên tập tin của ảnh tải lên", "storage_template_user_label": "Cụm từ {label} là Nhãn lưu trữ của người dùng", "system_settings": "Cài đặt hệ thống", + "tag_cleanup_job": "Dọn dẹp thẻ", "theme_custom_css_settings": "CSS tùy chỉnh", "theme_custom_css_settings_description": "Cascading Style Sheets cho phép tùy chỉnh thiết kế của Immich.", "theme_settings": "Chủ đề", @@ -312,6 +317,7 @@ "trash_settings_description": "Quản lý cài đặt thùng rác", "untracked_files": "Các tập tin không được theo dõi", "untracked_files_description": "Những tập tin này không được ứng dụng theo dõi. Chúng có thể là kết quả của các thao tác di chuyển thất bại, tải lên bị gián đoạn, hoặc bị bỏ lại do lỗi", + "user_cleanup_job": "Dọn dẹp người dùng", "user_delete_delay": "Tài khoản và các ảnh của {user} sẽ được lên lịch xóa vĩnh viễn sau {delay, plural, one {# ngày} other {# ngày}}.", "user_delete_delay_settings": "Thời gian xóa", "user_delete_delay_settings_description": "Số ngày chờ xóa để xóa vĩnh viễn tài khoản và các ảnh của người dùng. Tác vụ xóa người dùng chạy vào giữa đêm để kiểm tra các người dùng sẵn sàng bị xóa. Thay đổi cài đặt này sẽ được đánh giá vào lần thực hiện tiếp theo.", @@ -1111,6 +1117,7 @@ "search_options": "Tùy chọn tìm kiếm", "search_people": "Tìm kiếm người", "search_places": "Tìm kiếm địa điểm", + "search_settings": "Cài đặt tìm kiếm", "search_state": "Tìm kiếm tỉnh...", "search_tags": "Tìm kiếm thẻ...", "search_timezone": "Tìm kiếm múi giờ...", diff --git a/web/src/lib/i18n/zh_Hant.json b/web/src/lib/i18n/zh_Hant.json index fb9a18a1f507f..9680363293fb9 100644 --- a/web/src/lib/i18n/zh_Hant.json +++ b/web/src/lib/i18n/zh_Hant.json @@ -34,13 +34,14 @@ "authentication_settings_reenable": "如需重新啟用,請使用 伺服器指令。", "background_task_job": "背景任務", "check_all": "全選", - "cleared_jobs": "已清除 {job} 的任務", + "cleared_jobs": "已清除的作業:{job}", "config_set_by_file": "目前的設定已透過設定檔案設置", "confirm_delete_library": "確定要刪除「{library}」(圖庫)嗎?", "confirm_delete_library_assets": "您確定要刪除此圖庫嗎?這將從 Immich 中刪除{count, plural, one {個項目} other {個項目}},且無法復原。檔案仍會保留在硬碟中。", "confirm_email_below": "請在底下輸入 {email} 來確認", "confirm_reprocess_all_faces": "確定要重新處理所有臉孔嗎?這會清除已命名的人物。", "confirm_user_password_reset": "您確定要重設 {user} 的密碼嗎?", + "create_job": "建立作業", "crontab_guru": "", "disable_login": "停用登入", "disabled": "已禁用", @@ -70,12 +71,13 @@ "image_thumbnail_resolution": "縮圖解析度", "image_thumbnail_resolution_description": "觀賞多張照片時(時間軸、相簿等)用。較高的解析度可以保留更多細節,但編碼時間較長,檔案也較大,且可能降低應用程式的響應速度。", "job_concurrency": "{job}並行", + "job_created": "已建立作業", "job_not_concurrency_safe": "這個任務並行並不安全。", - "job_settings": "任務設定", - "job_settings_description": "管理任務並行", - "job_status": "任務狀態", - "jobs_delayed": "{jobCount, plural, other {# 項任務延遲}}", - "jobs_failed": "{jobCount, plural, other {# 項}}任務失敗", + "job_settings": "作業設定", + "job_settings_description": "管理作業並行", + "job_status": "作業狀態", + "jobs_delayed": "已延後 {jobCount, plural, other {# 項作業}}", + "jobs_failed": "{jobCount, plural, other {# 項}}作業失敗", "library_created": "已建立圖庫:{library}", "library_cron_expression": "Cron 運算式", "library_cron_expression_description": "以 Cron 格式設定掃描時段。詳細資訊請參閱 Crontab Guru", @@ -95,7 +97,7 @@ "logging_level_description": "啟用時的記錄層級。", "logging_settings": "記錄檔", "machine_learning_clip_model": "CLIP 模型", - "machine_learning_clip_model_description": "CLIP 模型 名稱列表。更換模型後須對所有影像重新執行「智慧搜尋」。", + "machine_learning_clip_model_description": "這裏有份 CLIP 模型名單。註:更換模型後須對所有圖片重新執行「智慧搜尋」作業。", "machine_learning_duplicate_detection": "重複項目偵測", "machine_learning_duplicate_detection_enabled": "啟用重複項目偵測", "machine_learning_duplicate_detection_enabled_description": "即使停用,完全一樣的素材仍會被忽略。", @@ -198,6 +200,7 @@ "password_settings": "密碼登入", "password_settings_description": "管理密碼登入設定", "paths_validated_successfully": "所有路徑驗證成功", + "person_cleanup_job": "清理人物", "quota_size_gib": "配額(GiB)", "refreshing_all_libraries": "正在重新整理所有圖庫", "registration": "管理者註冊", @@ -209,8 +212,9 @@ "require_password_change_on_login": "要求使用者在首次登入時更改密碼", "reset_settings_to_default": "將設定重設回預設", "reset_settings_to_recent_saved": "已設回最後儲存的設定", - "scanning_library_for_changed_files": "正在掃描資料庫以檢查文件變更", - "scanning_library_for_new_files": "正在掃描資料庫以檢查新文件", + "scanning_library_for_changed_files": "掃描圖庫中變更的檔案", + "scanning_library_for_new_files": "掃描圖庫中的新檔案", + "search_jobs": "搜尋作業…", "send_welcome_email": "傳送歡迎電子郵件", "server_external_domain_settings": "外部網域", "server_external_domain_settings_description": "公開分享鏈結的網域(包含「http(s)://」)", @@ -238,6 +242,7 @@ "storage_template_settings_description": "管理上傳檔案的資料夾結構和檔名", "storage_template_user_label": "{label} 是使用者的儲存標籤", "system_settings": "系統設定", + "tag_cleanup_job": "清理標記", "theme_custom_css_settings": "自訂 CSS", "theme_custom_css_settings_description": "可以用層疊樣式表(CSS)來自訂 Immich 的設計。", "theme_settings": "主題", @@ -312,8 +317,9 @@ "trash_settings_description": "管理垃圾桶設定", "untracked_files": "未被追蹤的檔案", "untracked_files_description": "這些檔案不會被追蹤。它們可能是移動失誤、上傳中斷或遇到漏洞而遺留的產物", - "user_delete_delay": "{user} 的帳戶和資產將安排在 {delay, plural, one {# 天} other {# 天}} 後進行永久刪除。", - "user_delete_delay_settings": "刪除延遲", + "user_cleanup_job": "清理使用者", + "user_delete_delay": "{user} 的帳號和檔案將於 {delay, plural, other {# 天}}後永久刪除。", + "user_delete_delay_settings": "延後刪除", "user_delete_delay_settings_description": "移除後永久刪除用戶帳戶和資產的天數。用戶刪除任務會在午夜運行,以檢查是否有準備好刪除的用戶。對此設置的更改將在下一次執行時進行評估。", "user_delete_immediately": "{user} 的帳戶和資產將被立即排隊進行永久刪除。", "user_delete_immediately_checkbox": "將用戶和資產排隊進行立即刪除", @@ -593,7 +599,7 @@ "cant_get_number_of_comments": "無法獲取評論數量", "cant_search_people": "無法搜尋人", "cant_search_places": "無法搜尋地點", - "cleared_jobs": "已清除以下工作的任務: {job}", + "cleared_jobs": "已清除的作業:{job}", "error_adding_assets_to_album": "將檔案加入相簿時出錯", "error_adding_users_to_album": "將使用者加入相簿時出錯", "error_deleting_shared_user": "刪除共享使用者時出錯", @@ -694,8 +700,8 @@ "unable_to_save_name": "無法儲存名稱", "unable_to_save_profile": "無法儲存個人資料", "unable_to_save_settings": "無法儲存設定", - "unable_to_scan_libraries": "無法掃描資料庫", - "unable_to_scan_library": "無法掃描資料庫", + "unable_to_scan_libraries": "無法掃描圖庫", + "unable_to_scan_library": "無法掃描圖庫", "unable_to_set_feature_photo": "無法設置特色照片", "unable_to_set_profile_picture": "無法設置個人頭像", "unable_to_submit_job": "無法提交作業", @@ -722,7 +728,7 @@ "expired": "已過期", "expires_date": "失效期限:{date}", "explore": "探索", - "explorer": "探測器", + "explorer": "總攬", "export": "匯出", "export_as_json": "匯出 JSON", "extension": "副檔名", @@ -747,7 +753,7 @@ "fix_incorrect_match": "修復不相符的", "folders": "資料夾", "folders_feature_description": "以資料夾瀏覽檔案系統中的照片和影片", - "force_re-scan_library_files": "強制重新掃描所有資料庫檔案", + "force_re-scan_library_files": "強制重新掃描所有圖庫檔案", "forward": "順序", "general": "一般", "get_help": "線上求助", @@ -802,7 +808,7 @@ "invite_to_album": "邀請至相簿", "items_count": "{count, plural, other {# 個項目}}", "job_settings_description": "", - "jobs": "工作", + "jobs": "作業", "keep": "保留", "keep_all": "全部保留", "keyboard_shortcuts": "鍵盤快捷鍵", diff --git a/web/src/lib/i18n/zh_SIMPLIFIED.json b/web/src/lib/i18n/zh_SIMPLIFIED.json index 08c236dcbf81c..6c4a433b1be24 100644 --- a/web/src/lib/i18n/zh_SIMPLIFIED.json +++ b/web/src/lib/i18n/zh_SIMPLIFIED.json @@ -34,13 +34,14 @@ "authentication_settings_reenable": "如需再次启用,使用 服务器指令。", "background_task_job": "后台任务", "check_all": "检查全部", - "cleared_jobs": "已清理作业:{job}", + "cleared_jobs": "已清理任务:{job}", "config_set_by_file": "当前配置已通过配置文件设置", "confirm_delete_library": "确定要删除图库“{library}”吗?", "confirm_delete_library_assets": "确定要删除该图库吗?这将删除所有包含在Immich中的{count, plural, one {#个项目} other {#个项目}},且无法撤销。但文件仍将保留在磁盘中。", "confirm_email_below": "输入“{email}”来确认", "confirm_reprocess_all_faces": "确定要对全部照片重新进行面部识别吗?这将同时清除所有已命名人物。", "confirm_user_password_reset": "确定要重置用户{user}的密码吗?", + "create_job": "创建任务", "crontab_guru": "Crontab Guru", "disable_login": "禁用登录", "disabled": "已禁用", @@ -51,7 +52,7 @@ "face_detection": "人脸检测", "face_detection_description": "使用机器学习检测项目中的人脸(视频只检测其缩略图中的人脸)。选择“全部”项将会(重新)处理所有项目。选择“缺失”项将尚未处理的项目置于队列中。人脸检测完成后,检测到的人脸将排队进行面部识别,将它们分组到现有的或新的人物中。", "facial_recognition_job_description": "将检测到的人脸按照人物分组。这一步将在人脸检测完成后执行。选择“全部”项将会(重新)分组所有面孔。选择“缺失”项将尚未分配的人脸置于队列中。", - "failed_job_command": "{command}命令执行失败的作业:{job}", + "failed_job_command": "{command}命令执行失败的任务:{job}", "force_delete_user_warning": "警告:这将立即移除用户以及所有项目。该操作无法撤回且文件无法恢复。", "forcing_refresh_library_files": "强制刷新所有图库文件", "image_format_description": "WebP 文件比 JPEG 文件小,但编码速度较慢。", @@ -70,11 +71,12 @@ "image_thumbnail_resolution": "缩略图分辨率", "image_thumbnail_resolution_description": "用于查看照片组(主时间轴、相册视图等)。更高的分辨率可以保留更多的细节,但编码时间更长,文件体积更大,并会降低应用程序的响应速度。", "job_concurrency": "{job}并发", + "job_created": "任务已创建", "job_not_concurrency_safe": "此任务并发并不安全。", "job_settings": "任务设置", "job_settings_description": "管理任务并发", "job_status": "任务状态", - "jobs_delayed": "{jobCount, plural, other {#项作业已推迟}}", + "jobs_delayed": "{jobCount, plural, other {#项任务已推迟}}", "jobs_failed": "{jobCount, plural, other {#项失败}}", "library_created": "已创建图库:{library}", "library_cron_expression": "Cron 表达式", @@ -95,7 +97,7 @@ "logging_level_description": "启用的日志级别。", "logging_settings": "日志", "machine_learning_clip_model": "CLIP模型", - "machine_learning_clip_model_description": "支持的CLIP模型名称见 此处。注意,更换模型后需要对所有图片重新运行“智能检索”作业。", + "machine_learning_clip_model_description": "支持的CLIP模型名称见 此处。注意,更换模型后需要对所有图片重新运行“智能检索”任务。", "machine_learning_duplicate_detection": "重复项检测", "machine_learning_duplicate_detection_enabled": "启用重复检测", "machine_learning_duplicate_detection_enabled_description": "如果禁用此功能,完全相同的项目仍将被去重。", @@ -152,7 +154,7 @@ "note_cannot_be_changed_later": "注意:此项一旦设定,以后无法更改!", "note_unlimited_quota": "提示:输入0表示无限制", "notification_email_from_address": "发件人地址", - "notification_email_from_address_description": "发件人邮箱地址,例如“张三<12345@qq.com>”", + "notification_email_from_address_description": "发件人邮箱地址,例如“Immich Photo Server ”", "notification_email_host_description": "服务器地址:(例如:smtp.qq.com)", "notification_email_ignore_certificate_errors": "忽略证书错误", "notification_email_ignore_certificate_errors_description": "忽略TLS证书验证错误(不建议)", @@ -198,6 +200,7 @@ "password_settings": "密码登录", "password_settings_description": "管理密码登录设置", "paths_validated_successfully": "所有路径验证成功", + "person_cleanup_job": "清理人物", "quota_size_gib": "配额大小(GB)", "refreshing_all_libraries": "刷新所有图库", "registration": "注册管理员", @@ -211,6 +214,7 @@ "reset_settings_to_recent_saved": "恢复到最近保存的设置", "scanning_library_for_changed_files": "扫描图库变更的文件", "scanning_library_for_new_files": "扫描图库新增的文件", + "search_jobs": "搜索任务...", "send_welcome_email": "发送欢迎邮件", "server_external_domain_settings": "外部域名", "server_external_domain_settings_description": "共享链接域名,包括 http(s)://", @@ -238,6 +242,7 @@ "storage_template_settings_description": "管理上传项目文件夹结构和文件名", "storage_template_user_label": "{label}是用户的存储标签", "system_settings": "系统设置", + "tag_cleanup_job": "清理标签", "theme_custom_css_settings": "自定义CSS", "theme_custom_css_settings_description": "可以通过CSS自定义Immich外观。", "theme_settings": "主题设置", @@ -312,6 +317,7 @@ "trash_settings_description": "管理回收站设置", "untracked_files": "未被追踪的文件", "untracked_files_description": "这些文件未被系统追踪。 这可能是移动失败、上传中断或因bug而落下", + "user_cleanup_job": "清理用户", "user_delete_delay": "{user}的账户及项目将在{delay, plural, one {#天} other {#天}}后自动永久删除。", "user_delete_delay_settings": "延期删除", "user_delete_delay_settings_description": "删除用户后永久删除账户及其所有项目的天数。用户删除作业在午夜运行,检查是否有用户可以删除。对该设置的更改将在下次执行时开始计算。", @@ -594,7 +600,7 @@ "cant_get_number_of_comments": "无法获取评论数量", "cant_search_people": "无法检索人物", "cant_search_places": "无法检索地点", - "cleared_jobs": "已删除作业:{job}", + "cleared_jobs": "已删除任务:{job}", "error_adding_assets_to_album": "添加项目到相册时出错", "error_adding_users_to_album": "添加用户到相册时出错", "error_deleting_shared_user": "删除共享用户时出错", From 1ef283460345b2e9e87106790e10a8af2e32ae61 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Wed, 25 Sep 2024 12:30:01 -0400 Subject: [PATCH 092/123] docs: hidden files cursed knowledge (#12929) --- docs/src/pages/cursed-knowledge.tsx | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/src/pages/cursed-knowledge.tsx b/docs/src/pages/cursed-knowledge.tsx index 55bb3d4cee193..1e5c724d16678 100644 --- a/docs/src/pages/cursed-knowledge.tsx +++ b/docs/src/pages/cursed-knowledge.tsx @@ -6,6 +6,7 @@ import { mdiLeadPencil, mdiLockOff, mdiLockOutline, + mdiMicrosoftWindows, mdiSecurity, mdiSpeedometerSlow, mdiTrashCan, @@ -21,6 +22,18 @@ const withLanguage = (date: Date) => (language: string) => date.toLocaleDateStri type Item = Omit & { date: Date }; const items: Item[] = [ + { + icon: mdiMicrosoftWindows, + iconColor: '#357EC7', + title: 'Hidden files in Windows are cursed', + description: + 'Hidden files in Windows cannot be opened with the "w" flag. That, combined with SMB option "hide dot files" leads to a lot of confusion.', + link: { + url: 'https://github.com/immich-app/immich/pull/12812', + text: '#12812', + }, + date: new Date(2024, 8, 20), + }, { icon: mdiWrap, iconColor: 'gray', From b2f2be34855d7c70fc699d4504d72b037df44e0b Mon Sep 17 00:00:00 2001 From: Jonathan Jogenfors Date: Wed, 25 Sep 2024 19:26:19 +0200 Subject: [PATCH 093/123] refactor(server): library syncing (#12220) * refactor: library scanning fix tests remove offline files step cleanup library service improve tests cleanup tests add db migration fix e2e cleanup openapi fix tests fix tests update docs update docs update mobile code fix formatting don't remove assets from library with invalid import path use trash for offline files add migration simplify scan endpoint cleanup library panel fix library tests e2e lint fix e2e trash e2e fix lint add asset trash tests add more tests ensure thumbs are generated cleanup svelte cleanup queue names fix tests fix lint add warning due to trash fix trash tests fix lint fix tests Admin message for offline asset fix comments Update web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> add permission to library scan endpoint revert asset interface sort add trash reason to shared link stub improve path view in offline update docs improve trash performance fix comments remove stray comment * refactor: add back isOffline and remove trashReason from asset, change sync job flow * chore(server): drop coverage to 80% for functions * chore: rebase and generated files --------- Co-authored-by: Zack Pollard --- docs/docs/features/libraries.md | 46 +- e2e/src/api/specs/library.e2e-spec.ts | 476 +++++---------- e2e/src/api/specs/search.e2e-spec.ts | 2 +- e2e/src/api/specs/trash.e2e-spec.ts | 113 +++- e2e/src/utils.ts | 14 + mobile/lib/entities/asset.entity.g.dart | 141 ++--- .../lib/extensions/collection_extensions.dart | 5 +- .../asset_viewer/bottom_gallery_bar.dart | 29 +- .../asset_viewer/top_control_app_bar.dart | 3 +- mobile/openapi/README.md | 2 - mobile/openapi/lib/api.dart | 1 - mobile/openapi/lib/api/assets_api.dart | 14 +- mobile/openapi/lib/api/libraries_api.dart | 54 +- mobile/openapi/lib/api_client.dart | 2 - .../openapi/lib/model/scan_library_dto.dart | 125 ---- open-api/immich-openapi-specs.json | 59 -- open-api/typescript-sdk/src/fetch-client.ts | 19 +- server/src/controllers/library.controller.ts | 28 +- server/src/dtos/asset-media.dto.ts | 3 - server/src/dtos/library.dto.ts | 10 +- server/src/interfaces/asset.interface.ts | 3 - server/src/interfaces/job.interface.ts | 27 +- server/src/queries/asset.repository.sql | 41 -- server/src/repositories/asset.repository.ts | 60 +- server/src/repositories/job.repository.ts | 10 +- server/src/repositories/trash.repository.ts | 11 +- server/src/services/asset-media.service.ts | 1 - server/src/services/job.service.ts | 2 +- server/src/services/library.service.spec.ts | 561 +++++------------- server/src/services/library.service.ts | 403 ++++++------- server/src/services/microservices.service.ts | 10 +- server/src/services/trash.service.spec.ts | 4 +- server/src/utils/database.ts | 2 +- server/test/fixtures/asset.stub.ts | 214 +++---- .../repositories/asset.repository.mock.ts | 1 - server/vitest.config.mjs | 2 +- .../asset-viewer/asset-viewer-nav-bar.svelte | 3 +- .../asset-viewer/detail-panel.svelte | 15 +- .../buttons/circle-icon-button.svelte | 3 +- web/src/lib/i18n/ar.json | 6 +- web/src/lib/i18n/bg.json | 6 +- web/src/lib/i18n/bi.json | 6 +- web/src/lib/i18n/ca.json | 6 +- web/src/lib/i18n/cs.json | 6 +- web/src/lib/i18n/da.json | 6 +- web/src/lib/i18n/de.json | 6 +- web/src/lib/i18n/en.json | 25 +- web/src/lib/i18n/es.json | 6 +- web/src/lib/i18n/fa.json | 6 +- web/src/lib/i18n/fi.json | 4 +- web/src/lib/i18n/fr.json | 6 +- web/src/lib/i18n/he.json | 6 +- web/src/lib/i18n/hi.json | 6 +- web/src/lib/i18n/hr.json | 4 +- web/src/lib/i18n/hu.json | 6 +- web/src/lib/i18n/hy.json | 6 +- web/src/lib/i18n/id.json | 6 +- web/src/lib/i18n/it.json | 6 +- web/src/lib/i18n/ja.json | 6 +- web/src/lib/i18n/kmr.json | 6 +- web/src/lib/i18n/ko.json | 6 +- web/src/lib/i18n/lt.json | 2 +- web/src/lib/i18n/lv.json | 2 +- web/src/lib/i18n/mn.json | 2 +- web/src/lib/i18n/nb_NO.json | 6 +- web/src/lib/i18n/nl.json | 6 +- web/src/lib/i18n/pl.json | 6 +- web/src/lib/i18n/pt.json | 6 +- web/src/lib/i18n/pt_BR.json | 6 +- web/src/lib/i18n/ro.json | 4 +- web/src/lib/i18n/ru.json | 6 +- web/src/lib/i18n/sk.json | 2 +- web/src/lib/i18n/sl.json | 2 +- web/src/lib/i18n/sr_Cyrl.json | 6 +- web/src/lib/i18n/sr_Latn.json | 6 +- web/src/lib/i18n/sv.json | 4 +- web/src/lib/i18n/ta.json | 6 +- web/src/lib/i18n/th.json | 4 +- web/src/lib/i18n/tr.json | 6 +- web/src/lib/i18n/uk.json | 6 +- web/src/lib/i18n/vi.json | 6 +- web/src/lib/i18n/zh_Hant.json | 6 +- web/src/lib/i18n/zh_SIMPLIFIED.json | 6 +- web/src/lib/utils/asset-utils.ts | 7 - .../admin/library-management/+page.svelte | 88 +-- 85 files changed, 941 insertions(+), 1926 deletions(-) delete mode 100644 mobile/openapi/lib/model/scan_library_dto.dart diff --git a/docs/docs/features/libraries.md b/docs/docs/features/libraries.md index cdea1a11a51e1..17555469543c8 100644 --- a/docs/docs/features/libraries.md +++ b/docs/docs/features/libraries.md @@ -1,18 +1,14 @@ -# Libraries +# External Libraries -## Overview +External libraries track assets stored in the filesystem outside of Immich. When the external library is scanned, Immich will load videos and photos from disk and create the corresponding assets. These assets will then be shown in the main timeline, and they will look and behave like any other asset, including viewing on the map, adding to albums, etc. Later, if a file is modified outside of Immich, you need to scan the library for the changes to show up. -Immich supports the creation of libraries which is a top-level asset container. Currently, there are two types of libraries: traditional upload libraries that can sync with a mobile device, and external libraries, that keeps up to date with files on disk. Libraries are different from albums in that an asset can belong to multiple albums but only one library, and deleting a library deletes all assets contained within. As of August 2023, this is a new feature and libraries have a lot of potential for future development beyond what is documented here. This document attempts to describe the current state of libraries. +If an external asset is deleted from disk, Immich will move it to trash on rescan. To restore the asset, you need to restore the original file. After 30 days the file will be removed from trash, and any changes to metadata within Immich will be lost. -## External Libraries +:::caution -External libraries tracks assets stored outside of Immich, i.e. in the file system. When the external library is scanned, Immich will read the metadata from the file and create an asset in the library for each image or video file. These items will then be shown in the main timeline, and they will look and behave like any other asset, including viewing on the map, adding to albums, etc. +If you add metadata to an external asset in any way (i.e. add it to an album or edit the description), that metadata is only stored inside Immich and will not be persisted to the external asset file. If you move an asset to another location within the library all such metadata will be lost upon rescan. This is because the asset is considered a new asset after the move. This is a known issue and will be fixed in a future release. -If a file is modified outside of Immich, the changes will not be reflected in immich until the library is scanned again. There are different ways to scan a library depending on the use case: - -- Scan Library Files: This is the default scan method and also the quickest. It will scan all files in the library and add new files to the library. It will notice if any files are missing (see below) but not check existing assets -- Scan All Library Files: Same as above, but will check each existing asset to see if the modification time has changed. If it has, the asset will be updated. Since it has to check each asset, this is slower than Scan Library Files. -- Force Scan All Library Files: Same as above, but will read each asset from disk no matter the modification time. This is useful in some cases where an asset has been modified externally but the modification time has not changed. This is the slowest way to scan because it reads each asset from disk. +::: :::caution @@ -20,22 +16,6 @@ Due to aggressive caching it can take some time for a refreshed asset to appear ::: -In external libraries, the file path is used for duplicate detection. This means that if a file is moved to a different location, it will be added as a new asset. If the file is moved back to its original location, it will be added as a new asset. In contrast to upload libraries, two identical files can be uploaded if they are in different locations. This is a deliberate design choice to make Immich reflect the file system as closely as possible. Remember that duplication detection is only done within the same library, so if you have multiple external libraries, the same file can be added to multiple libraries. - -:::caution - -If you add assets from an external library to an album and then move the asset to another location within the library, the asset will be removed from the album upon rescan. This is because the asset is considered a new asset after the move. This is a known issue and will be fixed in a future release. - -::: - -### Deleted External Assets - -Note: Either a manual or scheduled library scan must have been performed to identify offline assets before this process will work. - -In all above scan methods, Immich will check if any files are missing. This can happen if files are deleted, or if they are on a storage location that is currently unavailable, like a network drive that is not mounted, or a USB drive that has been unplugged. In order to prevent accidental deletion of assets, Immich will not immediately delete an asset from the library if the file is missing. Instead, the asset will be internally marked as offline and will still be visible in the main timeline. If the file is moved back to its original location and the library is scanned again, the asset will be restored. - -Finally, files can be deleted from Immich via the `Remove Offline Files` job. This job can be found by the three dots menu for the associated external storage that was configured under Administration > Libraries (the same location described at [create external libraries](#create-external-libraries)). When this job is run, any assets marked as offline will then be removed from Immich. Run this job whenever files have been deleted from the file system and you want to remove them from Immich. - ### Import Paths External libraries use import paths to determine which files to scan. Each library can have multiple import paths so that files from different locations can be added to the same library. Import paths are scanned recursively, and if a file is in multiple import paths, it will only be added once. Each import file must be a readable directory that exists on the filesystem; the import path dialog will alert you of any paths that are not accessible. @@ -66,9 +46,13 @@ Some basic examples: - `**/Raw/**` will exclude all files in any directory named `Raw` - `**/*.{tif,jpg}` will exclude all files with the extension `.tif` or `.jpg` +Special characters such as @ should be escaped, for instance: + +- `**/\@eadir/**` will exclude all files in any directory named `@eadir` + ### Automatic watching (EXPERIMENTAL) -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. +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. 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. @@ -84,7 +68,7 @@ In rare cases, the library watcher can hang, preventing Immich from starting up. ### Nightly job -There is an automatic job that's run once a day and refreshes all modified files in all libraries as well as cleans up any libraries stuck in deletion. +There is an automatic scan job that is scheduled to run once a day. This job also cleans up any libraries stuck in deletion. ## Usage @@ -120,7 +104,7 @@ This will disallow the images from being deleted in the web UI, or adding metada _Remember to run `docker compose up -d` to register the changes. Make sure you can see the mounted path in the container._ ::: -### Create External Libraries +### Create A New Library These actions must be performed by the Immich administrator. @@ -144,7 +128,7 @@ Next, we'll add an exclusion pattern to filter out raw files. - Enter `**/Raw/**` and click save. - Click save - Click the drop-down menu on the newly created library -- Click on Scan Library Files +- Click on Scan The christmas trip library will now be scanned in the background. In the meantime, let's add the videos and old photos to another library. @@ -161,7 +145,7 @@ If you get an error here, please rename the other external library to something - Click on Add Path - Enter `/mnt/media/videos` then click Add - Click Save -- Click on Scan Library Files +- Click on Scan Within seconds, the assets from the old-pics and videos folders should show up in the main timeline. diff --git a/e2e/src/api/specs/library.e2e-spec.ts b/e2e/src/api/specs/library.e2e-spec.ts index 8d98e866301f8..20bd230159c28 100644 --- a/e2e/src/api/specs/library.e2e-spec.ts +++ b/e2e/src/api/specs/library.e2e-spec.ts @@ -1,11 +1,4 @@ -import { - LibraryResponseDto, - LoginResponseDto, - ScanLibraryDto, - getAllLibraries, - removeOfflineFiles, - scanLibrary, -} from '@immich/sdk'; +import { LibraryResponseDto, LoginResponseDto, getAllLibraries, scanLibrary } from '@immich/sdk'; import { cpSync, existsSync } from 'node:fs'; import { Socket } from 'socket.io-client'; import { userDto, uuidDto } from 'src/fixtures'; @@ -15,8 +8,7 @@ import request from 'supertest'; import { utimes } from 'utimes'; import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; -const scan = async (accessToken: string, id: string, dto: ScanLibraryDto = {}) => - scanLibrary({ id, scanLibraryDto: dto }, { headers: asBearerAuth(accessToken) }); +const scan = async (accessToken: string, id: string) => scanLibrary({ id }, { headers: asBearerAuth(accessToken) }); describe('/libraries', () => { let admin: LoginResponseDto; @@ -293,14 +285,19 @@ describe('/libraries', () => { expect(body).toEqual(errorDto.unauthorized); }); - it('should scan external library', async () => { + it('should import new asset when scanning external library', async () => { const library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId, importPaths: [`${testAssetDirInternal}/temp/directoryA`], }); - await scan(admin.accessToken, library.id); - await utils.waitForWebsocketEvent({ event: 'assetUpload', total: 1 }); + const { status } = await request(app) + .post(`/libraries/${library.id}/scan`) + .set('Authorization', `Bearer ${admin.accessToken}`) + .send(); + expect(status).toBe(204); + + await utils.waitForQueueFinish(admin.accessToken, 'library'); const { assets } = await utils.metadataSearch(admin.accessToken, { originalPath: `${testAssetDirInternal}/temp/directoryA/assetA.png`, @@ -315,8 +312,13 @@ describe('/libraries', () => { exclusionPatterns: ['**/directoryA'], }); - await scan(admin.accessToken, library.id); - await utils.waitForWebsocketEvent({ event: 'assetUpload', total: 1 }); + const { status } = await request(app) + .post(`/libraries/${library.id}/scan`) + .set('Authorization', `Bearer ${admin.accessToken}`) + .send(); + expect(status).toBe(204); + + await utils.waitForQueueFinish(admin.accessToken, 'library'); const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); @@ -330,8 +332,13 @@ describe('/libraries', () => { importPaths: [`${testAssetDirInternal}/temp/directoryA`, `${testAssetDirInternal}/temp/directoryB`], }); - await scan(admin.accessToken, library.id); - await utils.waitForWebsocketEvent({ event: 'assetUpload', total: 2 }); + const { status } = await request(app) + .post(`/libraries/${library.id}/scan`) + .set('Authorization', `Bearer ${admin.accessToken}`) + .send(); + expect(status).toBe(204); + + await utils.waitForQueueFinish(admin.accessToken, 'library'); const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); @@ -340,95 +347,144 @@ describe('/libraries', () => { expect(assets.items.find((asset) => asset.originalPath.includes('directoryB'))).toBeDefined(); }); - it('should pick up new files', async () => { + it('should reimport a modified file', async () => { const library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId, importPaths: [`${testAssetDirInternal}/temp`], }); - await scan(admin.accessToken, library.id); - await utils.waitForWebsocketEvent({ event: 'assetUpload', total: 2 }); - - const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); - - expect(assets.count).toBe(2); - - utils.createImageFile(`${testAssetDir}/temp/directoryA/assetC.png`); + utils.createImageFile(`${testAssetDir}/temp/directoryA/assetB.jpg`); + await utimes(`${testAssetDir}/temp/directoryA/assetB.jpg`, 447_775_200_000); await scan(admin.accessToken, library.id); - await utils.waitForWebsocketEvent({ event: 'assetUpload', total: 3 }); + await utils.waitForQueueFinish(admin.accessToken, 'library'); - const { assets: newAssets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); + cpSync(`${testAssetDir}/albums/nature/tanners_ridge.jpg`, `${testAssetDir}/temp/directoryA/assetB.jpg`); + await utimes(`${testAssetDir}/temp/directoryA/assetB.jpg`, 447_775_200_001); - expect(newAssets.count).toBe(3); - utils.removeImageFile(`${testAssetDir}/temp/directoryA/assetC.png`); + const { status } = await request(app) + .post(`/libraries/${library.id}/scan`) + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ refreshModifiedFiles: true }); + expect(status).toBe(204); + + await utils.waitForQueueFinish(admin.accessToken, 'library'); + await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction'); + utils.removeImageFile(`${testAssetDir}/temp/directoryA/assetB.jpg`); + + const { assets } = await utils.metadataSearch(admin.accessToken, { + libraryId: library.id, + model: 'NIKON D750', + }); + expect(assets.count).toBe(1); }); - it('should offline a file missing from disk', async () => { - utils.createImageFile(`${testAssetDir}/temp/directoryA/assetC.png`); + it('should not reimport unmodified files', async () => { const library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId, importPaths: [`${testAssetDirInternal}/temp`], }); + utils.createImageFile(`${testAssetDir}/temp/directoryA/assetB.jpg`); + await utimes(`${testAssetDir}/temp/directoryA/assetB.jpg`, 447_775_200_000); + + await scan(admin.accessToken, library.id); + await utils.waitForQueueFinish(admin.accessToken, 'library'); + + cpSync(`${testAssetDir}/albums/nature/tanners_ridge.jpg`, `${testAssetDir}/temp/directoryA/assetB.jpg`); + await utimes(`${testAssetDir}/temp/directoryA/assetB.jpg`, 447_775_200_000); + + const { status } = await request(app) + .post(`/libraries/${library.id}/scan`) + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ refreshModifiedFiles: true }); + expect(status).toBe(204); + + await utils.waitForQueueFinish(admin.accessToken, 'library'); + await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction'); + utils.removeImageFile(`${testAssetDir}/temp/directoryA/assetB.jpg`); + + const { assets } = await utils.metadataSearch(admin.accessToken, { + libraryId: library.id, + model: 'NIKON D750', + }); + expect(assets.count).toBe(0); + }); + + it('should set an asset offline if its file is missing', async () => { + const library = await utils.createLibrary(admin.accessToken, { + ownerId: admin.userId, + importPaths: [`${testAssetDirInternal}/temp/offline`], + }); + + utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`); + await scan(admin.accessToken, library.id); await utils.waitForQueueFinish(admin.accessToken, 'library'); const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); - expect(assets.count).toBe(3); + expect(assets.count).toBe(1); - utils.removeImageFile(`${testAssetDir}/temp/directoryA/assetC.png`); + utils.removeImageFile(`${testAssetDir}/temp/offline/offline.png`); + + const { status } = await request(app) + .post(`/libraries/${library.id}/scan`) + .set('Authorization', `Bearer ${admin.accessToken}`) + .send(); + expect(status).toBe(204); - await scan(admin.accessToken, library.id); await utils.waitForQueueFinish(admin.accessToken, 'library'); + const trashedAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id); + expect(trashedAsset.originalPath).toBe(`${testAssetDirInternal}/temp/offline/offline.png`); + expect(trashedAsset.isOffline).toEqual(true); + const { assets: newAssets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); - expect(newAssets.count).toBe(3); - - expect(newAssets.items).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - isOffline: true, - originalFileName: 'assetC.png', - }), - ]), - ); + expect(newAssets.items).toEqual([]); }); - it('should offline a file outside of import paths', async () => { + it('should set an asset offline its file is not in any import path', async () => { const library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp`], + importPaths: [`${testAssetDirInternal}/temp/offline`], }); + utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`); + await scan(admin.accessToken, library.id); await utils.waitForQueueFinish(admin.accessToken, 'library'); + const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); + expect(assets.count).toBe(1); + + utils.createDirectory(`${testAssetDir}/temp/another-path/`); + await request(app) .put(`/libraries/${library.id}`) .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ importPaths: [`${testAssetDirInternal}/temp/directoryA`] }); + .send({ importPaths: [`${testAssetDirInternal}/temp/another-path/`] }); + + const { status } = await request(app) + .post(`/libraries/${library.id}/scan`) + .set('Authorization', `Bearer ${admin.accessToken}`) + .send(); + expect(status).toBe(204); - await scan(admin.accessToken, library.id); await utils.waitForQueueFinish(admin.accessToken, 'library'); - const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); + const trashedAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id); + expect(trashedAsset.originalPath).toBe(`${testAssetDirInternal}/temp/offline/offline.png`); + expect(trashedAsset.isOffline).toBe(true); - expect(assets.items).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - isOffline: false, - originalFileName: 'assetA.png', - }), - expect.objectContaining({ - isOffline: true, - originalFileName: 'assetB.png', - }), - ]), - ); + const { assets: newAssets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); + + expect(newAssets.items).toEqual([]); + + utils.removeImageFile(`${testAssetDir}/temp/offline/offline.png`); + utils.removeDirectory(`${testAssetDir}/temp/another-path/`); }); - it('should offline a file covered by an exclusion pattern', async () => { + it('should set an asset offline if its file is covered by an exclusion pattern', async () => { const library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId, importPaths: [`${testAssetDirInternal}/temp`], @@ -437,6 +493,12 @@ describe('/libraries', () => { await scan(admin.accessToken, library.id); await utils.waitForQueueFinish(admin.accessToken, 'library'); + const { assets } = await utils.metadataSearch(admin.accessToken, { + libraryId: library.id, + originalFileName: 'assetB.png', + }); + expect(assets.count).toBe(1); + await request(app) .put(`/libraries/${library.id}`) .set('Authorization', `Bearer ${admin.accessToken}`) @@ -445,282 +507,21 @@ describe('/libraries', () => { await scan(admin.accessToken, library.id); await utils.waitForQueueFinish(admin.accessToken, 'library'); - const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); + const trashedAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id); + expect(trashedAsset.isTrashed).toBe(true); + expect(trashedAsset.originalPath).toBe(`${testAssetDirInternal}/temp/directoryB/assetB.png`); + expect(trashedAsset.isOffline).toBe(true); - expect(assets.count).toBe(2); + const { assets: newAssets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); - expect(assets.items).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - isOffline: false, - originalFileName: 'assetA.png', - }), - expect.objectContaining({ - isOffline: true, - originalFileName: 'assetB.png', - }), - ]), - ); + expect(newAssets.items).toEqual([ + expect.objectContaining({ + originalFileName: 'assetA.png', + }), + ]); }); - it('should not try to delete offline files', async () => { - utils.createImageFile(`${testAssetDir}/temp/offline1/assetA.png`); - - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp/offline1`], - }); - - await scan(admin.accessToken, library.id); - await utils.waitForQueueFinish(admin.accessToken, 'library'); - - const { assets: initialAssets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); - expect(initialAssets).toEqual({ - count: 1, - total: 1, - facets: [], - items: [expect.objectContaining({ originalFileName: 'assetA.png' })], - nextPage: null, - }); - - utils.removeImageFile(`${testAssetDir}/temp/offline1/assetA.png`); - - await scan(admin.accessToken, library.id); - await utils.waitForQueueFinish(admin.accessToken, 'library'); - - const { assets: offlineAssets } = await utils.metadataSearch(admin.accessToken, { - libraryId: library.id, - isOffline: true, - }); - expect(offlineAssets).toEqual({ - count: 1, - total: 1, - facets: [], - items: [expect.objectContaining({ originalFileName: 'assetA.png' })], - nextPage: null, - }); - - utils.createImageFile(`${testAssetDir}/temp/offline1/assetA.png`); - await removeOfflineFiles({ id: library.id }, { headers: asBearerAuth(admin.accessToken) }); - await utils.waitForQueueFinish(admin.accessToken, 'library'); - await utils.waitForWebsocketEvent({ event: 'assetDelete', total: 1 }); - - expect(existsSync(`${testAssetDir}/temp/offline1/assetA.png`)).toBe(true); - - utils.removeImageFile(`${testAssetDir}/temp/offline1/assetA.png`); - }); - - it('should scan new files', async () => { - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp`], - }); - - await scan(admin.accessToken, library.id); - await utils.waitForQueueFinish(admin.accessToken, 'library'); - - utils.createImageFile(`${testAssetDir}/temp/directoryC/assetC.png`); - - await scan(admin.accessToken, library.id); - await utils.waitForQueueFinish(admin.accessToken, 'library'); - - const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); - - expect(assets.count).toBe(3); - expect(assets.items).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - originalFileName: 'assetC.png', - }), - ]), - ); - - utils.removeImageFile(`${testAssetDir}/temp/directoryC/assetC.png`); - }); - - describe('with refreshModifiedFiles=true', () => { - it('should reimport modified files', async () => { - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp`], - }); - - utils.createImageFile(`${testAssetDir}/temp/directoryA/assetB.jpg`); - await utimes(`${testAssetDir}/temp/directoryA/assetB.jpg`, 447_775_200_000); - - await scan(admin.accessToken, library.id); - await utils.waitForQueueFinish(admin.accessToken, 'library'); - - cpSync(`${testAssetDir}/albums/nature/tanners_ridge.jpg`, `${testAssetDir}/temp/directoryA/assetB.jpg`); - await utimes(`${testAssetDir}/temp/directoryA/assetB.jpg`, 447_775_200_001); - - await scan(admin.accessToken, library.id, { refreshModifiedFiles: true }); - await utils.waitForQueueFinish(admin.accessToken, 'library'); - await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction'); - utils.removeImageFile(`${testAssetDir}/temp/directoryA/assetB.jpg`); - - const { assets } = await utils.metadataSearch(admin.accessToken, { - libraryId: library.id, - model: 'NIKON D750', - }); - expect(assets.count).toBe(1); - }); - - it('should not reimport unmodified files', async () => { - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp`], - }); - - utils.createImageFile(`${testAssetDir}/temp/directoryA/assetB.jpg`); - await utimes(`${testAssetDir}/temp/directoryA/assetB.jpg`, 447_775_200_000); - - await scan(admin.accessToken, library.id); - await utils.waitForQueueFinish(admin.accessToken, 'library'); - - cpSync(`${testAssetDir}/albums/nature/tanners_ridge.jpg`, `${testAssetDir}/temp/directoryA/assetB.jpg`); - await utimes(`${testAssetDir}/temp/directoryA/assetB.jpg`, 447_775_200_000); - - await scan(admin.accessToken, library.id, { refreshModifiedFiles: true }); - await utils.waitForQueueFinish(admin.accessToken, 'library'); - await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction'); - utils.removeImageFile(`${testAssetDir}/temp/directoryA/assetB.jpg`); - - const { assets } = await utils.metadataSearch(admin.accessToken, { - libraryId: library.id, - model: 'NIKON D750', - }); - expect(assets.count).toBe(0); - }); - }); - - describe('with refreshAllFiles=true', () => { - it('should reimport all files', async () => { - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp`], - }); - - utils.createImageFile(`${testAssetDir}/temp/directoryA/assetB.jpg`); - await utimes(`${testAssetDir}/temp/directoryA/assetB.jpg`, 447_775_200_000); - - await scan(admin.accessToken, library.id); - await utils.waitForQueueFinish(admin.accessToken, 'library'); - - cpSync(`${testAssetDir}/albums/nature/tanners_ridge.jpg`, `${testAssetDir}/temp/directoryA/assetB.jpg`); - await utimes(`${testAssetDir}/temp/directoryA/assetB.jpg`, 447_775_200_000); - - await scan(admin.accessToken, library.id, { refreshAllFiles: true }); - await utils.waitForQueueFinish(admin.accessToken, 'library'); - await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction'); - utils.removeImageFile(`${testAssetDir}/temp/directoryA/assetB.jpg`); - - const { assets } = await utils.metadataSearch(admin.accessToken, { - libraryId: library.id, - model: 'NIKON D750', - }); - expect(assets.count).toBe(1); - }); - }); - }); - - describe('POST /libraries/:id/removeOffline', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).post(`/libraries/${uuidDto.notFound}/removeOffline`).send({}); - - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - - it('should remove offline files', async () => { - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp/offline`], - }); - - utils.createImageFile(`${testAssetDir}/temp/offline/online.png`); - utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`); - - await scan(admin.accessToken, library.id); - await utils.waitForQueueFinish(admin.accessToken, 'library'); - - const { assets: initialAssets } = await utils.metadataSearch(admin.accessToken, { - libraryId: library.id, - }); - expect(initialAssets.count).toBe(2); - - utils.removeImageFile(`${testAssetDir}/temp/offline/offline.png`); - - await scan(admin.accessToken, library.id); - await utils.waitForQueueFinish(admin.accessToken, 'library'); - - const { assets: offlineAssets } = await utils.metadataSearch(admin.accessToken, { - libraryId: library.id, - isOffline: true, - }); - expect(offlineAssets.count).toBe(1); - - const { status } = await request(app) - .post(`/libraries/${library.id}/removeOffline`) - .set('Authorization', `Bearer ${admin.accessToken}`) - .send(); - expect(status).toBe(204); - await utils.waitForQueueFinish(admin.accessToken, 'library'); - await utils.waitForQueueFinish(admin.accessToken, 'backgroundTask'); - - const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); - - expect(assets.count).toBe(1); - - utils.removeImageFile(`${testAssetDir}/temp/offline/online.png`); - }); - - it('should remove offline files from trash', async () => { - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp/offline`], - }); - - utils.createImageFile(`${testAssetDir}/temp/offline/online.png`); - utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`); - - await scan(admin.accessToken, library.id); - await utils.waitForQueueFinish(admin.accessToken, 'library'); - - const { assets: initialAssets } = await utils.metadataSearch(admin.accessToken, { - libraryId: library.id, - }); - - expect(initialAssets.count).toBe(2); - utils.removeImageFile(`${testAssetDir}/temp/offline/offline.png`); - - await scan(admin.accessToken, library.id); - await utils.waitForQueueFinish(admin.accessToken, 'library'); - - const { assets: offlineAssets } = await utils.metadataSearch(admin.accessToken, { - libraryId: library.id, - isOffline: true, - }); - expect(offlineAssets.count).toBe(1); - - const { status } = await request(app) - .post(`/libraries/${library.id}/removeOffline`) - .set('Authorization', `Bearer ${admin.accessToken}`) - .send(); - expect(status).toBe(204); - await utils.waitForQueueFinish(admin.accessToken, 'library'); - await utils.waitForQueueFinish(admin.accessToken, 'backgroundTask'); - - const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); - - expect(assets.count).toBe(1); - expect(assets.items[0].isOffline).toBe(false); - expect(assets.items[0].originalPath).toEqual(`${testAssetDirInternal}/temp/offline/online.png`); - - utils.removeImageFile(`${testAssetDir}/temp/offline/online.png`); - }); - - it('should not remove online files', async () => { + it('should not trash an online asset', async () => { const library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId, importPaths: [`${testAssetDirInternal}/temp`], @@ -733,10 +534,11 @@ describe('/libraries', () => { expect(assetsBefore.count).toBeGreaterThan(1); const { status } = await request(app) - .post(`/libraries/${library.id}/removeOffline`) + .post(`/libraries/${library.id}/scan`) .set('Authorization', `Bearer ${admin.accessToken}`) .send(); expect(status).toBe(204); + await utils.waitForQueueFinish(admin.accessToken, 'library'); const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); @@ -828,7 +630,7 @@ describe('/libraries', () => { }); await scan(admin.accessToken, library.id); - await utils.waitForWebsocketEvent({ event: 'assetUpload', total: 2 }); + await utils.waitForQueueFinish(admin.accessToken, 'library'); const { status, body } = await request(app) .delete(`/libraries/${library.id}`) diff --git a/e2e/src/api/specs/search.e2e-spec.ts b/e2e/src/api/specs/search.e2e-spec.ts index beeaf1cc01ca5..0e5d882f80e50 100644 --- a/e2e/src/api/specs/search.e2e-spec.ts +++ b/e2e/src/api/specs/search.e2e-spec.ts @@ -181,7 +181,7 @@ describe('/search', () => { dto: { size: -1.5 }, expected: ['size must not be less than 1', 'size must be an integer number'], }, - ...['isArchived', 'isFavorite', 'isEncoded', 'isMotion', 'isOffline', 'isVisible'].map((value) => ({ + ...['isArchived', 'isFavorite', 'isEncoded', 'isOffline', 'isMotion', 'isVisible'].map((value) => ({ should: `should reject ${value} not a boolean`, dto: { [value]: 'immich' }, expected: [`${value} must be a boolean value`], diff --git a/e2e/src/api/specs/trash.e2e-spec.ts b/e2e/src/api/specs/trash.e2e-spec.ts index 17bb568c61cef..0bfc0ec19be21 100644 --- a/e2e/src/api/specs/trash.e2e-spec.ts +++ b/e2e/src/api/specs/trash.e2e-spec.ts @@ -1,10 +1,13 @@ -import { LoginResponseDto, getAssetInfo, getAssetStatistics } from '@immich/sdk'; +import { LoginResponseDto, getAssetInfo, getAssetStatistics, scanLibrary } from '@immich/sdk'; +import { existsSync } from 'node:fs'; import { Socket } from 'socket.io-client'; import { errorDto } from 'src/responses'; -import { app, asBearerAuth, utils } from 'src/utils'; +import { app, asBearerAuth, testAssetDir, testAssetDirInternal, utils } from 'src/utils'; import request from 'supertest'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +const scan = async (accessToken: string, id: string) => scanLibrary({ id }, { headers: asBearerAuth(accessToken) }); + describe('/trash', () => { let admin: LoginResponseDto; let ws: Socket; @@ -44,6 +47,8 @@ describe('/trash', () => { const after = await getAssetStatistics({ isTrashed: true }, { headers: asBearerAuth(admin.accessToken) }); expect(after.total).toBe(0); + + expect(existsSync(before.originalPath)).toBe(false); }); it('should empty the trash with archived assets', async () => { @@ -64,6 +69,46 @@ describe('/trash', () => { const after = await getAssetStatistics({ isTrashed: true }, { headers: asBearerAuth(admin.accessToken) }); expect(after.total).toBe(0); + + expect(existsSync(before.originalPath)).toBe(false); + }); + + it('should not delete offline-trashed assets from disk', async () => { + const library = await utils.createLibrary(admin.accessToken, { + ownerId: admin.userId, + importPaths: [`${testAssetDirInternal}/temp/offline`], + }); + + utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`); + + await scan(admin.accessToken, library.id); + await utils.waitForQueueFinish(admin.accessToken, 'library'); + + const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); + expect(assets.items.length).toBe(1); + const asset = assets.items[0]; + + utils.removeImageFile(`${testAssetDir}/temp/offline/offline.png`); + + await scan(admin.accessToken, library.id); + await utils.waitForQueueFinish(admin.accessToken, 'library'); + + const assetBefore = await utils.getAssetInfo(admin.accessToken, asset.id); + expect(assetBefore).toMatchObject({ isTrashed: true, isOffline: true }); + + utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`); + + const { status } = await request(app).post('/trash/empty').set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(200); + + await utils.waitForQueueFinish(admin.accessToken, 'backgroundTask'); + + const assetAfter = await utils.getAssetInfo(admin.accessToken, asset.id); + expect(assetAfter).toMatchObject({ isTrashed: true, isOffline: true }); + + expect(existsSync(`${testAssetDir}/temp/offline/offline.png`)).toBe(true); + + utils.removeImageFile(`${testAssetDir}/temp/offline/offline.png`); }); }); @@ -91,6 +136,37 @@ describe('/trash', () => { const after = await getAssetInfo({ id: assetId }, { headers: asBearerAuth(admin.accessToken) }); expect(after).toStrictEqual(expect.objectContaining({ id: assetId, isTrashed: false })); }); + + it('should not restore offline-trashed assets', async () => { + const library = await utils.createLibrary(admin.accessToken, { + ownerId: admin.userId, + importPaths: [`${testAssetDirInternal}/temp/offline`], + }); + + utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`); + + await scan(admin.accessToken, library.id); + await utils.waitForQueueFinish(admin.accessToken, 'library'); + + const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); + expect(assets.count).toBe(1); + const assetId = assets.items[0].id; + + utils.removeImageFile(`${testAssetDir}/temp/offline/offline.png`); + + await scan(admin.accessToken, library.id); + + await utils.waitForQueueFinish(admin.accessToken, 'library'); + + const before = await getAssetInfo({ id: assetId }, { headers: asBearerAuth(admin.accessToken) }); + expect(before).toStrictEqual(expect.objectContaining({ id: assetId, isOffline: true })); + + const { status } = await request(app).post('/trash/restore').set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(200); + + const after = await getAssetInfo({ id: assetId }, { headers: asBearerAuth(admin.accessToken) }); + expect(after).toStrictEqual(expect.objectContaining({ id: assetId, isOffline: true })); + }); }); describe('POST /trash/restore/assets', () => { @@ -118,5 +194,38 @@ describe('/trash', () => { const after = await utils.getAssetInfo(admin.accessToken, assetId); expect(after.isTrashed).toBe(false); }); + + it('should not restore an offline-trashed asset', async () => { + const library = await utils.createLibrary(admin.accessToken, { + ownerId: admin.userId, + importPaths: [`${testAssetDirInternal}/temp/offline`], + }); + + utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`); + + await scan(admin.accessToken, library.id); + await utils.waitForQueueFinish(admin.accessToken, 'library'); + + const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); + expect(assets.count).toBe(1); + const assetId = assets.items[0].id; + + utils.removeImageFile(`${testAssetDir}/temp/offline/offline.png`); + + await scan(admin.accessToken, library.id); + await utils.waitForQueueFinish(admin.accessToken, 'library'); + + const before = await utils.getAssetInfo(admin.accessToken, assetId); + expect(before.isTrashed).toBe(true); + + const { status } = await request(app) + .post('/trash/restore/assets') + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ ids: [assetId] }); + expect(status).toBe(200); + + const after = await utils.getAssetInfo(admin.accessToken, assetId); + expect(after.isTrashed).toBe(true); + }); }); }); diff --git a/e2e/src/utils.ts b/e2e/src/utils.ts index 3c9d4284ce49c..e21b3bfd14934 100644 --- a/e2e/src/utils.ts +++ b/e2e/src/utils.ts @@ -372,6 +372,12 @@ export const utils = { writeFileSync(path, makeRandomImage()); }, + createDirectory: (path: string) => { + if (!existsSync(dirname(path))) { + mkdirSync(dirname(path), { recursive: true }); + } + }, + removeImageFile: (path: string) => { if (!existsSync(path)) { return; @@ -380,6 +386,14 @@ export const utils = { rmSync(path); }, + removeDirectory: (path: string) => { + if (!existsSync(path)) { + return; + } + + rmSync(path); + }, + getAssetInfo: (accessToken: string, id: string) => getAssetInfo({ id }, { headers: asBearerAuth(accessToken) }), checkExistingAssets: (accessToken: string, checkExistingAssetsDto: CheckExistingAssetsDto) => diff --git a/mobile/lib/entities/asset.entity.g.dart b/mobile/lib/entities/asset.entity.g.dart index 23bf23604635d..8be636efb659b 100644 --- a/mobile/lib/entities/asset.entity.g.dart +++ b/mobile/lib/entities/asset.entity.g.dart @@ -57,69 +57,64 @@ const AssetSchema = CollectionSchema( name: r'isFavorite', type: IsarType.bool, ), - r'isOffline': PropertySchema( - id: 8, - name: r'isOffline', - type: IsarType.bool, - ), r'isTrashed': PropertySchema( - id: 9, + id: 8, name: r'isTrashed', type: IsarType.bool, ), r'livePhotoVideoId': PropertySchema( - id: 10, + id: 9, name: r'livePhotoVideoId', type: IsarType.string, ), r'localId': PropertySchema( - id: 11, + id: 10, name: r'localId', type: IsarType.string, ), r'ownerId': PropertySchema( - id: 12, + id: 11, name: r'ownerId', type: IsarType.long, ), r'remoteId': PropertySchema( - id: 13, + id: 12, name: r'remoteId', type: IsarType.string, ), r'stackCount': PropertySchema( - id: 14, + id: 13, name: r'stackCount', type: IsarType.long, ), r'stackId': PropertySchema( - id: 15, + id: 14, name: r'stackId', type: IsarType.string, ), r'stackPrimaryAssetId': PropertySchema( - id: 16, + id: 15, name: r'stackPrimaryAssetId', type: IsarType.string, ), r'thumbhash': PropertySchema( - id: 17, + id: 16, name: r'thumbhash', type: IsarType.string, ), r'type': PropertySchema( - id: 18, + id: 17, name: r'type', type: IsarType.byte, enumMap: _AssettypeEnumValueMap, ), r'updatedAt': PropertySchema( - id: 19, + id: 18, name: r'updatedAt', type: IsarType.dateTime, ), r'width': PropertySchema( - id: 20, + id: 19, name: r'width', type: IsarType.int, ) @@ -244,19 +239,18 @@ void _assetSerialize( writer.writeInt(offsets[5], object.height); writer.writeBool(offsets[6], object.isArchived); writer.writeBool(offsets[7], object.isFavorite); - writer.writeBool(offsets[8], object.isOffline); - writer.writeBool(offsets[9], object.isTrashed); - writer.writeString(offsets[10], object.livePhotoVideoId); - writer.writeString(offsets[11], object.localId); - writer.writeLong(offsets[12], object.ownerId); - writer.writeString(offsets[13], object.remoteId); - writer.writeLong(offsets[14], object.stackCount); - writer.writeString(offsets[15], object.stackId); - writer.writeString(offsets[16], object.stackPrimaryAssetId); - writer.writeString(offsets[17], object.thumbhash); - writer.writeByte(offsets[18], object.type.index); - writer.writeDateTime(offsets[19], object.updatedAt); - writer.writeInt(offsets[20], object.width); + writer.writeBool(offsets[8], object.isTrashed); + writer.writeString(offsets[9], object.livePhotoVideoId); + writer.writeString(offsets[10], object.localId); + writer.writeLong(offsets[11], object.ownerId); + writer.writeString(offsets[12], object.remoteId); + writer.writeLong(offsets[13], object.stackCount); + writer.writeString(offsets[14], object.stackId); + writer.writeString(offsets[15], object.stackPrimaryAssetId); + writer.writeString(offsets[16], object.thumbhash); + writer.writeByte(offsets[17], object.type.index); + writer.writeDateTime(offsets[18], object.updatedAt); + writer.writeInt(offsets[19], object.width); } Asset _assetDeserialize( @@ -275,20 +269,19 @@ Asset _assetDeserialize( id: id, isArchived: reader.readBoolOrNull(offsets[6]) ?? false, isFavorite: reader.readBoolOrNull(offsets[7]) ?? false, - isOffline: reader.readBoolOrNull(offsets[8]) ?? false, - isTrashed: reader.readBoolOrNull(offsets[9]) ?? false, - livePhotoVideoId: reader.readStringOrNull(offsets[10]), - localId: reader.readStringOrNull(offsets[11]), - ownerId: reader.readLong(offsets[12]), - remoteId: reader.readStringOrNull(offsets[13]), - stackCount: reader.readLongOrNull(offsets[14]) ?? 0, - stackId: reader.readStringOrNull(offsets[15]), - stackPrimaryAssetId: reader.readStringOrNull(offsets[16]), - thumbhash: reader.readStringOrNull(offsets[17]), - type: _AssettypeValueEnumMap[reader.readByteOrNull(offsets[18])] ?? + isTrashed: reader.readBoolOrNull(offsets[8]) ?? false, + livePhotoVideoId: reader.readStringOrNull(offsets[9]), + localId: reader.readStringOrNull(offsets[10]), + ownerId: reader.readLong(offsets[11]), + remoteId: reader.readStringOrNull(offsets[12]), + stackCount: reader.readLongOrNull(offsets[13]) ?? 0, + stackId: reader.readStringOrNull(offsets[14]), + stackPrimaryAssetId: reader.readStringOrNull(offsets[15]), + thumbhash: reader.readStringOrNull(offsets[16]), + type: _AssettypeValueEnumMap[reader.readByteOrNull(offsets[17])] ?? AssetType.other, - updatedAt: reader.readDateTime(offsets[19]), - width: reader.readIntOrNull(offsets[20]), + updatedAt: reader.readDateTime(offsets[18]), + width: reader.readIntOrNull(offsets[19]), ); return object; } @@ -319,29 +312,27 @@ P _assetDeserializeProp

    ( case 8: return (reader.readBoolOrNull(offset) ?? false) as P; case 9: - return (reader.readBoolOrNull(offset) ?? false) as P; + return (reader.readStringOrNull(offset)) as P; case 10: return (reader.readStringOrNull(offset)) as P; case 11: - return (reader.readStringOrNull(offset)) as P; - case 12: return (reader.readLong(offset)) as P; - case 13: + case 12: return (reader.readStringOrNull(offset)) as P; - case 14: + case 13: return (reader.readLongOrNull(offset) ?? 0) as P; + case 14: + return (reader.readStringOrNull(offset)) as P; case 15: return (reader.readStringOrNull(offset)) as P; case 16: return (reader.readStringOrNull(offset)) as P; case 17: - return (reader.readStringOrNull(offset)) as P; - case 18: return (_AssettypeValueEnumMap[reader.readByteOrNull(offset)] ?? AssetType.other) as P; - case 19: + case 18: return (reader.readDateTime(offset)) as P; - case 20: + case 19: return (reader.readIntOrNull(offset)) as P; default: throw IsarError('Unknown property with id $propertyId'); @@ -1362,16 +1353,6 @@ extension AssetQueryFilter on QueryBuilder { }); } - QueryBuilder isOfflineEqualTo( - bool value) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.equalTo( - property: r'isOffline', - value: value, - )); - }); - } - QueryBuilder isTrashedEqualTo( bool value) { return QueryBuilder.apply(this, (query) { @@ -2647,18 +2628,6 @@ extension AssetQuerySortBy on QueryBuilder { }); } - QueryBuilder sortByIsOffline() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'isOffline', Sort.asc); - }); - } - - QueryBuilder sortByIsOfflineDesc() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'isOffline', Sort.desc); - }); - } - QueryBuilder sortByIsTrashed() { return QueryBuilder.apply(this, (query) { return query.addSortBy(r'isTrashed', Sort.asc); @@ -2913,18 +2882,6 @@ extension AssetQuerySortThenBy on QueryBuilder { }); } - QueryBuilder thenByIsOffline() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'isOffline', Sort.asc); - }); - } - - QueryBuilder thenByIsOfflineDesc() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'isOffline', Sort.desc); - }); - } - QueryBuilder thenByIsTrashed() { return QueryBuilder.apply(this, (query) { return query.addSortBy(r'isTrashed', Sort.asc); @@ -3121,12 +3078,6 @@ extension AssetQueryWhereDistinct on QueryBuilder { }); } - QueryBuilder distinctByIsOffline() { - return QueryBuilder.apply(this, (query) { - return query.addDistinctBy(r'isOffline'); - }); - } - QueryBuilder distinctByIsTrashed() { return QueryBuilder.apply(this, (query) { return query.addDistinctBy(r'isTrashed'); @@ -3263,12 +3214,6 @@ extension AssetQueryProperty on QueryBuilder { }); } - QueryBuilder isOfflineProperty() { - return QueryBuilder.apply(this, (query) { - return query.addPropertyName(r'isOffline'); - }); - } - QueryBuilder isTrashedProperty() { return QueryBuilder.apply(this, (query) { return query.addPropertyName(r'isTrashed'); diff --git a/mobile/lib/extensions/collection_extensions.dart b/mobile/lib/extensions/collection_extensions.dart index 769bec472b210..f71b0aacd3a7d 100644 --- a/mobile/lib/extensions/collection_extensions.dart +++ b/mobile/lib/extensions/collection_extensions.dart @@ -72,13 +72,14 @@ extension AssetListExtension on Iterable { } /// Filters out offline assets and returns those that are still accessible by the Immich server + /// TODO: isOffline is removed from Immich, so this method is not useful anymore Iterable nonOfflineOnly({ void Function()? errorCallback, }) { - final bool onlyLive = every((e) => !e.isOffline); + final bool onlyLive = every((e) => false); if (!onlyLive) { if (errorCallback != null) errorCallback(); - return where((a) => !a.isOffline); + return where((a) => false); } return this; } diff --git a/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart b/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart index 7e6136c256192..8b5684d0fa241 100644 --- a/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart +++ b/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart @@ -172,29 +172,12 @@ class BottomGalleryBar extends ConsumerWidget { } shareAsset() { - if (asset.isOffline) { - ImmichToast.show( - durationInSecond: 1, - context: context, - msg: 'asset_action_share_err_offline'.tr(), - gravity: ToastGravity.BOTTOM, - ); - return; - } ref.read(imageViewerStateProvider.notifier).shareAsset(asset, context); } void handleEdit() async { final image = Image(image: ImmichImage.imageProvider(asset: asset)); - if (asset.isOffline) { - ImmichToast.show( - durationInSecond: 1, - context: context, - msg: 'asset_action_edit_err_offline'.tr(), - gravity: ToastGravity.BOTTOM, - ); - return; - } + Navigator.of(context).push( MaterialPageRoute( builder: (context) => EditImagePage( @@ -219,16 +202,6 @@ class BottomGalleryBar extends ConsumerWidget { if (asset.isLocal) { return; } - if (asset.isOffline) { - ImmichToast.show( - durationInSecond: 1, - context: context, - msg: 'asset_action_share_err_offline'.tr(), - gravity: ToastGravity.BOTTOM, - ); - return; - } - ref.read(imageViewerStateProvider.notifier).downloadAsset( asset, context, diff --git a/mobile/lib/widgets/asset_viewer/top_control_app_bar.dart b/mobile/lib/widgets/asset_viewer/top_control_app_bar.dart index 2157a1aebbf36..984b61f50cc05 100644 --- a/mobile/lib/widgets/asset_viewer/top_control_app_bar.dart +++ b/mobile/lib/widgets/asset_viewer/top_control_app_bar.dart @@ -183,8 +183,7 @@ class TopControlAppBar extends HookConsumerWidget { if (asset.isRemote && isOwner) buildFavoriteButton(a), if (asset.livePhotoVideoId != null) buildLivePhotoButton(), if (asset.isLocal && !asset.isRemote) buildUploadButton(), - if (asset.isRemote && !asset.isLocal && !asset.isOffline && isOwner) - buildDownloadButton(), + if (asset.isRemote && !asset.isLocal && isOwner) buildDownloadButton(), if (asset.isRemote && (isOwner || isPartner) && !asset.isTrashed) buildAddToAlbumButton(), if (asset.isTrashed) buildRestoreButton(), diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index b6b0897e8f5e0..e337c4831f5eb 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -133,7 +133,6 @@ Class | Method | HTTP request | Description *LibrariesApi* | [**getAllLibraries**](doc//LibrariesApi.md#getalllibraries) | **GET** /libraries | *LibrariesApi* | [**getLibrary**](doc//LibrariesApi.md#getlibrary) | **GET** /libraries/{id} | *LibrariesApi* | [**getLibraryStatistics**](doc//LibrariesApi.md#getlibrarystatistics) | **GET** /libraries/{id}/statistics | -*LibrariesApi* | [**removeOfflineFiles**](doc//LibrariesApi.md#removeofflinefiles) | **POST** /libraries/{id}/removeOffline | *LibrariesApi* | [**scanLibrary**](doc//LibrariesApi.md#scanlibrary) | **POST** /libraries/{id}/scan | *LibrariesApi* | [**updateLibrary**](doc//LibrariesApi.md#updatelibrary) | **PUT** /libraries/{id} | *LibrariesApi* | [**validate**](doc//LibrariesApi.md#validate) | **POST** /libraries/{id}/validate | @@ -385,7 +384,6 @@ Class | Method | HTTP request | Description - [ReactionLevel](doc//ReactionLevel.md) - [ReactionType](doc//ReactionType.md) - [ReverseGeocodingStateResponseDto](doc//ReverseGeocodingStateResponseDto.md) - - [ScanLibraryDto](doc//ScanLibraryDto.md) - [SearchAlbumResponseDto](doc//SearchAlbumResponseDto.md) - [SearchAssetResponseDto](doc//SearchAssetResponseDto.md) - [SearchExploreItem](doc//SearchExploreItem.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index d08b6fc52138b..22b48df2fbcb1 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -197,7 +197,6 @@ part 'model/ratings_update.dart'; part 'model/reaction_level.dart'; part 'model/reaction_type.dart'; part 'model/reverse_geocoding_state_response_dto.dart'; -part 'model/scan_library_dto.dart'; part 'model/search_album_response_dto.dart'; part 'model/search_asset_response_dto.dart'; part 'model/search_explore_item.dart'; diff --git a/mobile/openapi/lib/api/assets_api.dart b/mobile/openapi/lib/api/assets_api.dart index bd1d5b84847a1..fd899869803fd 100644 --- a/mobile/openapi/lib/api/assets_api.dart +++ b/mobile/openapi/lib/api/assets_api.dart @@ -833,14 +833,12 @@ class AssetsApi { /// /// * [bool] isFavorite: /// - /// * [bool] isOffline: - /// /// * [bool] isVisible: /// /// * [String] livePhotoVideoId: /// /// * [MultipartFile] sidecarData: - Future uploadAssetWithHttpInfo(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? xImmichChecksum, String? duration, bool? isArchived, bool? isFavorite, bool? isOffline, bool? isVisible, String? livePhotoVideoId, MultipartFile? sidecarData, }) async { + Future uploadAssetWithHttpInfo(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? xImmichChecksum, String? duration, bool? isArchived, bool? isFavorite, bool? isVisible, String? livePhotoVideoId, MultipartFile? sidecarData, }) async { // ignore: prefer_const_declarations final path = r'/assets'; @@ -896,10 +894,6 @@ class AssetsApi { hasFields = true; mp.fields[r'isFavorite'] = parameterToString(isFavorite); } - if (isOffline != null) { - hasFields = true; - mp.fields[r'isOffline'] = parameterToString(isOffline); - } if (isVisible != null) { hasFields = true; mp.fields[r'isVisible'] = parameterToString(isVisible); @@ -951,15 +945,13 @@ class AssetsApi { /// /// * [bool] isFavorite: /// - /// * [bool] isOffline: - /// /// * [bool] isVisible: /// /// * [String] livePhotoVideoId: /// /// * [MultipartFile] sidecarData: - Future uploadAsset(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? xImmichChecksum, String? duration, bool? isArchived, bool? isFavorite, bool? isOffline, bool? isVisible, String? livePhotoVideoId, MultipartFile? sidecarData, }) async { - final response = await uploadAssetWithHttpInfo(assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, key: key, xImmichChecksum: xImmichChecksum, duration: duration, isArchived: isArchived, isFavorite: isFavorite, isOffline: isOffline, isVisible: isVisible, livePhotoVideoId: livePhotoVideoId, sidecarData: sidecarData, ); + Future uploadAsset(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? xImmichChecksum, String? duration, bool? isArchived, bool? isFavorite, bool? isVisible, String? livePhotoVideoId, MultipartFile? sidecarData, }) async { + final response = await uploadAssetWithHttpInfo(assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, key: key, xImmichChecksum: xImmichChecksum, duration: duration, isArchived: isArchived, isFavorite: isFavorite, isVisible: isVisible, livePhotoVideoId: livePhotoVideoId, sidecarData: sidecarData, ); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } diff --git a/mobile/openapi/lib/api/libraries_api.dart b/mobile/openapi/lib/api/libraries_api.dart index 53ab0e19ce77b..36d98d9a88a78 100644 --- a/mobile/openapi/lib/api/libraries_api.dart +++ b/mobile/openapi/lib/api/libraries_api.dart @@ -243,13 +243,13 @@ class LibrariesApi { return null; } - /// Performs an HTTP 'POST /libraries/{id}/removeOffline' operation and returns the [Response]. + /// Performs an HTTP 'POST /libraries/{id}/scan' operation and returns the [Response]. /// Parameters: /// /// * [String] id (required): - Future removeOfflineFilesWithHttpInfo(String id,) async { + Future scanLibraryWithHttpInfo(String id,) async { // ignore: prefer_const_declarations - final path = r'/libraries/{id}/removeOffline' + final path = r'/libraries/{id}/scan' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -276,52 +276,8 @@ class LibrariesApi { /// Parameters: /// /// * [String] id (required): - Future removeOfflineFiles(String id,) async { - final response = await removeOfflineFilesWithHttpInfo(id,); - if (response.statusCode >= HttpStatus.badRequest) { - throw ApiException(response.statusCode, await _decodeBodyBytes(response)); - } - } - - /// Performs an HTTP 'POST /libraries/{id}/scan' operation and returns the [Response]. - /// Parameters: - /// - /// * [String] id (required): - /// - /// * [ScanLibraryDto] scanLibraryDto (required): - Future scanLibraryWithHttpInfo(String id, ScanLibraryDto scanLibraryDto,) async { - // ignore: prefer_const_declarations - final path = r'/libraries/{id}/scan' - .replaceAll('{id}', id); - - // ignore: prefer_final_locals - Object? postBody = scanLibraryDto; - - final queryParams = []; - final headerParams = {}; - final formParams = {}; - - const contentTypes = ['application/json']; - - - return apiClient.invokeAPI( - path, - 'POST', - queryParams, - postBody, - headerParams, - formParams, - contentTypes.isEmpty ? null : contentTypes.first, - ); - } - - /// Parameters: - /// - /// * [String] id (required): - /// - /// * [ScanLibraryDto] scanLibraryDto (required): - Future scanLibrary(String id, ScanLibraryDto scanLibraryDto,) async { - final response = await scanLibraryWithHttpInfo(id, scanLibraryDto,); + Future scanLibrary(String id,) async { + final response = await scanLibraryWithHttpInfo(id,); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index c62d1c5b2e2b2..3db3297acb091 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -448,8 +448,6 @@ class ApiClient { return ReactionTypeTypeTransformer().decode(value); case 'ReverseGeocodingStateResponseDto': return ReverseGeocodingStateResponseDto.fromJson(value); - case 'ScanLibraryDto': - return ScanLibraryDto.fromJson(value); case 'SearchAlbumResponseDto': return SearchAlbumResponseDto.fromJson(value); case 'SearchAssetResponseDto': diff --git a/mobile/openapi/lib/model/scan_library_dto.dart b/mobile/openapi/lib/model/scan_library_dto.dart deleted file mode 100644 index 8ff978be05321..0000000000000 --- a/mobile/openapi/lib/model/scan_library_dto.dart +++ /dev/null @@ -1,125 +0,0 @@ -// -// AUTO-GENERATED FILE, DO NOT MODIFY! -// -// @dart=2.18 - -// ignore_for_file: unused_element, unused_import -// ignore_for_file: always_put_required_named_parameters_first -// ignore_for_file: constant_identifier_names -// ignore_for_file: lines_longer_than_80_chars - -part of openapi.api; - -class ScanLibraryDto { - /// Returns a new [ScanLibraryDto] instance. - ScanLibraryDto({ - this.refreshAllFiles, - this.refreshModifiedFiles, - }); - - /// - /// Please note: This property should have been non-nullable! Since the specification file - /// does not include a default value (using the "default:" property), however, the generated - /// source code must fall back to having a nullable type. - /// Consider adding a "default:" property in the specification file to hide this note. - /// - bool? refreshAllFiles; - - /// - /// Please note: This property should have been non-nullable! Since the specification file - /// does not include a default value (using the "default:" property), however, the generated - /// source code must fall back to having a nullable type. - /// Consider adding a "default:" property in the specification file to hide this note. - /// - bool? refreshModifiedFiles; - - @override - bool operator ==(Object other) => identical(this, other) || other is ScanLibraryDto && - other.refreshAllFiles == refreshAllFiles && - other.refreshModifiedFiles == refreshModifiedFiles; - - @override - int get hashCode => - // ignore: unnecessary_parenthesis - (refreshAllFiles == null ? 0 : refreshAllFiles!.hashCode) + - (refreshModifiedFiles == null ? 0 : refreshModifiedFiles!.hashCode); - - @override - String toString() => 'ScanLibraryDto[refreshAllFiles=$refreshAllFiles, refreshModifiedFiles=$refreshModifiedFiles]'; - - Map toJson() { - final json = {}; - if (this.refreshAllFiles != null) { - json[r'refreshAllFiles'] = this.refreshAllFiles; - } else { - // json[r'refreshAllFiles'] = null; - } - if (this.refreshModifiedFiles != null) { - json[r'refreshModifiedFiles'] = this.refreshModifiedFiles; - } else { - // json[r'refreshModifiedFiles'] = null; - } - return json; - } - - /// Returns a new [ScanLibraryDto] instance and imports its values from - /// [value] if it's a [Map], null otherwise. - // ignore: prefer_constructors_over_static_methods - static ScanLibraryDto? fromJson(dynamic value) { - upgradeDto(value, "ScanLibraryDto"); - if (value is Map) { - final json = value.cast(); - - return ScanLibraryDto( - refreshAllFiles: mapValueOfType(json, r'refreshAllFiles'), - refreshModifiedFiles: mapValueOfType(json, r'refreshModifiedFiles'), - ); - } - return null; - } - - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; - if (json is List && json.isNotEmpty) { - for (final row in json) { - final value = ScanLibraryDto.fromJson(row); - if (value != null) { - result.add(value); - } - } - } - return result.toList(growable: growable); - } - - static Map mapFromJson(dynamic json) { - final map = {}; - if (json is Map && json.isNotEmpty) { - json = json.cast(); // ignore: parameter_assignments - for (final entry in json.entries) { - final value = ScanLibraryDto.fromJson(entry.value); - if (value != null) { - map[entry.key] = value; - } - } - } - return map; - } - - // maps a json object with a list of ScanLibraryDto-objects as value to a dart map - static Map> mapListFromJson(dynamic json, {bool growable = false,}) { - final map = >{}; - if (json is Map && json.isNotEmpty) { - // ignore: parameter_assignments - json = json.cast(); - for (final entry in json.entries) { - map[entry.key] = ScanLibraryDto.listFromJson(entry.value, growable: growable,); - } - } - return map; - } - - /// The list of required keys that must be present in a JSON. - static const requiredKeys = { - }; -} - diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 1a070f126b9b9..d0864675a172b 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -2853,41 +2853,6 @@ ] } }, - "/libraries/{id}/removeOffline": { - "post": { - "operationId": "removeOfflineFiles", - "parameters": [ - { - "name": "id", - "required": true, - "in": "path", - "schema": { - "format": "uuid", - "type": "string" - } - } - ], - "responses": { - "204": { - "description": "" - } - }, - "security": [ - { - "bearer": [] - }, - { - "cookie": [] - }, - { - "api_key": [] - } - ], - "tags": [ - "Libraries" - ] - } - }, "/libraries/{id}/scan": { "post": { "operationId": "scanLibrary", @@ -2902,16 +2867,6 @@ } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ScanLibraryDto" - } - } - }, - "required": true - }, "responses": { "204": { "description": "" @@ -8287,9 +8242,6 @@ "isFavorite": { "type": "boolean" }, - "isOffline": { - "type": "boolean" - }, "isVisible": { "type": "boolean" }, @@ -10628,17 +10580,6 @@ ], "type": "object" }, - "ScanLibraryDto": { - "properties": { - "refreshAllFiles": { - "type": "boolean" - }, - "refreshModifiedFiles": { - "type": "boolean" - } - }, - "type": "object" - }, "SearchAlbumResponseDto": { "properties": { "count": { diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index f2f946f2626e2..85710af49c294 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -366,7 +366,6 @@ export type AssetMediaCreateDto = { fileModifiedAt: string; isArchived?: boolean; isFavorite?: boolean; - isOffline?: boolean; isVisible?: boolean; livePhotoVideoId?: string; sidecarData?: Blob; @@ -579,10 +578,6 @@ export type UpdateLibraryDto = { importPaths?: string[]; name?: string; }; -export type ScanLibraryDto = { - refreshAllFiles?: boolean; - refreshModifiedFiles?: boolean; -}; export type LibraryStatsResponseDto = { photos: number; total: number; @@ -2066,24 +2061,14 @@ export function updateLibrary({ id, updateLibraryDto }: { body: updateLibraryDto }))); } -export function removeOfflineFiles({ id }: { +export function scanLibrary({ id }: { id: string; }, opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchText(`/libraries/${encodeURIComponent(id)}/removeOffline`, { + return oazapfts.ok(oazapfts.fetchText(`/libraries/${encodeURIComponent(id)}/scan`, { ...opts, method: "POST" })); } -export function scanLibrary({ id, scanLibraryDto }: { - id: string; - scanLibraryDto: ScanLibraryDto; -}, opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchText(`/libraries/${encodeURIComponent(id)}/scan`, oazapfts.json({ - ...opts, - method: "POST", - body: scanLibraryDto - }))); -} export function getLibraryStatistics({ id }: { id: string; }, opts?: Oazapfts.RequestOpts) { diff --git a/server/src/controllers/library.controller.ts b/server/src/controllers/library.controller.ts index a45617fc2a503..b8959ca28875c 100644 --- a/server/src/controllers/library.controller.ts +++ b/server/src/controllers/library.controller.ts @@ -4,7 +4,6 @@ import { CreateLibraryDto, LibraryResponseDto, LibraryStatsResponseDto, - ScanLibraryDto, UpdateLibraryDto, ValidateLibraryDto, ValidateLibraryResponseDto, @@ -43,6 +42,13 @@ export class LibraryController { return this.service.update(id, dto); } + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + @Authenticated({ permission: Permission.LIBRARY_DELETE, admin: true }) + deleteLibrary(@Param() { id }: UUIDParamDto): Promise { + return this.service.delete(id); + } + @Post(':id/validate') @HttpCode(200) @Authenticated({ admin: true }) @@ -51,13 +57,6 @@ export class LibraryController { return this.service.validate(id, dto); } - @Delete(':id') - @HttpCode(HttpStatus.NO_CONTENT) - @Authenticated({ permission: Permission.LIBRARY_DELETE, admin: true }) - deleteLibrary(@Param() { id }: UUIDParamDto): Promise { - return this.service.delete(id); - } - @Get(':id/statistics') @Authenticated({ permission: Permission.LIBRARY_STATISTICS, admin: true }) getLibraryStatistics(@Param() { id }: UUIDParamDto): Promise { @@ -66,15 +65,8 @@ export class LibraryController { @Post(':id/scan') @HttpCode(HttpStatus.NO_CONTENT) - @Authenticated({ admin: true }) - scanLibrary(@Param() { id }: UUIDParamDto, @Body() dto: ScanLibraryDto) { - return this.service.queueScan(id, dto); - } - - @Post(':id/removeOffline') - @HttpCode(HttpStatus.NO_CONTENT) - @Authenticated({ admin: true }) - removeOfflineFiles(@Param() { id }: UUIDParamDto) { - return this.service.queueRemoveOffline(id); + @Authenticated({ permission: Permission.LIBRARY_UPDATE, admin: true }) + scanLibrary(@Param() { id }: UUIDParamDto) { + return this.service.queueScan(id); } } diff --git a/server/src/dtos/asset-media.dto.ts b/server/src/dtos/asset-media.dto.ts index e9e346c4cb593..c62857da65042 100644 --- a/server/src/dtos/asset-media.dto.ts +++ b/server/src/dtos/asset-media.dto.ts @@ -56,9 +56,6 @@ export class AssetMediaCreateDto extends AssetMediaBase { @ValidateBoolean({ optional: true }) isVisible?: boolean; - @ValidateBoolean({ optional: true }) - isOffline?: boolean; - @ValidateUUID({ optional: true }) livePhotoVideoId?: string; diff --git a/server/src/dtos/library.dto.ts b/server/src/dtos/library.dto.ts index c2c3ac9d27546..7fb363dd9a5e1 100644 --- a/server/src/dtos/library.dto.ts +++ b/server/src/dtos/library.dto.ts @@ -1,7 +1,7 @@ import { ApiProperty } from '@nestjs/swagger'; import { ArrayMaxSize, ArrayUnique, IsNotEmpty, IsString } from 'class-validator'; import { LibraryEntity } from 'src/entities/library.entity'; -import { Optional, ValidateBoolean, ValidateUUID } from 'src/validation'; +import { Optional, ValidateUUID } from 'src/validation'; export class CreateLibraryDto { @ValidateUUID() @@ -89,14 +89,6 @@ export class LibrarySearchDto { userId?: string; } -export class ScanLibraryDto { - @ValidateBoolean({ optional: true }) - refreshModifiedFiles?: boolean; - - @ValidateBoolean({ optional: true }) - refreshAllFiles?: boolean; -} - export class LibraryResponseDto { id!: string; ownerId!: string; diff --git a/server/src/interfaces/asset.interface.ts b/server/src/interfaces/asset.interface.ts index 387fa27185fc6..c6808e3aa87a6 100644 --- a/server/src/interfaces/asset.interface.ts +++ b/server/src/interfaces/asset.interface.ts @@ -36,8 +36,6 @@ export enum WithoutProperty { export enum WithProperty { SIDECAR = 'sidecar', - IS_ONLINE = 'isOnline', - IS_OFFLINE = 'isOffline', } export enum TimeBucketSize { @@ -176,7 +174,6 @@ export interface IAssetRepository { ): Paginated; getRandom(userIds: string[], count: number): Promise; getLastUpdatedAssetForAlbumId(albumId: string): Promise; - getExternalLibraryAssetPaths(pagination: PaginationOptions, libraryId: string): Paginated; getByLibraryIdAndOriginalPath(libraryId: string, originalPath: string): Promise; deleteAll(ownerId: string): Promise; getAll(pagination: PaginationOptions, options?: AssetSearchOptions): Paginated; diff --git a/server/src/interfaces/job.interface.ts b/server/src/interfaces/job.interface.ts index 3e7b0b9d08140..8b6e2c289bd28 100644 --- a/server/src/interfaces/job.interface.ts +++ b/server/src/interfaces/job.interface.ts @@ -76,12 +76,12 @@ export enum JobName { FACIAL_RECOGNITION = 'facial-recognition', // library management - LIBRARY_SCAN = 'library-refresh', - LIBRARY_SCAN_ASSET = 'library-refresh-asset', - LIBRARY_REMOVE_OFFLINE = 'library-remove-offline', - LIBRARY_CHECK_OFFLINE = 'library-check-offline', + LIBRARY_QUEUE_SYNC_FILES = 'library-queue-sync-files', + LIBRARY_QUEUE_SYNC_ASSETS = 'library-queue-sync-assets', + LIBRARY_SYNC_FILE = 'library-sync-file', + LIBRARY_SYNC_ASSET = 'library-sync-asset', LIBRARY_DELETE = 'library-delete', - LIBRARY_QUEUE_SCAN_ALL = 'library-queue-all-refresh', + LIBRARY_QUEUE_SYNC_ALL = 'library-queue-sync-all', LIBRARY_QUEUE_CLEANUP = 'library-queue-cleanup', // cleanup @@ -137,16 +137,11 @@ export interface ILibraryFileJob extends IEntityJob { assetPath: string; } -export interface ILibraryOfflineJob extends IEntityJob { +export interface ILibraryAssetJob extends IEntityJob { importPaths: string[]; exclusionPatterns: string[]; } -export interface ILibraryRefreshJob extends IEntityJob { - refreshModifiedFiles: boolean; - refreshAllFiles: boolean; -} - export interface IBulkEntityJob extends IBaseJob { ids: string[]; } @@ -277,12 +272,12 @@ export type JobItem = | { name: JobName.ASSET_DELETION_CHECK; data?: IBaseJob } // Library Management - | { name: JobName.LIBRARY_SCAN_ASSET; data: ILibraryFileJob } - | { name: JobName.LIBRARY_SCAN; data: ILibraryRefreshJob } - | { name: JobName.LIBRARY_REMOVE_OFFLINE; data: IEntityJob } + | { name: JobName.LIBRARY_SYNC_FILE; data: ILibraryFileJob } + | { name: JobName.LIBRARY_QUEUE_SYNC_FILES; data: IEntityJob } + | { name: JobName.LIBRARY_QUEUE_SYNC_ASSETS; data: IEntityJob } + | { name: JobName.LIBRARY_SYNC_ASSET; data: IEntityJob } | { name: JobName.LIBRARY_DELETE; data: IEntityJob } - | { name: JobName.LIBRARY_QUEUE_SCAN_ALL; data: IBaseJob } - | { name: JobName.LIBRARY_CHECK_OFFLINE; data: IEntityJob } + | { name: JobName.LIBRARY_QUEUE_SYNC_ALL; data?: IBaseJob } | { name: JobName.LIBRARY_QUEUE_CLEANUP; data: IBaseJob } // Notification diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index 5b5730717910f..69309325848ed 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -268,35 +268,6 @@ DELETE FROM "assets" WHERE "ownerId" = $1 --- AssetRepository.getExternalLibraryAssetPaths -SELECT DISTINCT - "distinctAlias"."AssetEntity_id" AS "ids_AssetEntity_id" -FROM - ( - SELECT - "AssetEntity"."id" AS "AssetEntity_id", - "AssetEntity"."originalPath" AS "AssetEntity_originalPath", - "AssetEntity"."isOffline" AS "AssetEntity_isOffline" - FROM - "assets" "AssetEntity" - LEFT JOIN "libraries" "AssetEntity__AssetEntity_library" ON "AssetEntity__AssetEntity_library"."id" = "AssetEntity"."libraryId" - AND ( - "AssetEntity__AssetEntity_library"."deletedAt" IS NULL - ) - WHERE - ( - ( - ((("AssetEntity__AssetEntity_library"."id" = $1))) - AND ("AssetEntity"."isExternal" = $2) - ) - ) - AND ("AssetEntity"."deletedAt" IS NULL) - ) "distinctAlias" -ORDER BY - "AssetEntity_id" ASC -LIMIT - 2 - -- AssetRepository.getByLibraryIdAndOriginalPath SELECT DISTINCT "distinctAlias"."AssetEntity_id" AS "ids_AssetEntity_id" @@ -366,18 +337,6 @@ WHERE AND "originalPath" = path ); --- AssetRepository.updateOfflineLibraryAssets -UPDATE "assets" -SET - "isOffline" = $1, - "updatedAt" = CURRENT_TIMESTAMP -WHERE - ( - "libraryId" = $2 - AND NOT ("originalPath" IN ($3)) - AND "isOffline" = $4 - ) - -- AssetRepository.getAllByDeviceId SELECT "AssetEntity"."deviceAssetId" AS "AssetEntity_deviceAssetId", diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index 4ec5523df11b0..43e765d00b678 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -13,7 +13,6 @@ import { AssetDeltaSyncOptions, AssetExploreFieldOptions, AssetFullSyncOptions, - AssetPathEntity, AssetStats, AssetStatsOptions, AssetUpdateAllOptions, @@ -177,14 +176,6 @@ export class AssetRepository implements IAssetRepository { return this.getAll(pagination, { ...options, userIds: [userId] }); } - @GenerateSql({ params: [{ take: 1, skip: 0 }, DummyValue.UUID] }) - getExternalLibraryAssetPaths(pagination: PaginationOptions, libraryId: string): Paginated { - return paginate(this.repository, pagination, { - select: { id: true, originalPath: true, isOffline: true }, - where: { library: { id: libraryId }, isExternal: true }, - }); - } - @GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] }) getByLibraryIdAndOriginalPath(libraryId: string, originalPath: string): Promise { return this.repository.findOne({ @@ -198,24 +189,16 @@ export class AssetRepository implements IAssetRepository { async getPathsNotInLibrary(libraryId: string, originalPaths: string[]): Promise { const result = await this.repository.query( ` - WITH paths AS (SELECT unnest($2::text[]) AS path) - SELECT path FROM paths - WHERE NOT EXISTS (SELECT 1 FROM assets WHERE "libraryId" = $1 AND "originalPath" = path); - `, + WITH paths AS (SELECT unnest($2::text[]) AS path) + SELECT path + FROM paths + WHERE NOT EXISTS (SELECT 1 FROM assets WHERE "libraryId" = $1 AND "originalPath" = path); + `, [libraryId, originalPaths], ); return result.map((row: { path: string }) => row.path); } - @GenerateSql({ params: [DummyValue.UUID, [DummyValue.STRING]] }) - @ChunkedArray({ paramIndex: 1 }) - async updateOfflineLibraryAssets(libraryId: string, originalPaths: string[]): Promise { - await this.repository.update( - { library: { id: libraryId }, originalPath: Not(In(originalPaths)), isOffline: false }, - { isOffline: true }, - ); - } - getAll(pagination: PaginationOptions, options: AssetSearchOptions = {}): Paginated { let builder = this.repository.createQueryBuilder('asset').leftJoinAndSelect('asset.files', 'files'); builder = searchAssetBuilder(builder, options); @@ -373,12 +356,10 @@ export class AssetRepository implements IAssetRepository { } @GenerateSql( - ...Object.values(WithProperty) - .filter((property) => property !== WithProperty.IS_OFFLINE && property !== WithProperty.IS_ONLINE) - .map((property) => ({ - name: property, - params: [DummyValue.PAGINATION, property], - })), + ...Object.values(WithProperty).map((property) => ({ + name: property, + params: [DummyValue.PAGINATION, property], + })), ) getWithout(pagination: PaginationOptions, property: WithoutProperty): Paginated { let relations: FindOptionsRelations = {}; @@ -531,26 +512,16 @@ export class AssetRepository implements IAssetRepository { where = [{ sidecarPath: Not(IsNull()), isVisible: true }]; break; } - case WithProperty.IS_OFFLINE: { - if (!libraryId) { - throw new Error('Library id is required when finding offline assets'); - } - where = [{ isOffline: true, libraryId }]; - break; - } - case WithProperty.IS_ONLINE: { - if (!libraryId) { - throw new Error('Library id is required when finding online assets'); - } - where = [{ isOffline: false, libraryId }]; - break; - } default: { throw new Error(`Invalid getWith property: ${property}`); } } + if (libraryId) { + where = [{ ...where, libraryId }]; + } + return paginate(this.repository, pagination, { where, withDeleted, @@ -750,7 +721,10 @@ export class AssetRepository implements IAssetRepository { builder.andWhere(`asset.deletedAt ${options.isTrashed ? 'IS NOT NULL' : 'IS NULL'}`).withDeleted(); if (options.isTrashed) { - builder.andWhere('asset.status = :status', { status: AssetStatus.TRASHED }); + // TODO: Temporarily inverted to support showing offline assets in the trash queries. + // Once offline assets are handled in a separate screen, this should be set back to status = TRASHED + // and the offline screens should use a separate isOffline = true parameter in the timeline query. + builder.andWhere('asset.status != :status', { status: AssetStatus.DELETED }); } } diff --git a/server/src/repositories/job.repository.ts b/server/src/repositories/job.repository.ts index 2b4c1f6dc1b3b..cd4c7135be80f 100644 --- a/server/src/repositories/job.repository.ts +++ b/server/src/repositories/job.repository.ts @@ -79,12 +79,12 @@ export const JOBS_TO_QUEUE: Record = { [JobName.SIDECAR_WRITE]: QueueName.SIDECAR, // Library management - [JobName.LIBRARY_SCAN_ASSET]: QueueName.LIBRARY, - [JobName.LIBRARY_SCAN]: QueueName.LIBRARY, + [JobName.LIBRARY_SYNC_FILE]: QueueName.LIBRARY, + [JobName.LIBRARY_QUEUE_SYNC_FILES]: QueueName.LIBRARY, + [JobName.LIBRARY_QUEUE_SYNC_ASSETS]: QueueName.LIBRARY, [JobName.LIBRARY_DELETE]: QueueName.LIBRARY, - [JobName.LIBRARY_CHECK_OFFLINE]: QueueName.LIBRARY, - [JobName.LIBRARY_REMOVE_OFFLINE]: QueueName.LIBRARY, - [JobName.LIBRARY_QUEUE_SCAN_ALL]: QueueName.LIBRARY, + [JobName.LIBRARY_SYNC_ASSET]: QueueName.LIBRARY, + [JobName.LIBRARY_QUEUE_SYNC_ALL]: QueueName.LIBRARY, [JobName.LIBRARY_QUEUE_CLEANUP]: QueueName.LIBRARY, // Notification diff --git a/server/src/repositories/trash.repository.ts b/server/src/repositories/trash.repository.ts index 9e0f6728f19ed..d24f4f709afac 100644 --- a/server/src/repositories/trash.repository.ts +++ b/server/src/repositories/trash.repository.ts @@ -3,7 +3,7 @@ import { AssetEntity } from 'src/entities/asset.entity'; import { AssetStatus } from 'src/enum'; import { ITrashRepository } from 'src/interfaces/trash.interface'; import { Paginated, paginatedBuilder, PaginationOptions } from 'src/utils/pagination'; -import { In, IsNull, Not, Repository } from 'typeorm'; +import { In, Repository } from 'typeorm'; export class TrashRepository implements ITrashRepository { constructor(@InjectRepository(AssetEntity) private assetRepository: Repository) {} @@ -26,7 +26,7 @@ export class TrashRepository implements ITrashRepository { async restore(userId: string): Promise { const result = await this.assetRepository.update( - { ownerId: userId, deletedAt: Not(IsNull()) }, + { ownerId: userId, status: AssetStatus.TRASHED }, { status: AssetStatus.ACTIVE, deletedAt: null }, ); @@ -35,7 +35,7 @@ export class TrashRepository implements ITrashRepository { async empty(userId: string): Promise { const result = await this.assetRepository.update( - { ownerId: userId, deletedAt: Not(IsNull()), status: AssetStatus.TRASHED }, + { ownerId: userId, status: AssetStatus.TRASHED }, { status: AssetStatus.DELETED }, ); @@ -43,7 +43,10 @@ export class TrashRepository implements ITrashRepository { } async restoreAll(ids: string[]): Promise { - const result = await this.assetRepository.update({ id: In(ids) }, { status: AssetStatus.ACTIVE, deletedAt: null }); + const result = await this.assetRepository.update( + { id: In(ids), status: AssetStatus.TRASHED }, + { status: AssetStatus.ACTIVE, deletedAt: null }, + ); return result.affected ?? 0; } } diff --git a/server/src/services/asset-media.service.ts b/server/src/services/asset-media.service.ts index 5321c335a7e5d..d3dce323f0bb7 100644 --- a/server/src/services/asset-media.service.ts +++ b/server/src/services/asset-media.service.ts @@ -427,7 +427,6 @@ export class AssetMediaService { livePhotoVideoId: dto.livePhotoVideoId, originalFileName: file.originalName, sidecarPath: sidecarFile?.originalPath, - isOffline: dto.isOffline ?? false, }); if (sidecarFile) { diff --git a/server/src/services/job.service.ts b/server/src/services/job.service.ts index 5ed9f3202457b..f978f334108d1 100644 --- a/server/src/services/job.service.ts +++ b/server/src/services/job.service.ts @@ -164,7 +164,7 @@ export class JobService { } case QueueName.LIBRARY: { - return this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_SCAN_ALL, data: { force } }); + return this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_SYNC_ALL, data: { force } }); } default: { diff --git a/server/src/services/library.service.spec.ts b/server/src/services/library.service.spec.ts index 36bdfd05dc1db..8b14c76cbcc1d 100644 --- a/server/src/services/library.service.spec.ts +++ b/server/src/services/library.service.spec.ts @@ -10,9 +10,8 @@ import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { IDatabaseRepository } from 'src/interfaces/database.interface'; import { IJobRepository, + ILibraryAssetJob, ILibraryFileJob, - ILibraryOfflineJob, - ILibraryRefreshJob, JobName, JOBS_LIBRARY_PAGINATION_SIZE, JobStatus, @@ -37,6 +36,10 @@ import { makeMockWatcher, newStorageRepositoryMock } from 'test/repositories/sto import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; import { Mocked, vitest } from 'vitest'; +async function* mockWalk() { + yield await Promise.resolve(['/data/user1/photo.jpg']); +} + describe(LibraryService.name, () => { let sut: LibraryService; @@ -91,7 +94,7 @@ describe(LibraryService.name, () => { enabled: true, cronExpression: '0 1 * * *', }, - watch: { enabled: false }, + watch: { enabled: true }, }, } as SystemConfig); @@ -163,102 +166,29 @@ describe(LibraryService.name, () => { describe('handleQueueAssetRefresh', () => { it('should queue refresh of a new asset', async () => { - const mockLibraryJob: ILibraryRefreshJob = { - id: libraryStub.externalLibrary1.id, - refreshModifiedFiles: false, - refreshAllFiles: false, - }; - assetMock.getWith.mockResolvedValue({ items: [], hasNextPage: false }); libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); - // eslint-disable-next-line @typescript-eslint/require-await - storageMock.walk.mockImplementation(async function* generator() { - yield ['/data/user1/photo.jpg']; - }); - assetMock.getExternalLibraryAssetPaths.mockResolvedValue({ items: [], hasNextPage: false }); + storageMock.walk.mockImplementation(mockWalk); - await sut.handleQueueAssetRefresh(mockLibraryJob); + await sut.handleQueueSyncFiles({ id: libraryStub.externalLibrary1.id }); expect(jobMock.queueAll).toHaveBeenCalledWith([ { - name: JobName.LIBRARY_SCAN_ASSET, + name: JobName.LIBRARY_SYNC_FILE, data: { id: libraryStub.externalLibrary1.id, ownerId: libraryStub.externalLibrary1.owner.id, assetPath: '/data/user1/photo.jpg', - force: false, - }, - }, - ]); - }); - - it('should queue offline check of existing online assets', async () => { - const mockLibraryJob: ILibraryRefreshJob = { - id: libraryStub.externalLibrary1.id, - refreshModifiedFiles: false, - refreshAllFiles: false, - }; - - assetMock.getWith.mockResolvedValue({ items: [], hasNextPage: false }); - libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); - storageMock.walk.mockImplementation(async function* generator() {}); - assetMock.getWith.mockResolvedValue({ items: [assetStub.external], hasNextPage: false }); - - await sut.handleQueueAssetRefresh(mockLibraryJob); - - expect(jobMock.queueAll).toHaveBeenCalledWith([ - { - name: JobName.LIBRARY_CHECK_OFFLINE, - data: { - id: assetStub.external.id, - importPaths: libraryStub.externalLibrary1.importPaths, - exclusionPatterns: [], }, }, ]); }); it("should fail when library can't be found", async () => { - const mockLibraryJob: ILibraryRefreshJob = { - id: libraryStub.externalLibrary1.id, - refreshModifiedFiles: false, - refreshAllFiles: false, - }; - libraryMock.get.mockResolvedValue(null); - await expect(sut.handleQueueAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SKIPPED); - }); - - it('should force queue new assets', async () => { - const mockLibraryJob: ILibraryRefreshJob = { - id: libraryStub.externalLibrary1.id, - refreshModifiedFiles: false, - refreshAllFiles: true, - }; - - assetMock.getWith.mockResolvedValue({ items: [], hasNextPage: false }); - libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); - // eslint-disable-next-line @typescript-eslint/require-await - storageMock.walk.mockImplementation(async function* generator() { - yield ['/data/user1/photo.jpg']; - }); - assetMock.getExternalLibraryAssetPaths.mockResolvedValue({ items: [], hasNextPage: false }); - - await sut.handleQueueAssetRefresh(mockLibraryJob); - - expect(jobMock.queueAll).toHaveBeenCalledWith([ - { - name: JobName.LIBRARY_SCAN_ASSET, - data: { - id: libraryStub.externalLibrary1.id, - ownerId: libraryStub.externalLibrary1.owner.id, - assetPath: '/data/user1/photo.jpg', - force: true, - }, - }, - ]); + await expect(sut.handleQueueSyncFiles({ id: libraryStub.externalLibrary1.id })).resolves.toBe(JobStatus.SKIPPED); }); it('should ignore import paths that do not exist', async () => { @@ -276,16 +206,9 @@ describe(LibraryService.name, () => { assetMock.getWith.mockResolvedValue({ items: [], hasNextPage: false }); - const mockLibraryJob: ILibraryRefreshJob = { - id: libraryStub.externalLibraryWithImportPaths1.id, - refreshModifiedFiles: false, - refreshAllFiles: false, - }; - libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); - assetMock.getExternalLibraryAssetPaths.mockResolvedValue({ items: [], hasNextPage: false }); - await sut.handleQueueAssetRefresh(mockLibraryJob); + await sut.handleQueueSyncFiles({ id: libraryStub.externalLibraryWithImportPaths1.id }); expect(storageMock.walk).toHaveBeenCalledWith({ pathsToCrawl: [libraryStub.externalLibraryWithImportPaths1.importPaths[1]], @@ -296,9 +219,36 @@ describe(LibraryService.name, () => { }); }); - describe('handleOfflineCheck', () => { + describe('handleQueueRemoveDeleted', () => { + it('should queue online check of existing assets', async () => { + libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); + storageMock.walk.mockImplementation(async function* generator() {}); + assetMock.getAll.mockResolvedValue({ items: [assetStub.external], hasNextPage: false }); + + await sut.handleQueueSyncAssets({ id: libraryStub.externalLibrary1.id }); + + expect(jobMock.queueAll).toHaveBeenCalledWith([ + { + name: JobName.LIBRARY_SYNC_ASSET, + data: { + id: assetStub.external.id, + importPaths: libraryStub.externalLibrary1.importPaths, + exclusionPatterns: [], + }, + }, + ]); + }); + + it("should fail when library can't be found", async () => { + libraryMock.get.mockResolvedValue(null); + + await expect(sut.handleQueueSyncAssets({ id: libraryStub.externalLibrary1.id })).resolves.toBe(JobStatus.SKIPPED); + }); + }); + + describe('handleSyncAsset', () => { it('should skip missing assets', async () => { - const mockAssetJob: ILibraryOfflineJob = { + const mockAssetJob: ILibraryAssetJob = { id: assetStub.external.id, importPaths: ['/'], exclusionPatterns: [], @@ -306,41 +256,31 @@ describe(LibraryService.name, () => { assetMock.getById.mockResolvedValue(null); - await expect(sut.handleOfflineCheck(mockAssetJob)).resolves.toBe(JobStatus.SKIPPED); + await expect(sut.handleSyncAsset(mockAssetJob)).resolves.toBe(JobStatus.SKIPPED); - expect(assetMock.update).not.toHaveBeenCalled(); - }); - - it('should do nothing with already-offline assets', async () => { - const mockAssetJob: ILibraryOfflineJob = { - id: assetStub.external.id, - importPaths: ['/'], - exclusionPatterns: [], - }; - - assetMock.getById.mockResolvedValue(assetStub.offline); - - await expect(sut.handleOfflineCheck(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS); - - expect(assetMock.update).not.toHaveBeenCalled(); + expect(assetMock.remove).not.toHaveBeenCalled(); }); it('should offline assets no longer on disk', async () => { - const mockAssetJob: ILibraryOfflineJob = { + const mockAssetJob: ILibraryAssetJob = { id: assetStub.external.id, importPaths: ['/'], exclusionPatterns: [], }; assetMock.getById.mockResolvedValue(assetStub.external); + storageMock.stat.mockRejectedValue(new Error('ENOENT, no such file or directory')); - await expect(sut.handleOfflineCheck(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS); + await expect(sut.handleSyncAsset(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS); - expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.external.id, isOffline: true }); + expect(assetMock.updateAll).toHaveBeenCalledWith([assetStub.external.id], { + isOffline: true, + deletedAt: expect.any(Date), + }); }); it('should offline assets matching an exclusion pattern', async () => { - const mockAssetJob: ILibraryOfflineJob = { + const mockAssetJob: ILibraryAssetJob = { id: assetStub.external.id, importPaths: ['/'], exclusionPatterns: ['**/user1/**'], @@ -348,13 +288,15 @@ describe(LibraryService.name, () => { assetMock.getById.mockResolvedValue(assetStub.external); - await expect(sut.handleOfflineCheck(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS); - - expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.external.id, isOffline: true }); + await expect(sut.handleSyncAsset(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS); + expect(assetMock.updateAll).toHaveBeenCalledWith([assetStub.external.id], { + isOffline: true, + deletedAt: expect.any(Date), + }); }); it('should set assets outside of import paths as offline', async () => { - const mockAssetJob: ILibraryOfflineJob = { + const mockAssetJob: ILibraryAssetJob = { id: assetStub.external.id, importPaths: ['/data/user2'], exclusionPatterns: [], @@ -363,28 +305,74 @@ describe(LibraryService.name, () => { assetMock.getById.mockResolvedValue(assetStub.external); storageMock.checkFileExists.mockResolvedValue(true); - await expect(sut.handleOfflineCheck(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS); + await expect(sut.handleSyncAsset(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS); - expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.external.id, isOffline: true }); + expect(assetMock.updateAll).toHaveBeenCalledWith([assetStub.external.id], { + isOffline: true, + deletedAt: expect.any(Date), + }); }); it('should do nothing with online assets', async () => { - const mockAssetJob: ILibraryOfflineJob = { + const mockAssetJob: ILibraryAssetJob = { id: assetStub.external.id, importPaths: ['/'], exclusionPatterns: [], }; assetMock.getById.mockResolvedValue(assetStub.external); - storageMock.checkFileExists.mockResolvedValue(true); + storageMock.stat.mockResolvedValue({ mtime: assetStub.external.fileModifiedAt } as Stats); - await expect(sut.handleOfflineCheck(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS); + await expect(sut.handleSyncAsset(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS); - expect(assetMock.update).not.toHaveBeenCalled(); + expect(assetMock.updateAll).not.toHaveBeenCalled(); + }); + + it('should un-trash an asset previously marked as offline', async () => { + const mockAssetJob: ILibraryAssetJob = { + id: assetStub.external.id, + importPaths: ['/'], + exclusionPatterns: [], + }; + + assetMock.getById.mockResolvedValue(assetStub.trashedOffline); + storageMock.stat.mockResolvedValue({ mtime: assetStub.trashedOffline.fileModifiedAt } as Stats); + + await expect(sut.handleSyncAsset(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS); + + expect(assetMock.updateAll).toHaveBeenCalledWith([assetStub.trashedOffline.id], { + deletedAt: null, + fileCreatedAt: assetStub.trashedOffline.fileModifiedAt, + fileModifiedAt: assetStub.trashedOffline.fileModifiedAt, + isOffline: false, + originalFileName: 'path.jpg', + }); }); }); - describe('handleAssetRefresh', () => { + it('should update file when mtime has changed', async () => { + const mockAssetJob: ILibraryAssetJob = { + id: assetStub.external.id, + importPaths: ['/'], + exclusionPatterns: [], + }; + + const newMTime = new Date(); + assetMock.getById.mockResolvedValue(assetStub.external); + storageMock.stat.mockResolvedValue({ mtime: newMTime } as Stats); + + await expect(sut.handleSyncAsset(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS); + + expect(assetMock.updateAll).toHaveBeenCalledWith([assetStub.external.id], { + fileModifiedAt: newMTime, + fileCreatedAt: newMTime, + isOffline: false, + originalFileName: 'photo.jpg', + deletedAt: null, + }); + }); + + describe('handleSyncFile', () => { let mockUser: UserEntity; beforeEach(() => { @@ -397,42 +385,18 @@ describe(LibraryService.name, () => { } as Stats); }); - it('should reject an unknown file extension', async () => { - const mockLibraryJob: ILibraryFileJob = { - id: libraryStub.externalLibrary1.id, - ownerId: mockUser.id, - assetPath: '/data/user1/file.xyz', - force: false, - }; - - assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(null); - - await expect(sut.handleAssetRefresh(mockLibraryJob)).rejects.toBeInstanceOf(BadRequestException); - }); - - it('should reject an unknown file type', async () => { - const mockLibraryJob: ILibraryFileJob = { - id: libraryStub.externalLibrary1.id, - ownerId: mockUser.id, - assetPath: '/data/user1/file.xyz', - force: false, - }; - - await expect(sut.handleAssetRefresh(mockLibraryJob)).rejects.toBeInstanceOf(BadRequestException); - }); - - it('should add a new image', async () => { + it('should import a new asset', async () => { const mockLibraryJob: ILibraryFileJob = { id: libraryStub.externalLibrary1.id, ownerId: mockUser.id, assetPath: '/data/user1/photo.jpg', - force: false, }; assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(null); assetMock.create.mockResolvedValue(assetStub.image); + libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); - await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS); + await expect(sut.handleSyncFile(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS); expect(assetMock.create.mock.calls).toEqual([ [ @@ -467,19 +431,19 @@ describe(LibraryService.name, () => { ]); }); - it('should add a new image with sidecar', async () => { + it('should import a new asset with sidecar', async () => { const mockLibraryJob: ILibraryFileJob = { id: libraryStub.externalLibrary1.id, ownerId: mockUser.id, assetPath: '/data/user1/photo.jpg', - force: false, }; assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(null); assetMock.create.mockResolvedValue(assetStub.image); storageMock.checkFileExists.mockResolvedValue(true); + libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); - await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS); + await expect(sut.handleSyncFile(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS); expect(assetMock.create.mock.calls).toEqual([ [ @@ -514,18 +478,18 @@ describe(LibraryService.name, () => { ]); }); - it('should add a new video', async () => { + it('should import a new video', async () => { const mockLibraryJob: ILibraryFileJob = { id: libraryStub.externalLibrary1.id, ownerId: mockUser.id, assetPath: '/data/user1/video.mp4', - force: false, }; assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(null); assetMock.create.mockResolvedValue(assetStub.video); + libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); - await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS); + await expect(sut.handleSyncFile(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS); expect(assetMock.create.mock.calls).toEqual([ [ @@ -568,29 +532,27 @@ describe(LibraryService.name, () => { ]); }); - it('should not add an image to a soft deleted library', async () => { + it('should not import an asset to a soft deleted library', async () => { const mockLibraryJob: ILibraryFileJob = { id: libraryStub.externalLibrary1.id, ownerId: mockUser.id, assetPath: '/data/user1/photo.jpg', - force: false, }; assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(null); assetMock.create.mockResolvedValue(assetStub.image); libraryMock.get.mockResolvedValue({ ...libraryStub.externalLibrary1, deletedAt: new Date() }); - await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.FAILED); + await expect(sut.handleSyncFile(mockLibraryJob)).resolves.toBe(JobStatus.FAILED); expect(assetMock.create.mock.calls).toEqual([]); }); - it('should not import an asset when mtime matches db asset', async () => { + it('should not refresh a file whose mtime matches existing asset', async () => { const mockLibraryJob: ILibraryFileJob = { id: libraryStub.externalLibrary1.id, ownerId: mockUser.id, assetPath: assetStub.hasFileExtension.originalPath, - force: false, }; storageMock.stat.mockResolvedValue({ @@ -601,190 +563,52 @@ describe(LibraryService.name, () => { assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.hasFileExtension); - await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SKIPPED); + await expect(sut.handleSyncFile(mockLibraryJob)).resolves.toBe(JobStatus.SKIPPED); expect(jobMock.queue).not.toHaveBeenCalled(); expect(jobMock.queueAll).not.toHaveBeenCalled(); }); - it('should import an asset when mtime differs from db asset', async () => { + it('should skip existing asset', async () => { const mockLibraryJob: ILibraryFileJob = { id: libraryStub.externalLibrary1.id, ownerId: mockUser.id, assetPath: '/data/user1/photo.jpg', - force: false, }; assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); - assetMock.create.mockResolvedValue(assetStub.image); - await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS); - - expect(jobMock.queue).toHaveBeenCalledWith({ - name: JobName.METADATA_EXTRACTION, - data: { - id: assetStub.image.id, - source: 'upload', - }, - }); - - expect(jobMock.queue).not.toHaveBeenCalledWith({ - name: JobName.VIDEO_CONVERSION, - data: { - id: assetStub.image.id, - }, - }); + await expect(sut.handleSyncFile(mockLibraryJob)).resolves.toBe(JobStatus.SKIPPED); }); - it('should import an asset that is missing a file extension', async () => { - // This tests for the case where the file extension is missing from the asset path. - // This happened in previous versions of Immich + it('should not refresh an asset trashed by user', async () => { const mockLibraryJob: ILibraryFileJob = { id: libraryStub.externalLibrary1.id, ownerId: mockUser.id, - assetPath: assetStub.missingFileExtension.originalPath, - force: false, - }; - - assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.missingFileExtension); - - await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS); - - expect(assetMock.updateAll).toHaveBeenCalledWith( - [assetStub.missingFileExtension.id], - expect.objectContaining({ originalFileName: 'photo.jpg' }), - ); - }); - - it('should set a missing asset to offline', async () => { - storageMock.stat.mockRejectedValue(new Error('Path not found')); - - const mockLibraryJob: ILibraryFileJob = { - id: assetStub.image.id, - ownerId: mockUser.id, - assetPath: '/data/user1/photo.jpg', - force: false, - }; - - assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); - assetMock.create.mockResolvedValue(assetStub.image); - - await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS); - - expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.image.id, isOffline: true }); - expect(jobMock.queue).not.toHaveBeenCalled(); - expect(jobMock.queueAll).not.toHaveBeenCalled(); - }); - - it('should online a previously-offline asset', async () => { - const mockLibraryJob: ILibraryFileJob = { - id: assetStub.offline.id, - ownerId: mockUser.id, - assetPath: '/data/user1/photo.jpg', - force: false, - }; - - assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.offline); - assetMock.create.mockResolvedValue(assetStub.offline); - - await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS); - - expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.offline.id, isOffline: false }); - - expect(jobMock.queue).toHaveBeenCalledWith({ - name: JobName.METADATA_EXTRACTION, - data: { - id: assetStub.offline.id, - source: 'upload', - }, - }); - - expect(jobMock.queue).not.toHaveBeenCalledWith({ - name: JobName.VIDEO_CONVERSION, - data: { - id: assetStub.offline.id, - }, - }); - }); - - it('should do nothing when mtime matches existing asset', async () => { - const mockLibraryJob: ILibraryFileJob = { - id: assetStub.image.id, - ownerId: assetStub.image.ownerId, - assetPath: '/data/user1/photo.jpg', - force: false, - }; - - assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); - assetMock.create.mockResolvedValue(assetStub.image); - - expect(assetMock.update).not.toHaveBeenCalled(); - - await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS); - }); - - it('should refresh an existing asset if forced', async () => { - const mockLibraryJob: ILibraryFileJob = { - id: assetStub.image.id, - ownerId: assetStub.hasFileExtension.ownerId, assetPath: assetStub.hasFileExtension.originalPath, - force: true, }; - assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.hasFileExtension); - assetMock.create.mockResolvedValue(assetStub.hasFileExtension); + assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.trashed); - await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS); + await expect(sut.handleSyncFile(mockLibraryJob)).resolves.toBe(JobStatus.SKIPPED); - expect(assetMock.updateAll).toHaveBeenCalledWith([assetStub.hasFileExtension.id], { - fileCreatedAt: new Date('2023-01-01'), - fileModifiedAt: new Date('2023-01-01'), - originalFileName: assetStub.hasFileExtension.originalFileName, - }); + expect(jobMock.queue).not.toHaveBeenCalled(); + expect(jobMock.queueAll).not.toHaveBeenCalled(); }); - it('should refresh an existing asset with modified mtime', async () => { - const filemtime = new Date(); - filemtime.setSeconds(assetStub.image.fileModifiedAt.getSeconds() + 10); - - const mockLibraryJob: ILibraryFileJob = { - id: libraryStub.externalLibrary1.id, - ownerId: userStub.admin.id, - assetPath: '/data/user1/photo.jpg', - force: false, - }; - - storageMock.stat.mockResolvedValue({ - size: 100, - mtime: filemtime, - ctime: new Date('2023-01-01'), - } as Stats); - - assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(null); - assetMock.create.mockResolvedValue(assetStub.image); - - await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS); - - expect(assetMock.create).toHaveBeenCalled(); - const createdAsset = assetMock.create.mock.calls[0][0]; - - expect(createdAsset.fileModifiedAt).toEqual(filemtime); - }); - - it('should throw error when asset does not exist', async () => { + it('should throw BadRequestException when asset does not exist', async () => { storageMock.stat.mockRejectedValue(new Error("ENOENT, no such file or directory '/data/user1/photo.jpg'")); const mockLibraryJob: ILibraryFileJob = { id: libraryStub.externalLibrary1.id, ownerId: userStub.admin.id, assetPath: '/data/user1/photo.jpg', - force: false, }; assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(null); assetMock.create.mockResolvedValue(assetStub.image); - await expect(sut.handleAssetRefresh(mockLibraryJob)).rejects.toBeInstanceOf(BadRequestException); + await expect(sut.handleSyncFile(mockLibraryJob)).resolves.toBe(JobStatus.FAILED); }); }); @@ -857,7 +681,6 @@ describe(LibraryService.name, () => { describe('getStatistics', () => { it('should return library statistics', async () => { - libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); libraryMock.getStatistics.mockResolvedValue({ photos: 10, videos: 0, total: 10, usage: 1337 }); await expect(sut.getStatistics(libraryStub.externalLibrary1.id)).resolves.toEqual({ photos: 10, @@ -1092,12 +915,11 @@ describe(LibraryService.name, () => { expect(jobMock.queueAll).toHaveBeenCalledWith([ { - name: JobName.LIBRARY_SCAN_ASSET, + name: JobName.LIBRARY_SYNC_FILE, data: { id: libraryStub.externalLibraryWithImportPaths1.id, assetPath: '/foo/photo.jpg', ownerId: libraryStub.externalLibraryWithImportPaths1.owner.id, - force: false, }, }, ]); @@ -1114,30 +936,16 @@ describe(LibraryService.name, () => { expect(jobMock.queueAll).toHaveBeenCalledWith([ { - name: JobName.LIBRARY_SCAN_ASSET, + name: JobName.LIBRARY_SYNC_FILE, data: { id: libraryStub.externalLibraryWithImportPaths1.id, assetPath: '/foo/photo.jpg', ownerId: libraryStub.externalLibraryWithImportPaths1.owner.id, - force: false, }, }, ]); }); - it('should handle a file unlink event', async () => { - libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); - libraryMock.getAll.mockResolvedValue([libraryStub.externalLibraryWithImportPaths1]); - assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.external); - storageMock.watch.mockImplementation( - makeMockWatcher({ items: [{ event: 'unlink', value: '/foo/photo.jpg' }] }), - ); - - await sut.watchAll(); - - expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.external.id, isOffline: true }); - }); - it('should handle an error event', async () => { libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.external); @@ -1232,72 +1040,23 @@ describe(LibraryService.name, () => { }); describe('queueScan', () => { - it('should queue a library scan of external library', async () => { + it('should queue a library scan', async () => { libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); - await sut.queueScan(libraryStub.externalLibrary1.id, {}); + await sut.queueScan(libraryStub.externalLibrary1.id); expect(jobMock.queue.mock.calls).toEqual([ [ { - name: JobName.LIBRARY_SCAN, + name: JobName.LIBRARY_QUEUE_SYNC_FILES, data: { id: libraryStub.externalLibrary1.id, - refreshModifiedFiles: false, - refreshAllFiles: false, }, }, ], - ]); - }); - - it('should queue a library scan of all modified assets', async () => { - libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); - - await sut.queueScan(libraryStub.externalLibrary1.id, { refreshModifiedFiles: true }); - - expect(jobMock.queue.mock.calls).toEqual([ [ { - name: JobName.LIBRARY_SCAN, - data: { - id: libraryStub.externalLibrary1.id, - refreshModifiedFiles: true, - refreshAllFiles: false, - }, - }, - ], - ]); - }); - - it('should queue a forced library scan', async () => { - libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); - - await sut.queueScan(libraryStub.externalLibrary1.id, { refreshAllFiles: true }); - - expect(jobMock.queue.mock.calls).toEqual([ - [ - { - name: JobName.LIBRARY_SCAN, - data: { - id: libraryStub.externalLibrary1.id, - refreshModifiedFiles: false, - refreshAllFiles: true, - }, - }, - ], - ]); - }); - }); - - describe('queueEmptyTrash', () => { - it('should queue the trash job', async () => { - await sut.queueRemoveOffline(libraryStub.externalLibrary1.id); - - expect(jobMock.queue.mock.calls).toEqual([ - [ - { - name: JobName.LIBRARY_REMOVE_OFFLINE, + name: JobName.LIBRARY_QUEUE_SYNC_ASSETS, data: { id: libraryStub.externalLibrary1.id, }, @@ -1311,7 +1070,7 @@ describe(LibraryService.name, () => { it('should queue the refresh job', async () => { libraryMock.getAll.mockResolvedValue([libraryStub.externalLibrary1]); - await expect(sut.handleQueueAllScan({})).resolves.toBe(JobStatus.SUCCESS); + await expect(sut.handleQueueSyncAll()).resolves.toBe(JobStatus.SUCCESS); expect(jobMock.queue.mock.calls).toEqual([ [ @@ -1323,48 +1082,32 @@ describe(LibraryService.name, () => { ]); expect(jobMock.queueAll).toHaveBeenCalledWith([ { - name: JobName.LIBRARY_SCAN, + name: JobName.LIBRARY_QUEUE_SYNC_FILES, data: { id: libraryStub.externalLibrary1.id, - refreshModifiedFiles: true, - refreshAllFiles: false, - }, - }, - ]); - }); - - it('should queue the force refresh job', async () => { - libraryMock.getAll.mockResolvedValue([libraryStub.externalLibrary1]); - - await expect(sut.handleQueueAllScan({ force: true })).resolves.toBe(JobStatus.SUCCESS); - - expect(jobMock.queue).toHaveBeenCalledWith({ - name: JobName.LIBRARY_QUEUE_CLEANUP, - data: {}, - }); - - expect(jobMock.queueAll).toHaveBeenCalledWith([ - { - name: JobName.LIBRARY_SCAN, - data: { - id: libraryStub.externalLibrary1.id, - refreshModifiedFiles: false, - refreshAllFiles: true, }, }, ]); }); }); - describe('handleRemoveOfflineFiles', () => { - it('should queue trash deletion jobs', async () => { - assetMock.getWith.mockResolvedValue({ items: [assetStub.image1], hasNextPage: false }); + describe('handleQueueAssetOfflineCheck', () => { + it('should queue removal jobs', async () => { + libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); + assetMock.getAll.mockResolvedValue({ items: [assetStub.image1], hasNextPage: false }); assetMock.getById.mockResolvedValue(assetStub.image1); - await expect(sut.handleRemoveOffline({ id: libraryStub.externalLibrary1.id })).resolves.toBe(JobStatus.SUCCESS); + await expect(sut.handleQueueSyncAssets({ id: libraryStub.externalLibrary1.id })).resolves.toBe(JobStatus.SUCCESS); expect(jobMock.queueAll).toHaveBeenCalledWith([ - { name: JobName.ASSET_DELETION, data: { id: assetStub.image1.id, deleteOnDisk: false } }, + { + name: JobName.LIBRARY_SYNC_ASSET, + data: { + id: assetStub.image1.id, + importPaths: libraryStub.externalLibrary1.importPaths, + exclusionPatterns: libraryStub.externalLibrary1.exclusionPatterns, + }, + }, ]); }); }); diff --git a/server/src/services/library.service.ts b/server/src/services/library.service.ts index 3dd81dd61377b..52b786089cbaf 100644 --- a/server/src/services/library.service.ts +++ b/server/src/services/library.service.ts @@ -1,6 +1,5 @@ import { BadRequestException, Inject, Injectable } from '@nestjs/common'; import { R_OK } from 'node:constants'; -import { Stats } from 'node:fs'; import path, { basename, parse } from 'node:path'; import picomatch from 'picomatch'; import { StorageCore } from 'src/cores/storage.core'; @@ -10,27 +9,26 @@ import { CreateLibraryDto, LibraryResponseDto, LibraryStatsResponseDto, - ScanLibraryDto, + mapLibrary, UpdateLibraryDto, ValidateLibraryDto, ValidateLibraryImportPathResponseDto, ValidateLibraryResponseDto, - mapLibrary, } from 'src/dtos/library.dto'; +import { AssetEntity } from 'src/entities/asset.entity'; +import { LibraryEntity } from 'src/entities/library.entity'; import { AssetType } from 'src/enum'; -import { IAssetRepository, WithProperty } from 'src/interfaces/asset.interface'; +import { IAssetRepository } from 'src/interfaces/asset.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.interface'; import { ArgOf } from 'src/interfaces/event.interface'; import { - IBaseJob, IEntityJob, IJobRepository, + ILibraryAssetJob, ILibraryFileJob, - ILibraryOfflineJob, - ILibraryRefreshJob, - JOBS_LIBRARY_PAGINATION_SIZE, JobName, + JOBS_LIBRARY_PAGINATION_SIZE, JobStatus, } from 'src/interfaces/job.interface'; import { ILibraryRepository } from 'src/interfaces/library.interface'; @@ -78,11 +76,7 @@ export class LibraryService { this.jobRepository.addCronJob( 'libraryScan', scan.cronExpression, - () => - handlePromiseError( - this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_SCAN_ALL, data: { force: false } }), - this.logger, - ), + () => handlePromiseError(this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_SYNC_ALL }), this.logger), scan.enabled, ); @@ -143,7 +137,7 @@ export class LibraryService { const handler = async () => { this.logger.debug(`File add event received for ${path} in library ${library.id}}`); if (matcher(path)) { - await this.scanAssets(library.id, [path], library.ownerId, false); + await this.syncFiles(library, [path]); } }; return handlePromiseError(handler(), this.logger); @@ -151,9 +145,13 @@ export class LibraryService { onChange: (path) => { const handler = async () => { this.logger.debug(`Detected file change for ${path} in library ${library.id}`); + const asset = await this.assetRepository.getByLibraryIdAndOriginalPath(library.id, path); + if (asset) { + await this.syncAssets(library, [asset.id]); + } if (matcher(path)) { // Note: if the changed file was not previously imported, it will be imported now. - await this.scanAssets(library.id, [path], library.ownerId, false); + await this.syncFiles(library, [path]); } }; return handlePromiseError(handler(), this.logger); @@ -162,8 +160,8 @@ export class LibraryService { const handler = async () => { this.logger.debug(`Detected deleted file at ${path} in library ${library.id}`); const asset = await this.assetRepository.getByLibraryIdAndOriginalPath(library.id, path); - if (asset && matcher(path)) { - await this.assetRepository.update({ id: asset.id, isOffline: true }); + if (asset) { + await this.syncAssets(library, [asset.id]); } }; return handlePromiseError(handler(), this.logger); @@ -216,7 +214,7 @@ export class LibraryService { async getStatistics(id: string): Promise { const statistics = await this.repository.getStatistics(id); if (!statistics) { - throw new BadRequestException('Library not found'); + throw new BadRequestException(`Library ${id} not found`); } return statistics; } @@ -250,20 +248,28 @@ export class LibraryService { return mapLibrary(library); } - private async scanAssets(libraryId: string, assetPaths: string[], ownerId: string, force = false) { + private async syncFiles({ id, ownerId }: LibraryEntity, assetPaths: string[]) { await this.jobRepository.queueAll( assetPaths.map((assetPath) => ({ - name: JobName.LIBRARY_SCAN_ASSET, + name: JobName.LIBRARY_SYNC_FILE, data: { - id: libraryId, + id, assetPath, ownerId, - force, }, })), ); } + private async syncAssets({ importPaths, exclusionPatterns }: LibraryEntity, assetIds: string[]) { + await this.jobRepository.queueAll( + assetIds.map((assetId) => ({ + name: JobName.LIBRARY_SYNC_ASSET, + data: { id: assetId, importPaths, exclusionPatterns }, + })), + ); + } + private async validateImportPath(importPath: string): Promise { const validation = new ValidateLibraryImportPathResponseDto(); validation.importPath = importPath; @@ -366,258 +372,182 @@ export class LibraryService { return JobStatus.SUCCESS; } - async handleAssetRefresh(job: ILibraryFileJob): Promise { + async handleSyncFile(job: ILibraryFileJob): Promise { + // Only needs to handle new assets const assetPath = path.normalize(job.assetPath); - const existingAssetEntity = await this.assetRepository.getByLibraryIdAndOriginalPath(job.id, assetPath); - - let stats: Stats; - try { - stats = await this.storageRepository.stat(assetPath); - } catch (error: Error | any) { - // Can't access file, probably offline - if (existingAssetEntity) { - // Mark asset as offline - this.logger.debug(`Marking asset as offline: ${assetPath}`); - - await this.assetRepository.update({ id: existingAssetEntity.id, isOffline: true }); - return JobStatus.SUCCESS; - } else { - // File can't be accessed and does not already exist in db - throw new BadRequestException('Cannot access file', { cause: error }); - } - } - - let doImport = false; - let doRefresh = false; - - if (job.force) { - doRefresh = true; - } - - const originalFileName = parse(assetPath).base; - - if (!existingAssetEntity) { - // This asset is new to us, read it from disk - this.logger.debug(`Importing new asset: ${assetPath}`); - doImport = true; - } else if (stats.mtime.toISOString() !== existingAssetEntity.fileModifiedAt.toISOString()) { - // File modification time has changed since last time we checked, re-read from disk - this.logger.debug( - `File modification time has changed, re-importing asset: ${assetPath}. Old mtime: ${existingAssetEntity.fileModifiedAt}. New mtime: ${stats.mtime}`, - ); - doRefresh = true; - } else if (existingAssetEntity.originalFileName !== originalFileName) { - // TODO: We can likely remove this check in the second half of 2024 when all assets have likely been re-imported by all users - this.logger.debug( - `Asset is missing file extension, re-importing: ${assetPath}. Current incorrect filename: ${existingAssetEntity.originalFileName}.`, - ); - doRefresh = true; - } else if (!job.force && stats && !existingAssetEntity.isOffline) { - // Asset exists on disk and in db and mtime has not changed. Also, we are not forcing refresn. Therefore, do nothing - this.logger.debug(`Asset already exists in database and on disk, will not import: ${assetPath}`); - } - - if (stats && existingAssetEntity?.isOffline) { - // File was previously offline but is now online - this.logger.debug(`Marking previously-offline asset as online: ${assetPath}`); - await this.assetRepository.update({ id: existingAssetEntity.id, isOffline: false }); - doRefresh = true; - } - - if (!doImport && !doRefresh) { - // If we don't import, exit here + let asset = await this.assetRepository.getByLibraryIdAndOriginalPath(job.id, assetPath); + if (asset) { return JobStatus.SKIPPED; } - let assetType: AssetType; - - if (mimeTypes.isImage(assetPath)) { - assetType = AssetType.IMAGE; - } else if (mimeTypes.isVideo(assetPath)) { - assetType = AssetType.VIDEO; - } else { - throw new BadRequestException(`Unsupported file type ${assetPath}`); + let stat; + try { + stat = await this.storageRepository.stat(assetPath); + } catch (error: any) { + if (error.code === 'ENOENT') { + this.logger.error(`File not found: ${assetPath}`); + return JobStatus.SKIPPED; + } + this.logger.error(`Error reading file: ${assetPath}. Error: ${error}`); + return JobStatus.FAILED; } + this.logger.log(`Importing new library asset: ${assetPath}`); + + const library = await this.repository.get(job.id, true); + if (!library || library.deletedAt) { + this.logger.error('Cannot import asset into deleted library'); + return JobStatus.FAILED; + } + + // TODO: device asset id is deprecated, remove it + const deviceAssetId = `${basename(assetPath)}`.replaceAll(/\s+/g, ''); + + const pathHash = this.cryptoRepository.hashSha1(`path:${assetPath}`); + // TODO: doesn't xmp replace the file extension? Will need investigation let sidecarPath: string | null = null; if (await this.storageRepository.checkFileExists(`${assetPath}.xmp`, R_OK)) { sidecarPath = `${assetPath}.xmp`; } - // TODO: device asset id is deprecated, remove it - const deviceAssetId = `${basename(assetPath)}`.replaceAll(/\s+/g, ''); + const assetType = mimeTypes.isVideo(assetPath) ? AssetType.VIDEO : AssetType.IMAGE; - let assetId; - if (doImport) { - const library = await this.repository.get(job.id, true); - if (library?.deletedAt) { - this.logger.error('Cannot import asset into deleted library'); - return JobStatus.FAILED; - } + const mtime = stat.mtime; - const pathHash = this.cryptoRepository.hashSha1(`path:${assetPath}`); + asset = await this.assetRepository.create({ + ownerId: job.ownerId, + libraryId: job.id, + checksum: pathHash, + originalPath: assetPath, + deviceAssetId, + deviceId: 'Library Import', + fileCreatedAt: mtime, + fileModifiedAt: mtime, + localDateTime: mtime, + type: assetType, + originalFileName: parse(assetPath).base, - // TODO: In wait of refactoring the domain asset service, this function is just manually written like this - const addedAsset = await this.assetRepository.create({ - ownerId: job.ownerId, - libraryId: job.id, - checksum: pathHash, - originalPath: assetPath, - deviceAssetId, - deviceId: 'Library Import', - fileCreatedAt: stats.mtime, - fileModifiedAt: stats.mtime, - localDateTime: stats.mtime, - type: assetType, - originalFileName, - sidecarPath, - isExternal: true, - }); - assetId = addedAsset.id; - } else if (doRefresh && existingAssetEntity) { - assetId = existingAssetEntity.id; - await this.assetRepository.updateAll([existingAssetEntity.id], { - fileCreatedAt: stats.mtime, - fileModifiedAt: stats.mtime, - originalFileName, - }); - } else { - // Not importing and not refreshing, do nothing - return JobStatus.SKIPPED; - } + sidecarPath, + isExternal: true, + }); - this.logger.debug(`Queueing metadata extraction for: ${assetPath}`); - - await this.jobRepository.queue({ name: JobName.METADATA_EXTRACTION, data: { id: assetId, source: 'upload' } }); - - if (assetType === AssetType.VIDEO) { - await this.jobRepository.queue({ name: JobName.VIDEO_CONVERSION, data: { id: assetId } }); - } + await this.queuePostSyncJobs(asset); return JobStatus.SUCCESS; } - async queueScan(id: string, dto: ScanLibraryDto) { + async queuePostSyncJobs(asset: AssetEntity) { + this.logger.debug(`Queueing metadata extraction for: ${asset.originalPath}`); + + await this.jobRepository.queue({ name: JobName.METADATA_EXTRACTION, data: { id: asset.id, source: 'upload' } }); + + if (asset.type === AssetType.VIDEO) { + await this.jobRepository.queue({ name: JobName.VIDEO_CONVERSION, data: { id: asset.id } }); + } + } + + async queueScan(id: string) { await this.findOrFail(id); await this.jobRepository.queue({ - name: JobName.LIBRARY_SCAN, + name: JobName.LIBRARY_QUEUE_SYNC_FILES, data: { id, - refreshModifiedFiles: dto.refreshModifiedFiles ?? false, - refreshAllFiles: dto.refreshAllFiles ?? false, }, }); + await this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_SYNC_ASSETS, data: { id } }); } - async queueRemoveOffline(id: string) { - this.logger.verbose(`Queueing offline file removal from library ${id}`); - await this.jobRepository.queue({ name: JobName.LIBRARY_REMOVE_OFFLINE, data: { id } }); - } - - async handleQueueAllScan(job: IBaseJob): Promise { - this.logger.debug(`Refreshing all external libraries: force=${job.force}`); + async handleQueueSyncAll(): Promise { + this.logger.debug(`Refreshing all external libraries`); await this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_CLEANUP, data: {} }); const libraries = await this.repository.getAll(true); await this.jobRepository.queueAll( libraries.map((library) => ({ - name: JobName.LIBRARY_SCAN, + name: JobName.LIBRARY_QUEUE_SYNC_FILES, + data: { + id: library.id, + }, + })), + ); + await this.jobRepository.queueAll( + libraries.map((library) => ({ + name: JobName.LIBRARY_QUEUE_SYNC_ASSETS, data: { id: library.id, - refreshModifiedFiles: !job.force, - refreshAllFiles: job.force ?? false, }, })), ); return JobStatus.SUCCESS; } - async handleOfflineCheck(job: ILibraryOfflineJob): Promise { + async handleSyncAsset(job: ILibraryAssetJob): Promise { const asset = await this.assetRepository.getById(job.id); - if (!asset) { - // Asset is no longer in the database, skip return JobStatus.SKIPPED; } - if (asset.isOffline) { - this.logger.verbose(`Asset is already offline: ${asset.originalPath}`); - return JobStatus.SUCCESS; - } + const markOffline = async (explanation: string) => { + if (!asset.isOffline) { + this.logger.debug(`${explanation}, removing: ${asset.originalPath}`); + await this.assetRepository.updateAll([asset.id], { isOffline: true, deletedAt: new Date() }); + } + }; const isInPath = job.importPaths.find((path) => asset.originalPath.startsWith(path)); if (!isInPath) { - this.logger.debug(`Asset is no longer in an import path, marking offline: ${asset.originalPath}`); - await this.assetRepository.update({ id: asset.id, isOffline: true }); + await markOffline('Asset is no longer in an import path'); return JobStatus.SUCCESS; } const isExcluded = job.exclusionPatterns.some((pattern) => picomatch.isMatch(asset.originalPath, pattern)); if (isExcluded) { - this.logger.debug(`Asset is covered by an exclusion pattern, marking offline: ${asset.originalPath}`); - await this.assetRepository.update({ id: asset.id, isOffline: true }); + await markOffline('Asset is covered by an exclusion pattern'); return JobStatus.SUCCESS; } - const fileExists = await this.storageRepository.checkFileExists(asset.originalPath, R_OK); - if (!fileExists) { - this.logger.debug(`Asset is no longer found on disk, marking offline: ${asset.originalPath}`); - await this.assetRepository.update({ id: asset.id, isOffline: true }); + let stat; + try { + stat = await this.storageRepository.stat(asset.originalPath); + } catch { + await markOffline('Asset is no longer on disk or is inaccessible because of permissions'); return JobStatus.SUCCESS; } - this.logger.verbose( - `Asset is found on disk, not covered by an exclusion pattern, and is in an import path, keeping online: ${asset.originalPath}`, - ); + const mtime = stat.mtime; + const isAssetModified = mtime.toISOString() !== asset.fileModifiedAt.toISOString(); + if (asset.isOffline || isAssetModified) { + this.logger.debug(`Asset was offline or modified, updating asset record ${asset.originalPath}`); + //TODO: When we have asset status, we need to leave deletedAt as is when status is trashed + await this.assetRepository.updateAll([asset.id], { + isOffline: false, + deletedAt: null, + fileCreatedAt: mtime, + fileModifiedAt: mtime, + originalFileName: parse(asset.originalPath).base, + }); + } + + if (isAssetModified) { + this.logger.debug(`Asset was modified, queuing metadata extraction for: ${asset.originalPath}`); + await this.queuePostSyncJobs(asset); + } return JobStatus.SUCCESS; } - async handleRemoveOffline(job: IEntityJob): Promise { - this.logger.debug(`Removing offline assets for library ${job.id}`); - - const assetPagination = usePagination(JOBS_LIBRARY_PAGINATION_SIZE, (pagination) => - this.assetRepository.getWith(pagination, WithProperty.IS_OFFLINE, job.id, true), - ); - - let offlineAssets = 0; - for await (const assets of assetPagination) { - offlineAssets += assets.length; - if (assets.length > 0) { - this.logger.debug(`Discovered ${offlineAssets} offline assets in library ${job.id}`); - await this.jobRepository.queueAll( - assets.map((asset) => ({ - name: JobName.ASSET_DELETION, - data: { - id: asset.id, - deleteOnDisk: false, - }, - })), - ); - this.logger.verbose(`Queued deletion of ${assets.length} offline assets in library ${job.id}`); - } - } - - if (offlineAssets) { - this.logger.debug(`Finished queueing deletion of ${offlineAssets} offline assets for library ${job.id}`); - } else { - this.logger.debug(`Found no offline assets to delete from library ${job.id}`); - } - - return JobStatus.SUCCESS; - } - - async handleQueueAssetRefresh(job: ILibraryRefreshJob): Promise { + async handleQueueSyncFiles(job: IEntityJob): Promise { const library = await this.repository.get(job.id); if (!library) { + this.logger.debug(`Library ${job.id} not found, skipping refresh`); return JobStatus.SKIPPED; } - this.logger.log(`Refreshing library ${library.id}`); + this.logger.log(`Refreshing library ${library.id} for new assets`); const validImportPaths: string[] = []; @@ -630,55 +560,66 @@ export class LibraryService { } } - if (validImportPaths.length === 0) { + if (validImportPaths) { + const assetsOnDisk = this.storageRepository.walk({ + pathsToCrawl: validImportPaths, + includeHidden: false, + exclusionPatterns: library.exclusionPatterns, + take: JOBS_LIBRARY_PAGINATION_SIZE, + }); + + let count = 0; + + for await (const assetBatch of assetsOnDisk) { + count += assetBatch.length; + this.logger.debug(`Discovered ${count} asset(s) on disk for library ${library.id}...`); + await this.syncFiles(library, assetBatch); + this.logger.verbose(`Queued scan of ${assetBatch.length} crawled asset(s) in library ${library.id}...`); + } + + if (count > 0) { + this.logger.debug(`Finished queueing scan of ${count} assets on disk for library ${library.id}`); + } else { + this.logger.debug(`No non-excluded assets found in any import path for library ${library.id}`); + } + } else { this.logger.warn(`No valid import paths found for library ${library.id}`); } - const assetsOnDisk = this.storageRepository.walk({ - pathsToCrawl: validImportPaths, - includeHidden: false, - exclusionPatterns: library.exclusionPatterns, - take: JOBS_LIBRARY_PAGINATION_SIZE, - }); + await this.repository.update({ id: job.id, refreshedAt: new Date() }); - let crawledAssets = 0; + return JobStatus.SUCCESS; + } - for await (const assetBatch of assetsOnDisk) { - crawledAssets += assetBatch.length; - this.logger.debug(`Discovered ${crawledAssets} asset(s) on disk for library ${library.id}...`); - await this.scanAssets(job.id, assetBatch, library.ownerId, job.refreshAllFiles ?? false); - this.logger.verbose(`Queued scan of ${assetBatch.length} crawled asset(s) in library ${library.id}...`); + async handleQueueSyncAssets(job: IEntityJob): Promise { + const library = await this.repository.get(job.id); + if (!library) { + return JobStatus.SKIPPED; } - if (crawledAssets) { - this.logger.debug(`Finished queueing scan of ${crawledAssets} assets on disk for library ${library.id}`); - } else { - this.logger.debug(`No non-excluded assets found in any import path for library ${library.id}`); - } + this.logger.log(`Scanning library ${library.id} for removed assets`); const onlineAssets = usePagination(JOBS_LIBRARY_PAGINATION_SIZE, (pagination) => - this.assetRepository.getWith(pagination, WithProperty.IS_ONLINE, job.id), + this.assetRepository.getAll(pagination, { libraryId: job.id }), ); - let onlineAssetCount = 0; + let assetCount = 0; for await (const assets of onlineAssets) { - onlineAssetCount += assets.length; - this.logger.debug(`Discovered ${onlineAssetCount} asset(s) in library ${library.id}...`); + assetCount += assets.length; + this.logger.debug(`Discovered ${assetCount} asset(s) in library ${library.id}...`); await this.jobRepository.queueAll( assets.map((asset) => ({ - name: JobName.LIBRARY_CHECK_OFFLINE, - data: { id: asset.id, importPaths: validImportPaths, exclusionPatterns: library.exclusionPatterns }, + name: JobName.LIBRARY_SYNC_ASSET, + data: { id: asset.id, importPaths: library.importPaths, exclusionPatterns: library.exclusionPatterns }, })), ); - this.logger.debug(`Queued online check of ${assets.length} asset(s) in library ${library.id}...`); + this.logger.debug(`Queued check of ${assets.length} asset(s) in library ${library.id}...`); } - if (onlineAssetCount) { - this.logger.log(`Finished queueing online check of ${onlineAssetCount} assets for library ${library.id}`); + if (assetCount) { + this.logger.log(`Finished queueing check of ${assetCount} assets for library ${library.id}`); } - await this.repository.update({ id: job.id, refreshedAt: new Date() }); - return JobStatus.SUCCESS; } diff --git a/server/src/services/microservices.service.ts b/server/src/services/microservices.service.ts index 25bfc0fdd29e1..80f1b2be415aa 100644 --- a/server/src/services/microservices.service.ts +++ b/server/src/services/microservices.service.ts @@ -86,12 +86,12 @@ export class MicroservicesService { [JobName.SIDECAR_DISCOVERY]: (data) => this.metadataService.handleSidecarDiscovery(data), [JobName.SIDECAR_SYNC]: (data) => this.metadataService.handleSidecarSync(data), [JobName.SIDECAR_WRITE]: (data) => this.metadataService.handleSidecarWrite(data), - [JobName.LIBRARY_SCAN_ASSET]: (data) => this.libraryService.handleAssetRefresh(data), - [JobName.LIBRARY_SCAN]: (data) => this.libraryService.handleQueueAssetRefresh(data), + [JobName.LIBRARY_QUEUE_SYNC_ALL]: () => this.libraryService.handleQueueSyncAll(), + [JobName.LIBRARY_QUEUE_SYNC_FILES]: (data) => this.libraryService.handleQueueSyncFiles(data), //Queues all files paths on disk + [JobName.LIBRARY_SYNC_FILE]: (data) => this.libraryService.handleSyncFile(data), //Handles a single path on disk //Watcher calls for new files + [JobName.LIBRARY_QUEUE_SYNC_ASSETS]: (data) => this.libraryService.handleQueueSyncAssets(data), //Queues all library assets + [JobName.LIBRARY_SYNC_ASSET]: (data) => this.libraryService.handleSyncAsset(data), //Handles all library assets // Watcher calls for unlink and changed [JobName.LIBRARY_DELETE]: (data) => this.libraryService.handleDeleteLibrary(data), - [JobName.LIBRARY_CHECK_OFFLINE]: (data) => this.libraryService.handleOfflineCheck(data), - [JobName.LIBRARY_REMOVE_OFFLINE]: (data) => this.libraryService.handleRemoveOffline(data), - [JobName.LIBRARY_QUEUE_SCAN_ALL]: (data) => this.libraryService.handleQueueAllScan(data), [JobName.LIBRARY_QUEUE_CLEANUP]: () => this.libraryService.handleQueueCleanup(), [JobName.SEND_EMAIL]: (data) => this.notificationService.handleSendEmail(data), [JobName.NOTIFY_ALBUM_INVITE]: (data) => this.notificationService.handleAlbumInvite(data), diff --git a/server/src/services/trash.service.spec.ts b/server/src/services/trash.service.spec.ts index 87821f028a08a..d0c719ae48e73 100644 --- a/server/src/services/trash.service.spec.ts +++ b/server/src/services/trash.service.spec.ts @@ -67,7 +67,7 @@ describe(TrashService.name, () => { }); it('should restore', async () => { - trashMock.getDeletedIds.mockResolvedValue({ items: ['asset-id'], hasNextPage: false }); + trashMock.getDeletedIds.mockResolvedValue({ items: ['asset-1'], hasNextPage: false }); trashMock.restore.mockResolvedValue(1); await expect(sut.restore(authStub.user1)).resolves.toEqual({ count: 1 }); expect(trashMock.restore).toHaveBeenCalledWith('user-id'); @@ -83,7 +83,7 @@ describe(TrashService.name, () => { }); it('should empty the trash', async () => { - trashMock.getDeletedIds.mockResolvedValue({ items: ['asset-id'], hasNextPage: false }); + trashMock.getDeletedIds.mockResolvedValue({ items: ['asset-1'], hasNextPage: false }); trashMock.empty.mockResolvedValue(1); await expect(sut.empty(authStub.user1)).resolves.toEqual({ count: 1 }); expect(trashMock.empty).toHaveBeenCalledWith('user-id'); diff --git a/server/src/utils/database.ts b/server/src/utils/database.ts index f3232eb78bb2e..5f4577f4df59b 100644 --- a/server/src/utils/database.ts +++ b/server/src/utils/database.ts @@ -80,7 +80,7 @@ export function searchAssetBuilder( }); } - const status = _.pick(options, ['isFavorite', 'isOffline', 'isVisible', 'type']); + const status = _.pick(options, ['isFavorite', 'isVisible', 'type']); const { isArchived, isEncoded, diff --git a/server/test/fixtures/asset.stub.ts b/server/test/fixtures/asset.stub.ts index a9b5167909db8..119c0b6e5ab76 100644 --- a/server/test/fixtures/asset.stub.ts +++ b/server/test/fixtures/asset.stub.ts @@ -70,9 +70,9 @@ export const assetStub = { faces: [], sidecarPath: null, deletedAt: null, - isOffline: false, isExternal: false, duplicateId: null, + isOffline: false, }), noWebpPath: Object.freeze({ @@ -104,13 +104,13 @@ export const assetStub = { originalFileName: 'IMG_456.jpg', faces: [], sidecarPath: null, - isOffline: false, isExternal: false, exifInfo: { fileSizeInByte: 123_000, } as ExifEntity, deletedAt: null, duplicateId: null, + isOffline: false, }), noThumbhash: Object.freeze({ @@ -133,7 +133,6 @@ export const assetStub = { localDateTime: new Date('2023-02-23T05:06:29.716Z'), isFavorite: true, isArchived: false, - isOffline: false, duration: null, isVisible: true, isExternal: false, @@ -146,6 +145,7 @@ export const assetStub = { sidecarPath: null, deletedAt: null, duplicateId: null, + isOffline: false, }), primaryImage: Object.freeze({ @@ -173,7 +173,6 @@ export const assetStub = { isExternal: false, livePhotoVideo: null, livePhotoVideoId: null, - isOffline: false, tags: [], sharedLinks: [], originalFileName: 'asset-id.jpg', @@ -191,6 +190,7 @@ export const assetStub = { { id: 'stack-child-asset-2' } as AssetEntity, ]), duplicateId: null, + isOffline: false, }), image: Object.freeze({ @@ -218,7 +218,6 @@ export const assetStub = { isExternal: false, livePhotoVideo: null, livePhotoVideoId: null, - isOffline: false, tags: [], sharedLinks: [], originalFileName: 'asset-id.jpg', @@ -231,9 +230,50 @@ export const assetStub = { exifImageWidth: 2160, } as ExifEntity, duplicateId: null, + isOffline: false, }), trashed: Object.freeze({ + id: 'asset-id', + deviceAssetId: 'device-asset-id', + fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), + fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), + owner: userStub.user1, + ownerId: 'user-id', + deviceId: 'device-id', + originalPath: '/original/path.jpg', + checksum: Buffer.from('file hash', 'utf8'), + type: AssetType.IMAGE, + files, + thumbhash: Buffer.from('blablabla', 'base64'), + encodedVideoPath: null, + createdAt: new Date('2023-02-23T05:06:29.716Z'), + updatedAt: new Date('2023-02-23T05:06:29.716Z'), + deletedAt: new Date('2023-02-24T05:06:29.716Z'), + localDateTime: new Date('2023-02-23T05:06:29.716Z'), + isFavorite: false, + isArchived: false, + duration: null, + isVisible: true, + isExternal: false, + livePhotoVideo: null, + livePhotoVideoId: null, + tags: [], + sharedLinks: [], + originalFileName: 'asset-id.jpg', + faces: [], + sidecarPath: null, + exifInfo: { + fileSizeInByte: 5000, + exifImageHeight: 3840, + exifImageWidth: 2160, + } as ExifEntity, + duplicateId: null, + isOffline: false, + status: AssetStatus.TRASHED, + }), + + trashedOffline: Object.freeze({ id: 'asset-id', status: AssetStatus.ACTIVE, deviceAssetId: 'device-asset-id', @@ -259,7 +299,6 @@ export const assetStub = { isExternal: false, livePhotoVideo: null, livePhotoVideoId: null, - isOffline: false, tags: [], sharedLinks: [], originalFileName: 'asset-id.jpg', @@ -271,8 +310,8 @@ export const assetStub = { exifImageWidth: 2160, } as ExifEntity, duplicateId: null, + isOffline: true, }), - archived: Object.freeze({ id: 'asset-id', status: AssetStatus.ACTIVE, @@ -298,7 +337,6 @@ export const assetStub = { isExternal: false, livePhotoVideo: null, livePhotoVideoId: null, - isOffline: false, tags: [], sharedLinks: [], originalFileName: 'asset-id.jpg', @@ -311,6 +349,7 @@ export const assetStub = { exifImageWidth: 2160, } as ExifEntity, duplicateId: null, + isOffline: false, }), external: Object.freeze({ @@ -338,97 +377,19 @@ export const assetStub = { isVisible: true, livePhotoVideo: null, livePhotoVideoId: null, + libraryId: 'library-id', + library: libraryStub.externalLibrary1, + tags: [], + sharedLinks: [], + originalFileName: 'asset-id.jpg', + faces: [], + deletedAt: null, + sidecarPath: null, + exifInfo: { + fileSizeInByte: 5000, + } as ExifEntity, + duplicateId: null, isOffline: false, - libraryId: 'library-id', - library: libraryStub.externalLibrary1, - tags: [], - sharedLinks: [], - originalFileName: 'asset-id.jpg', - faces: [], - deletedAt: null, - sidecarPath: null, - exifInfo: { - fileSizeInByte: 5000, - } as ExifEntity, - duplicateId: null, - }), - - offline: Object.freeze({ - id: 'asset-id', - status: AssetStatus.ACTIVE, - deviceAssetId: 'device-asset-id', - fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), - fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), - owner: userStub.user1, - ownerId: 'user-id', - deviceId: 'device-id', - originalPath: '/original/path.jpg', - checksum: Buffer.from('file hash', 'utf8'), - type: AssetType.IMAGE, - files, - thumbhash: Buffer.from('blablabla', 'base64'), - encodedVideoPath: null, - createdAt: new Date('2023-02-23T05:06:29.716Z'), - updatedAt: new Date('2023-02-23T05:06:29.716Z'), - localDateTime: new Date('2023-02-23T05:06:29.716Z'), - isFavorite: true, - isArchived: false, - isExternal: false, - duration: null, - isVisible: true, - livePhotoVideo: null, - livePhotoVideoId: null, - isOffline: true, - tags: [], - sharedLinks: [], - originalFileName: 'asset-id.jpg', - faces: [], - sidecarPath: null, - exifInfo: { - fileSizeInByte: 5000, - } as ExifEntity, - deletedAt: null, - duplicateId: null, - }), - - externalOffline: Object.freeze({ - id: 'asset-id', - status: AssetStatus.ACTIVE, - deviceAssetId: 'device-asset-id', - fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), - fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), - owner: userStub.user1, - ownerId: 'user-id', - deviceId: 'device-id', - originalPath: '/data/user1/photo.jpg', - checksum: Buffer.from('path hash', 'utf8'), - type: AssetType.IMAGE, - files, - thumbhash: Buffer.from('blablabla', 'base64'), - encodedVideoPath: null, - createdAt: new Date('2023-02-23T05:06:29.716Z'), - updatedAt: new Date('2023-02-23T05:06:29.716Z'), - localDateTime: new Date('2023-02-23T05:06:29.716Z'), - isFavorite: true, - isArchived: false, - isExternal: true, - duration: null, - isVisible: true, - livePhotoVideo: null, - livePhotoVideoId: null, - isOffline: true, - libraryId: 'library-id', - library: libraryStub.externalLibrary1, - tags: [], - sharedLinks: [], - originalFileName: 'asset-id.jpg', - faces: [], - sidecarPath: null, - exifInfo: { - fileSizeInByte: 5000, - } as ExifEntity, - deletedAt: null, - duplicateId: null, }), image1: Object.freeze({ @@ -457,7 +418,6 @@ export const assetStub = { livePhotoVideo: null, livePhotoVideoId: null, isExternal: false, - isOffline: false, tags: [], sharedLinks: [], originalFileName: 'asset-id.ext', @@ -467,6 +427,7 @@ export const assetStub = { fileSizeInByte: 5000, } as ExifEntity, duplicateId: null, + isOffline: false, }), imageFrom2015: Object.freeze({ @@ -490,7 +451,6 @@ export const assetStub = { isFavorite: true, isArchived: false, isExternal: false, - isOffline: false, duration: null, isVisible: true, livePhotoVideo: null, @@ -505,6 +465,7 @@ export const assetStub = { } as ExifEntity, deletedAt: null, duplicateId: null, + isOffline: false, }), video: Object.freeze({ @@ -529,7 +490,6 @@ export const assetStub = { isFavorite: true, isArchived: false, isExternal: false, - isOffline: false, duration: null, isVisible: true, livePhotoVideo: null, @@ -545,6 +505,7 @@ export const assetStub = { } as ExifEntity, deletedAt: null, duplicateId: null, + isOffline: false, }), livePhotoMotionAsset: Object.freeze({ @@ -664,7 +625,6 @@ export const assetStub = { isFavorite: false, isArchived: false, isExternal: false, - isOffline: false, duration: null, isVisible: true, livePhotoVideo: null, @@ -683,6 +643,7 @@ export const assetStub = { } as ExifEntity, deletedAt: null, duplicateId: null, + isOffline: false, }), sidecar: Object.freeze({ id: 'asset-id', @@ -705,7 +666,6 @@ export const assetStub = { isFavorite: true, isArchived: false, isExternal: false, - isOffline: false, duration: null, isVisible: true, livePhotoVideo: null, @@ -717,6 +677,7 @@ export const assetStub = { sidecarPath: '/original/path.ext.xmp', deletedAt: null, duplicateId: null, + isOffline: false, }), sidecarWithoutExt: Object.freeze({ id: 'asset-id', @@ -739,7 +700,6 @@ export const assetStub = { isFavorite: true, isArchived: false, isExternal: false, - isOffline: false, duration: null, isVisible: true, livePhotoVideo: null, @@ -751,41 +711,7 @@ export const assetStub = { sidecarPath: '/original/path.xmp', deletedAt: null, duplicateId: null, - }), - - readOnly: Object.freeze({ - id: 'read-only-asset', - status: AssetStatus.ACTIVE, - deviceAssetId: 'device-asset-id', - fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), - fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), - owner: userStub.user1, - ownerId: 'user-id', - deviceId: 'device-id', - originalPath: '/original/path.ext', - thumbhash: null, - checksum: Buffer.from('file hash', 'utf8'), - type: AssetType.IMAGE, - files: [previewFile], - encodedVideoPath: null, - createdAt: new Date('2023-02-23T05:06:29.716Z'), - updatedAt: new Date('2023-02-23T05:06:29.716Z'), - localDateTime: new Date('2023-02-23T05:06:29.716Z'), - isFavorite: true, - isArchived: false, - isExternal: false, isOffline: false, - duration: null, - isVisible: true, - livePhotoVideo: null, - livePhotoVideoId: null, - tags: [], - sharedLinks: [], - originalFileName: 'asset-id.ext', - faces: [], - sidecarPath: '/original/path.ext.xmp', - deletedAt: null, - duplicateId: null, }), hasEncodedVideo: Object.freeze({ @@ -810,7 +736,6 @@ export const assetStub = { isFavorite: true, isArchived: false, isExternal: false, - isOffline: false, duration: null, isVisible: true, livePhotoVideo: null, @@ -824,6 +749,7 @@ export const assetStub = { } as ExifEntity, deletedAt: null, duplicateId: null, + isOffline: false, }), missingFileExtension: Object.freeze({ id: 'asset-id', @@ -850,7 +776,6 @@ export const assetStub = { isVisible: true, livePhotoVideo: null, livePhotoVideoId: null, - isOffline: false, libraryId: 'library-id', library: libraryStub.externalLibrary1, tags: [], @@ -863,6 +788,7 @@ export const assetStub = { fileSizeInByte: 5000, } as ExifEntity, duplicateId: null, + isOffline: false, }), hasFileExtension: Object.freeze({ id: 'asset-id', @@ -889,7 +815,6 @@ export const assetStub = { isVisible: true, livePhotoVideo: null, livePhotoVideoId: null, - isOffline: false, libraryId: 'library-id', library: libraryStub.externalLibrary1, tags: [], @@ -902,6 +827,7 @@ export const assetStub = { fileSizeInByte: 5000, } as ExifEntity, duplicateId: null, + isOffline: false, }), imageDng: Object.freeze({ id: 'asset-id', @@ -928,7 +854,6 @@ export const assetStub = { isExternal: false, livePhotoVideo: null, livePhotoVideoId: null, - isOffline: false, tags: [], sharedLinks: [], originalFileName: 'asset-id.jpg', @@ -941,6 +866,7 @@ export const assetStub = { bitsPerSample: 14, } as ExifEntity, duplicateId: null, + isOffline: false, }), hasEmbedding: Object.freeze({ id: 'asset-id-embedding', @@ -967,7 +893,6 @@ export const assetStub = { isExternal: false, livePhotoVideo: null, livePhotoVideoId: null, - isOffline: false, tags: [], sharedLinks: [], originalFileName: 'asset-id.jpg', @@ -982,6 +907,7 @@ export const assetStub = { assetId: 'asset-id', embedding: Array.from({ length: 512 }, Math.random), }, + isOffline: false, }), hasDupe: Object.freeze({ id: 'asset-id-dupe', @@ -1008,7 +934,6 @@ export const assetStub = { isExternal: false, livePhotoVideo: null, livePhotoVideoId: null, - isOffline: false, tags: [], sharedLinks: [], originalFileName: 'asset-id.jpg', @@ -1023,5 +948,6 @@ export const assetStub = { assetId: 'asset-id', embedding: Array.from({ length: 512 }, Math.random), }, + isOffline: false, }), }; diff --git a/server/test/repositories/asset.repository.mock.ts b/server/test/repositories/asset.repository.mock.ts index 9ac568af306b8..ba2f5e10d98cc 100644 --- a/server/test/repositories/asset.repository.mock.ts +++ b/server/test/repositories/asset.repository.mock.ts @@ -25,7 +25,6 @@ export const newAssetRepositoryMock = (): Mocked => { getLivePhotoCount: vitest.fn(), updateAll: vitest.fn(), updateDuplicates: vitest.fn(), - getExternalLibraryAssetPaths: vitest.fn(), getByLibraryIdAndOriginalPath: vitest.fn(), deleteAll: vitest.fn(), update: vitest.fn(), diff --git a/server/vitest.config.mjs b/server/vitest.config.mjs index 3c0ea00c84c7a..1013b4606df3f 100644 --- a/server/vitest.config.mjs +++ b/server/vitest.config.mjs @@ -13,7 +13,7 @@ export default defineConfig({ lines: 80, statements: 80, branches: 85, - functions: 85, + functions: 80, }, }, server: { diff --git a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte index db216641d5c2f..d19b428750fda 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte @@ -59,7 +59,6 @@ export let onClose: () => void; const sharedLink = getSharedLink(); - $: isOwner = $user && asset.ownerId === $user?.id; $: showDownloadButton = sharedLink ? sharedLink.allowDownload : !asset.isOffline; // $: showEditorButton = @@ -87,7 +86,7 @@ {/if} {#if asset.isOffline} - + {/if} {#if asset.livePhotoVideoId} diff --git a/web/src/lib/components/asset-viewer/detail-panel.svelte b/web/src/lib/components/asset-viewer/detail-panel.svelte index 9e32927fc3605..88ea98778faca 100644 --- a/web/src/lib/components/asset-viewer/detail-panel.svelte +++ b/web/src/lib/components/asset-viewer/detail-panel.svelte @@ -148,12 +148,21 @@ {#if asset.isOffline}

    -
    {$t('asset_offline')}
    -
    +
    + {$t('asset_offline')} +
    +

    - {$t('asset_offline_description')} + {#if $user?.isAdmin} +

    {$t('admin.asset_offline_description')}

    + {:else} + {$t('asset_offline_description')} + {/if}

    +
    +

    {asset.originalPath}

    +
    {/if} diff --git a/web/src/lib/components/elements/buttons/circle-icon-button.svelte b/web/src/lib/components/elements/buttons/circle-icon-button.svelte index 76f962f107ce1..8af3f75ade296 100644 --- a/web/src/lib/components/elements/buttons/circle-icon-button.svelte +++ b/web/src/lib/components/elements/buttons/circle-icon-button.svelte @@ -1,7 +1,7 @@ @@ -508,6 +508,7 @@ onNextAsset={() => navigateAsset('next')} on:close={closeViewer} {sharedLink} + haveFadeTransition={$slideshowState === SlideshowState.None || $slideshowTransition} /> {/if} {:else} diff --git a/web/src/lib/components/slideshow-settings.svelte b/web/src/lib/components/slideshow-settings.svelte index e2bf6a4b2c22d..6f0397be98f19 100644 --- a/web/src/lib/components/slideshow-settings.svelte +++ b/web/src/lib/components/slideshow-settings.svelte @@ -18,7 +18,7 @@ import SettingDropdown from './shared-components/settings/setting-dropdown.svelte'; import { t } from 'svelte-i18n'; - const { slideshowDelay, showProgressBar, slideshowNavigation, slideshowLook } = slideshowStore; + const { slideshowDelay, showProgressBar, slideshowNavigation, slideshowLook, slideshowTransition } = slideshowStore; export let onClose = () => {}; @@ -65,6 +65,7 @@ }} /> + ('slideshow-show-progressbar', true); const slideshowDelay = persisted('slideshow-delay', 5, {}); + const slideshowTransition = persisted('slideshow-transition', true); return { restartProgress: { @@ -67,6 +68,7 @@ function createSlideshowStore() { slideshowState, slideshowDelay, showProgressBar, + slideshowTransition, }; } From 03aa34602040ff075cb144b97c19473442c3f0cb Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 27 Sep 2024 22:28:31 +0700 Subject: [PATCH 109/123] fix(mobile): incorrect filename is retrieved during upload (#12990) * fix(mobile): incorrect filename is retrieve during upload * use the same convention to get local id * revert previous change * pr feedback --- mobile/lib/interfaces/asset_media.interface.dart | 3 +++ mobile/lib/repositories/asset_media.repository.dart | 13 +++++++++++++ mobile/lib/services/background.service.dart | 3 +++ mobile/lib/services/backup.service.dart | 9 ++++++++- 4 files changed, 27 insertions(+), 1 deletion(-) diff --git a/mobile/lib/interfaces/asset_media.interface.dart b/mobile/lib/interfaces/asset_media.interface.dart index f89a238dd47ee..2606d5c23c518 100644 --- a/mobile/lib/interfaces/asset_media.interface.dart +++ b/mobile/lib/interfaces/asset_media.interface.dart @@ -4,4 +4,7 @@ abstract interface class IAssetMediaRepository { Future> deleteAll(List ids); Future get(String id); + + /// Obtaining the correct original filename of the asset + Future getOriginalFilename(String id); } diff --git a/mobile/lib/repositories/asset_media.repository.dart b/mobile/lib/repositories/asset_media.repository.dart index 20cf680339e53..68fffa08a6fcb 100644 --- a/mobile/lib/repositories/asset_media.repository.dart +++ b/mobile/lib/repositories/asset_media.repository.dart @@ -43,4 +43,17 @@ class AssetMediaRepository implements IAssetMediaRepository { asset.local = local; return asset; } + + @override + Future getOriginalFilename(String id) async { + final entity = await AssetEntity.fromId(id); + + if (entity == null) { + return null; + } + + // titleAsync gets the correct original filename for some assets on iOS + // otherwise using the `entity.title` would return a random GUID + return await entity.titleAsync; + } } diff --git a/mobile/lib/services/background.service.dart b/mobile/lib/services/background.service.dart index d06bc86d4871b..86dfd0c5998c5 100644 --- a/mobile/lib/services/background.service.dart +++ b/mobile/lib/services/background.service.dart @@ -15,6 +15,7 @@ import 'package:immich_mobile/models/backup/success_upload_asset.model.dart'; import 'package:immich_mobile/repositories/album.repository.dart'; import 'package:immich_mobile/repositories/album_api.repository.dart'; import 'package:immich_mobile/repositories/asset.repository.dart'; +import 'package:immich_mobile/repositories/asset_media.repository.dart'; import 'package:immich_mobile/repositories/backup.repository.dart'; import 'package:immich_mobile/repositories/album_media.repository.dart'; import 'package:immich_mobile/repositories/file_media.repository.dart'; @@ -368,6 +369,7 @@ class BackgroundService { BackupRepository backupAlbumRepository = BackupRepository(db); AlbumMediaRepository albumMediaRepository = AlbumMediaRepository(); FileMediaRepository fileMediaRepository = FileMediaRepository(); + AssetMediaRepository assetMediaRepository = AssetMediaRepository(); UserRepository userRepository = UserRepository(db); UserApiRepository userApiRepository = UserApiRepository(apiService.usersApi); @@ -409,6 +411,7 @@ class BackgroundService { albumService, albumMediaRepository, fileMediaRepository, + assetMediaRepository, ); final selectedAlbums = backupService.selectedAlbumsQuery().findAllSync(); diff --git a/mobile/lib/services/backup.service.dart b/mobile/lib/services/backup.service.dart index 19d731d773d75..683339f271ed2 100644 --- a/mobile/lib/services/backup.service.dart +++ b/mobile/lib/services/backup.service.dart @@ -12,6 +12,7 @@ import 'package:immich_mobile/entities/backup_album.entity.dart'; import 'package:immich_mobile/entities/duplicated_asset.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/interfaces/album_media.interface.dart'; +import 'package:immich_mobile/interfaces/asset_media.interface.dart'; import 'package:immich_mobile/interfaces/file_media.interface.dart'; import 'package:immich_mobile/models/backup/backup_candidate.model.dart'; import 'package:immich_mobile/models/backup/current_upload_asset.model.dart'; @@ -21,6 +22,7 @@ import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/db.provider.dart'; import 'package:immich_mobile/repositories/album_media.repository.dart'; +import 'package:immich_mobile/repositories/asset_media.repository.dart'; import 'package:immich_mobile/repositories/file_media.repository.dart'; import 'package:immich_mobile/services/album.service.dart'; import 'package:immich_mobile/services/api.service.dart'; @@ -40,6 +42,7 @@ final backupServiceProvider = Provider( ref.watch(albumServiceProvider), ref.watch(albumMediaRepositoryProvider), ref.watch(fileMediaRepositoryProvider), + ref.watch(assetMediaRepositoryProvider), ), ); @@ -52,6 +55,7 @@ class BackupService { final AlbumService _albumService; final IAlbumMediaRepository _albumMediaRepository; final IFileMediaRepository _fileMediaRepository; + final IAssetMediaRepository _assetMediaRepository; BackupService( this._apiService, @@ -60,6 +64,7 @@ class BackupService { this._albumService, this._albumMediaRepository, this._fileMediaRepository, + this._assetMediaRepository, ); Future?> getDeviceBackupAsset() async { @@ -329,7 +334,9 @@ class BackupService { } if (file != null) { - String originalFileName = asset.fileName; + String? originalFileName = + await _assetMediaRepository.getOriginalFilename(asset.localId!); + originalFileName ??= asset.fileName; if (asset.local!.isLivePhoto) { if (livePhotoFile == null) { From 7c15e11efccc1f6f4d7c1da12e932ae5fc058838 Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 27 Sep 2024 15:32:16 +0000 Subject: [PATCH 110/123] chore: version v1.116.1 --- cli/package-lock.json | 6 +++--- cli/package.json | 2 +- docs/static/archived-versions.json | 4 ++++ e2e/package-lock.json | 8 ++++---- e2e/package.json | 2 +- machine-learning/pyproject.toml | 2 +- mobile/android/fastlane/Fastfile | 4 ++-- mobile/ios/fastlane/Fastfile | 2 +- mobile/openapi/README.md | 2 +- mobile/pubspec.yaml | 2 +- open-api/immich-openapi-specs.json | 2 +- open-api/typescript-sdk/package-lock.json | 4 ++-- open-api/typescript-sdk/package.json | 2 +- open-api/typescript-sdk/src/fetch-client.ts | 2 +- server/package-lock.json | 4 ++-- server/package.json | 2 +- web/package-lock.json | 6 +++--- web/package.json | 2 +- 18 files changed, 31 insertions(+), 27 deletions(-) diff --git a/cli/package-lock.json b/cli/package-lock.json index c66d663576e6c..73f0e405ba2b2 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -1,12 +1,12 @@ { "name": "@immich/cli", - "version": "2.2.20", + "version": "2.2.21", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@immich/cli", - "version": "2.2.20", + "version": "2.2.21", "license": "GNU Affero General Public License version 3", "dependencies": { "fast-glob": "^3.3.2", @@ -52,7 +52,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.116.0", + "version": "1.116.1", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { diff --git a/cli/package.json b/cli/package.json index ba2f8468226d4..f28bbe130f39c 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@immich/cli", - "version": "2.2.20", + "version": "2.2.21", "description": "Command Line Interface (CLI) for Immich", "type": "module", "exports": "./dist/index.js", diff --git a/docs/static/archived-versions.json b/docs/static/archived-versions.json index 992aaa6d4b5d2..9fc474c729490 100644 --- a/docs/static/archived-versions.json +++ b/docs/static/archived-versions.json @@ -1,4 +1,8 @@ [ + { + "label": "v1.116.1", + "url": "https://v1.116.1.archive.immich.app" + }, { "label": "v1.116.0", "url": "https://v1.116.0.archive.immich.app" diff --git a/e2e/package-lock.json b/e2e/package-lock.json index 63ad7be469cf7..b451e5dacf434 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich-e2e", - "version": "1.116.0", + "version": "1.116.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich-e2e", - "version": "1.116.0", + "version": "1.116.1", "license": "GNU Affero General Public License version 3", "devDependencies": { "@eslint/eslintrc": "^3.1.0", @@ -45,7 +45,7 @@ }, "../cli": { "name": "@immich/cli", - "version": "2.2.20", + "version": "2.2.21", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { @@ -92,7 +92,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.116.0", + "version": "1.116.1", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { diff --git a/e2e/package.json b/e2e/package.json index 80bf261a03512..38d671d9d5674 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -1,6 +1,6 @@ { "name": "immich-e2e", - "version": "1.116.0", + "version": "1.116.1", "description": "", "main": "index.js", "type": "module", diff --git a/machine-learning/pyproject.toml b/machine-learning/pyproject.toml index 8d1539a79b07d..1f953b882745f 100644 --- a/machine-learning/pyproject.toml +++ b/machine-learning/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "machine-learning" -version = "1.116.0" +version = "1.116.1" description = "" authors = ["Hau Tran "] readme = "README.md" diff --git a/mobile/android/fastlane/Fastfile b/mobile/android/fastlane/Fastfile index 6a6454bfe986b..43d643d2f6abd 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" => 160, - "android.injected.version.name" => "1.116.0", + "android.injected.version.code" => 161, + "android.injected.version.name" => "1.116.1", } ) 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/ios/fastlane/Fastfile b/mobile/ios/fastlane/Fastfile index 1cc5524c40093..a9382cb9690bc 100644 --- a/mobile/ios/fastlane/Fastfile +++ b/mobile/ios/fastlane/Fastfile @@ -19,7 +19,7 @@ platform :ios do desc "iOS Release" lane :release do increment_version_number( - version_number: "1.116.0" + version_number: "1.116.1" ) increment_build_number( build_number: latest_testflight_build_number + 1, diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 9f2261e03d8e6..e5280e31394e1 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.116.0 +- API version: 1.116.1 - Generator version: 7.8.0 - Build package: org.openapitools.codegen.languages.DartClientCodegen diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index a219b6ddb1575..ac8294a0a6580 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.116.0+160 +version: 1.116.1+161 environment: sdk: '>=3.3.0 <4.0.0' diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index bb0aa83009863..b2682dd95a0eb 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -7409,7 +7409,7 @@ "info": { "title": "Immich", "description": "Immich API", - "version": "1.116.0", + "version": "1.116.1", "contact": {} }, "tags": [], diff --git a/open-api/typescript-sdk/package-lock.json b/open-api/typescript-sdk/package-lock.json index 3ab9ac0583a35..95bbddc50709f 100644 --- a/open-api/typescript-sdk/package-lock.json +++ b/open-api/typescript-sdk/package-lock.json @@ -1,12 +1,12 @@ { "name": "@immich/sdk", - "version": "1.116.0", + "version": "1.116.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@immich/sdk", - "version": "1.116.0", + "version": "1.116.1", "license": "GNU Affero General Public License version 3", "dependencies": { "@oazapfts/runtime": "^1.0.2" diff --git a/open-api/typescript-sdk/package.json b/open-api/typescript-sdk/package.json index 45a1fada32eb1..3226f63b19303 100644 --- a/open-api/typescript-sdk/package.json +++ b/open-api/typescript-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@immich/sdk", - "version": "1.116.0", + "version": "1.116.1", "description": "Auto-generated TypeScript SDK for the Immich API", "type": "module", "main": "./build/index.js", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 63597d49bc6c1..bf2721f848dbc 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -1,6 +1,6 @@ /** * Immich - * 1.116.0 + * 1.116.1 * DO NOT MODIFY - This file has been generated using oazapfts. * See https://www.npmjs.com/package/oazapfts */ diff --git a/server/package-lock.json b/server/package-lock.json index 57c8dd7146732..53c34aeb32d06 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich", - "version": "1.116.0", + "version": "1.116.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "immich", - "version": "1.116.0", + "version": "1.116.1", "license": "GNU Affero General Public License version 3", "dependencies": { "@nestjs/bullmq": "^10.0.1", diff --git a/server/package.json b/server/package.json index 8ba20f6b3bc23..3817bd5d01168 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "immich", - "version": "1.116.0", + "version": "1.116.1", "description": "", "author": "", "private": true, diff --git a/web/package-lock.json b/web/package-lock.json index 172c315570d43..6a6baca4c277b 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich-web", - "version": "1.116.0", + "version": "1.116.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich-web", - "version": "1.116.0", + "version": "1.116.1", "license": "GNU Affero General Public License version 3", "dependencies": { "@formatjs/icu-messageformat-parser": "^2.7.8", @@ -74,7 +74,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.116.0", + "version": "1.116.1", "license": "GNU Affero General Public License version 3", "dependencies": { "@oazapfts/runtime": "^1.0.2" diff --git a/web/package.json b/web/package.json index 938b4dc9cf008..9b8d356840c5c 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "immich-web", - "version": "1.116.0", + "version": "1.116.1", "license": "GNU Affero General Public License version 3", "scripts": { "dev": "vite dev --host 0.0.0.0 --port 3000", From dbe542803f6e05b3cc878797677c18bc9739cae6 Mon Sep 17 00:00:00 2001 From: bo0tzz Date: Fri, 27 Sep 2024 19:07:00 +0200 Subject: [PATCH 111/123] docs: update FAQ CLIP search explanation (#12986) --- docs/docs/FAQ.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/FAQ.mdx b/docs/docs/FAQ.mdx index 3144b1b9a8456..b328d3a047099 100644 --- a/docs/docs/FAQ.mdx +++ b/docs/docs/FAQ.mdx @@ -187,7 +187,7 @@ However, when the trash is emptied, the files will re-appear in the main timelin ### How does smart search work? -Immich uses CLIP models. For more information about CLIP and its capabilities, read about it [here](https://openai.com/research/clip). +Immich uses CLIP models. An ML model converts each image to an "embedding", which is essentially a string of numbers that semantically encodes what is in the image. The same is done for the text that you enter when you do a search, and that text embedding is then compared with those of the images to find similar ones. As such, there are no "tags", "labels", or "descriptions" generated that you can look at. For more information about CLIP and its capabilities, read about it [here](https://openai.com/research/clip). ### How does facial recognition work? From 789937d4a2409601c35120dff9e454fd735c6a76 Mon Sep 17 00:00:00 2001 From: Zack Pollard Date: Fri, 27 Sep 2024 18:15:44 +0100 Subject: [PATCH 112/123] fix: library pagination to 10k to avoid too many postgres query params (#12993) --- server/src/interfaces/job.interface.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/interfaces/job.interface.ts b/server/src/interfaces/job.interface.ts index 8b6e2c289bd28..af2726b858aee 100644 --- a/server/src/interfaces/job.interface.ts +++ b/server/src/interfaces/job.interface.ts @@ -116,7 +116,7 @@ export enum JobName { } export const JOBS_ASSET_PAGINATION_SIZE = 1000; -export const JOBS_LIBRARY_PAGINATION_SIZE = 100_000; +export const JOBS_LIBRARY_PAGINATION_SIZE = 10_000; export interface IBaseJob { force?: boolean; From 4ed1517e6032839b0bbc062a93b19fbb34e4758e Mon Sep 17 00:00:00 2001 From: Alex Date: Sat, 28 Sep 2024 01:13:24 +0700 Subject: [PATCH 113/123] chore(mobile): post release task (#12991) --- mobile/ios/Runner.xcodeproj/project.pbxproj | 6 +++--- mobile/ios/Runner/Info.plist | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index 241cb8ecd99df..70bddbf10b997 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -401,7 +401,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 176; + CURRENT_PROJECT_VERSION = 177; 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 = 176; + CURRENT_PROJECT_VERSION = 177; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -571,7 +571,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 176; + CURRENT_PROJECT_VERSION = 177; 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 14fc27b56dd61..b684804037010 100644 --- a/mobile/ios/Runner/Info.plist +++ b/mobile/ios/Runner/Info.plist @@ -58,11 +58,11 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.116.0 + 1.116.1 CFBundleSignature ???? CFBundleVersion - 176 + 177 FLTEnableImpeller ITSAppUsesNonExemptEncryption From 8bbcd5c31e4a227f92864ae2977c4033bc0c50b7 Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 27 Sep 2024 18:17:49 +0000 Subject: [PATCH 114/123] chore: version v1.116.2 --- cli/package-lock.json | 6 +++--- cli/package.json | 2 +- docs/static/archived-versions.json | 4 ++++ e2e/package-lock.json | 8 ++++---- e2e/package.json | 2 +- machine-learning/pyproject.toml | 2 +- mobile/android/fastlane/Fastfile | 2 +- mobile/ios/fastlane/Fastfile | 2 +- mobile/openapi/README.md | 2 +- mobile/pubspec.yaml | 2 +- open-api/immich-openapi-specs.json | 2 +- open-api/typescript-sdk/package-lock.json | 4 ++-- open-api/typescript-sdk/package.json | 2 +- open-api/typescript-sdk/src/fetch-client.ts | 2 +- server/package-lock.json | 4 ++-- server/package.json | 2 +- web/package-lock.json | 6 +++--- web/package.json | 2 +- 18 files changed, 30 insertions(+), 26 deletions(-) diff --git a/cli/package-lock.json b/cli/package-lock.json index 73f0e405ba2b2..e508fe843f60d 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -1,12 +1,12 @@ { "name": "@immich/cli", - "version": "2.2.21", + "version": "2.2.22", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@immich/cli", - "version": "2.2.21", + "version": "2.2.22", "license": "GNU Affero General Public License version 3", "dependencies": { "fast-glob": "^3.3.2", @@ -52,7 +52,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.116.1", + "version": "1.116.2", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { diff --git a/cli/package.json b/cli/package.json index f28bbe130f39c..522a8e593e9e7 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@immich/cli", - "version": "2.2.21", + "version": "2.2.22", "description": "Command Line Interface (CLI) for Immich", "type": "module", "exports": "./dist/index.js", diff --git a/docs/static/archived-versions.json b/docs/static/archived-versions.json index 9fc474c729490..36a8fed81df1e 100644 --- a/docs/static/archived-versions.json +++ b/docs/static/archived-versions.json @@ -1,4 +1,8 @@ [ + { + "label": "v1.116.2", + "url": "https://v1.116.2.archive.immich.app" + }, { "label": "v1.116.1", "url": "https://v1.116.1.archive.immich.app" diff --git a/e2e/package-lock.json b/e2e/package-lock.json index b451e5dacf434..e7b463b0b2696 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich-e2e", - "version": "1.116.1", + "version": "1.116.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich-e2e", - "version": "1.116.1", + "version": "1.116.2", "license": "GNU Affero General Public License version 3", "devDependencies": { "@eslint/eslintrc": "^3.1.0", @@ -45,7 +45,7 @@ }, "../cli": { "name": "@immich/cli", - "version": "2.2.21", + "version": "2.2.22", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { @@ -92,7 +92,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.116.1", + "version": "1.116.2", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { diff --git a/e2e/package.json b/e2e/package.json index 38d671d9d5674..7c0025902dd3a 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -1,6 +1,6 @@ { "name": "immich-e2e", - "version": "1.116.1", + "version": "1.116.2", "description": "", "main": "index.js", "type": "module", diff --git a/machine-learning/pyproject.toml b/machine-learning/pyproject.toml index 1f953b882745f..840aa93c06453 100644 --- a/machine-learning/pyproject.toml +++ b/machine-learning/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "machine-learning" -version = "1.116.1" +version = "1.116.2" description = "" authors = ["Hau Tran "] readme = "README.md" diff --git a/mobile/android/fastlane/Fastfile b/mobile/android/fastlane/Fastfile index 43d643d2f6abd..d1f09a011f4fe 100644 --- a/mobile/android/fastlane/Fastfile +++ b/mobile/android/fastlane/Fastfile @@ -36,7 +36,7 @@ platform :android do build_type: 'Release', properties: { "android.injected.version.code" => 161, - "android.injected.version.name" => "1.116.1", + "android.injected.version.name" => "1.116.2", } ) 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/ios/fastlane/Fastfile b/mobile/ios/fastlane/Fastfile index a9382cb9690bc..8dc3676fb787a 100644 --- a/mobile/ios/fastlane/Fastfile +++ b/mobile/ios/fastlane/Fastfile @@ -19,7 +19,7 @@ platform :ios do desc "iOS Release" lane :release do increment_version_number( - version_number: "1.116.1" + version_number: "1.116.2" ) increment_build_number( build_number: latest_testflight_build_number + 1, diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index e5280e31394e1..fecbbf482be54 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.116.1 +- API version: 1.116.2 - Generator version: 7.8.0 - Build package: org.openapitools.codegen.languages.DartClientCodegen diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index ac8294a0a6580..dc1eb11ca7f24 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.116.1+161 +version: 1.116.2+161 environment: sdk: '>=3.3.0 <4.0.0' diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index b2682dd95a0eb..6afd0d792ff34 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -7409,7 +7409,7 @@ "info": { "title": "Immich", "description": "Immich API", - "version": "1.116.1", + "version": "1.116.2", "contact": {} }, "tags": [], diff --git a/open-api/typescript-sdk/package-lock.json b/open-api/typescript-sdk/package-lock.json index 95bbddc50709f..72d7a3ec546d8 100644 --- a/open-api/typescript-sdk/package-lock.json +++ b/open-api/typescript-sdk/package-lock.json @@ -1,12 +1,12 @@ { "name": "@immich/sdk", - "version": "1.116.1", + "version": "1.116.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@immich/sdk", - "version": "1.116.1", + "version": "1.116.2", "license": "GNU Affero General Public License version 3", "dependencies": { "@oazapfts/runtime": "^1.0.2" diff --git a/open-api/typescript-sdk/package.json b/open-api/typescript-sdk/package.json index 3226f63b19303..41bc3a3b16017 100644 --- a/open-api/typescript-sdk/package.json +++ b/open-api/typescript-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@immich/sdk", - "version": "1.116.1", + "version": "1.116.2", "description": "Auto-generated TypeScript SDK for the Immich API", "type": "module", "main": "./build/index.js", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index bf2721f848dbc..b1ae5d28764f2 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -1,6 +1,6 @@ /** * Immich - * 1.116.1 + * 1.116.2 * DO NOT MODIFY - This file has been generated using oazapfts. * See https://www.npmjs.com/package/oazapfts */ diff --git a/server/package-lock.json b/server/package-lock.json index 53c34aeb32d06..646a26b1ee9d0 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich", - "version": "1.116.1", + "version": "1.116.2", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "immich", - "version": "1.116.1", + "version": "1.116.2", "license": "GNU Affero General Public License version 3", "dependencies": { "@nestjs/bullmq": "^10.0.1", diff --git a/server/package.json b/server/package.json index 3817bd5d01168..d4816109069e9 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "immich", - "version": "1.116.1", + "version": "1.116.2", "description": "", "author": "", "private": true, diff --git a/web/package-lock.json b/web/package-lock.json index 6a6baca4c277b..a32e96e67f78b 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich-web", - "version": "1.116.1", + "version": "1.116.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich-web", - "version": "1.116.1", + "version": "1.116.2", "license": "GNU Affero General Public License version 3", "dependencies": { "@formatjs/icu-messageformat-parser": "^2.7.8", @@ -74,7 +74,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.116.1", + "version": "1.116.2", "license": "GNU Affero General Public License version 3", "dependencies": { "@oazapfts/runtime": "^1.0.2" diff --git a/web/package.json b/web/package.json index 9b8d356840c5c..20553759fad43 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "immich-web", - "version": "1.116.1", + "version": "1.116.2", "license": "GNU Affero General Public License version 3", "scripts": { "dev": "vite dev --host 0.0.0.0 --port 3000", From 7579bc43591dd72bb84b8426786f7834e76e2844 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 27 Sep 2024 22:07:59 +0000 Subject: [PATCH 115/123] fix(deps): update machine-learning (#12883) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- machine-learning/Dockerfile | 4 +- machine-learning/export/Dockerfile | 2 +- machine-learning/poetry.lock | 67 ++++++++++++++---------------- 3 files changed, 35 insertions(+), 38 deletions(-) diff --git a/machine-learning/Dockerfile b/machine-learning/Dockerfile index e394091ae13f8..d982962fbcdc6 100644 --- a/machine-learning/Dockerfile +++ b/machine-learning/Dockerfile @@ -1,6 +1,6 @@ ARG DEVICE=cpu -FROM python:3.11-bookworm@sha256:157a371e60389919fe4a72dff71ce86eaa5234f59114c23b0b346d0d02c74d39 AS builder-cpu +FROM python:3.11-bookworm@sha256:e456ff58048f52f121025159e68bf16248c4122c8b96fadffd89331df50c9994 AS builder-cpu FROM builder-cpu AS builder-openvino @@ -34,7 +34,7 @@ RUN python3 -m venv /opt/venv COPY poetry.lock pyproject.toml ./ RUN poetry install --sync --no-interaction --no-ansi --no-root --with ${DEVICE} --without dev -FROM python:3.11-slim-bookworm@sha256:669bbd08353610485a94d5d0c976b4b6498c55280fe42c00f7581f85ee9f3121 AS prod-cpu +FROM python:3.11-slim-bookworm@sha256:585cf0799407efc267fe1cce318322ec26e015ac1b3d77f2517d50bc3acfc232 AS prod-cpu FROM prod-cpu AS prod-openvino diff --git a/machine-learning/export/Dockerfile b/machine-learning/export/Dockerfile index 0754f882f3ae0..195e64ab35ad6 100644 --- a/machine-learning/export/Dockerfile +++ b/machine-learning/export/Dockerfile @@ -1,4 +1,4 @@ -FROM mambaorg/micromamba:bookworm-slim@sha256:5f32c5742e2248f2ca07ccae6861371321aba37372bf8e1a80d6f728f1ab4418 AS builder +FROM mambaorg/micromamba:bookworm-slim@sha256:e3797091302382ea841498bc93a7b0a50f7c1448333d5e946d2d1608d0c5f43d AS builder ENV TRANSFORMERS_CACHE=/cache \ PYTHONDONTWRITEBYTECODE=1 \ diff --git a/machine-learning/poetry.lock b/machine-learning/poetry.lock index 84c9ae5d31151..5bb1726378050 100644 --- a/machine-learning/poetry.lock +++ b/machine-learning/poetry.lock @@ -680,13 +680,13 @@ test = ["pytest (>=6)"] [[package]] name = "fastapi-slim" -version = "0.114.2" +version = "0.115.0" description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" optional = false python-versions = ">=3.8" files = [ - {file = "fastapi_slim-0.114.2-py3-none-any.whl", hash = "sha256:52ae76c53a30ad0fa96beb84c1bf4bef9c72e88c2f7c0473e836f01d7ac3ca6b"}, - {file = "fastapi_slim-0.114.2.tar.gz", hash = "sha256:76d0a450826fb0fa740268be55ef04c44807da87a94fbbf5f16338b5a4a2d321"}, + {file = "fastapi_slim-0.115.0-py3-none-any.whl", hash = "sha256:27ab44da95b622e68be7a19f06df1960a320b9d94e689b0adfc055bb26ee9be7"}, + {file = "fastapi_slim-0.115.0.tar.gz", hash = "sha256:b4b962ca2aa0a31010dafdad3d4da99d368a5591223304c6fb385712fad7feb6"}, ] [package.dependencies] @@ -2037,22 +2037,22 @@ reference = "cuda12" [[package]] name = "onnxruntime-openvino" -version = "1.18.0" +version = "1.19.0" description = "ONNX Runtime is a runtime accelerator for Machine Learning models" optional = false python-versions = "*" files = [ - {file = "onnxruntime_openvino-1.18.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:565b874d21bcd48126da7d62f57db019f5ec0e1f82ae9b0740afa2ad91f8d331"}, - {file = "onnxruntime_openvino-1.18.0-cp310-cp310-win_amd64.whl", hash = "sha256:7f1931060f710a6c8e32121bb73044c4772ef5925802fc8776d3fe1e87ab3f75"}, - {file = "onnxruntime_openvino-1.18.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:eb1723d386f70a8e26398d983ebe35d2c25ba56e9cdb382670ebbf1f5139f8ba"}, - {file = "onnxruntime_openvino-1.18.0-cp311-cp311-win_amd64.whl", hash = "sha256:874a1e263dd86674593e5a879257650b06a8609c4d5768c3d8ed8dc4ae874b9c"}, - {file = "onnxruntime_openvino-1.18.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:597eb18f3de7ead69b08a242d74c4573b28bbfba40ca2a1a40f75bf7a834808e"}, + {file = "onnxruntime_openvino-1.19.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:8c5658da819b26d9f35f95204e1bdfb74a100a7533e74edab3af6316c1e316e8"}, + {file = "onnxruntime_openvino-1.19.0-cp310-cp310-win_amd64.whl", hash = "sha256:fb8de2a60cf78db6e201b0a489479995d166938e9c53b01ff342dc7f5f8251ff"}, + {file = "onnxruntime_openvino-1.19.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:f3a0b954026286421b3a769c746c403e8f141f3887d1dd601beb7c4dbf77488a"}, + {file = "onnxruntime_openvino-1.19.0-cp311-cp311-win_amd64.whl", hash = "sha256:12330922ecdb694ea28dbdcf08c172e47a5a84fee603040691341336ee3e42bc"}, + {file = "onnxruntime_openvino-1.19.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:be00502b1a46ba1891cbe49049033745f71c0b99df6d24b979f5b4084b9567d0"}, ] [package.dependencies] coloredlogs = "*" flatbuffers = "*" -numpy = ">=1.26.4" +numpy = ">=1.21.6" packaging = "*" protobuf = "*" sympy = "*" @@ -2576,18 +2576,15 @@ cli = ["click (>=5.0)"] [[package]] name = "python-multipart" -version = "0.0.9" +version = "0.0.10" description = "A streaming multipart parser for Python" optional = false python-versions = ">=3.8" files = [ - {file = "python_multipart-0.0.9-py3-none-any.whl", hash = "sha256:97ca7b8ea7b05f977dc3849c3ba99d51689822fab725c3703af7c866a0c2b215"}, - {file = "python_multipart-0.0.9.tar.gz", hash = "sha256:03f54688c663f1b7977105f021043b0793151e4cb1c1a9d4a11fc13d622c4026"}, + {file = "python_multipart-0.0.10-py3-none-any.whl", hash = "sha256:2b06ad9e8d50c7a8db80e3b56dab590137b323410605af2be20d62a5f1ba1dc8"}, + {file = "python_multipart-0.0.10.tar.gz", hash = "sha256:46eb3c6ce6fdda5fb1a03c7e11d490e407c6930a2703fe7aef4da71c374688fa"}, ] -[package.extras] -dev = ["atomicwrites (==1.4.1)", "attrs (==23.2.0)", "coverage (==7.4.1)", "hatch", "invoke (==2.2.0)", "more-itertools (==10.2.0)", "pbr (==6.0.0)", "pluggy (==1.4.0)", "py (==1.11.0)", "pytest (==8.0.0)", "pytest-cov (==4.1.0)", "pytest-timeout (==2.2.0)", "pyyaml (==6.0.1)", "ruff (==0.2.1)"] - [[package]] name = "pywin32" version = "306" @@ -2834,29 +2831,29 @@ jupyter = ["ipywidgets (>=7.5.1,<9)"] [[package]] name = "ruff" -version = "0.6.6" +version = "0.6.8" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" files = [ - {file = "ruff-0.6.6-py3-none-linux_armv6l.whl", hash = "sha256:f5bc5398457484fc0374425b43b030e4668ed4d2da8ee7fdda0e926c9f11ccfb"}, - {file = "ruff-0.6.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:515a698254c9c47bb84335281a170213b3ee5eb47feebe903e1be10087a167ce"}, - {file = "ruff-0.6.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6bb1b4995775f1837ab70f26698dd73852bbb82e8f70b175d2713c0354fe9182"}, - {file = "ruff-0.6.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69c546f412dfae8bb9cc4f27f0e45cdd554e42fecbb34f03312b93368e1cd0a6"}, - {file = "ruff-0.6.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:59627e97364329e4eae7d86fa7980c10e2b129e2293d25c478ebcb861b3e3fd6"}, - {file = "ruff-0.6.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:94c3f78c3d32190aafbb6bc5410c96cfed0a88aadb49c3f852bbc2aa9783a7d8"}, - {file = "ruff-0.6.6-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:704da526c1e137f38c8a067a4a975fe6834b9f8ba7dbc5fd7503d58148851b8f"}, - {file = "ruff-0.6.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:efeede5815a24104579a0f6320660536c5ffc1c91ae94f8c65659af915fb9de9"}, - {file = "ruff-0.6.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e368aef0cc02ca3593eae2fb8186b81c9c2b3f39acaaa1108eb6b4d04617e61f"}, - {file = "ruff-0.6.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2653fc3b2a9315bd809725c88dd2446550099728d077a04191febb5ea79a4f79"}, - {file = "ruff-0.6.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:bb858cd9ce2d062503337c5b9784d7b583bcf9d1a43c4df6ccb5eab774fbafcb"}, - {file = "ruff-0.6.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:488f8e15c01ea9afb8c0ba35d55bd951f484d0c1b7c5fd746ce3c47ccdedce68"}, - {file = "ruff-0.6.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:aefb0bd15f1cfa4c9c227b6120573bb3d6c4ee3b29fb54a5ad58f03859bc43c6"}, - {file = "ruff-0.6.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a4c0698cc780bcb2c61496cbd56b6a3ac0ad858c966652f7dbf4ceb029252fbe"}, - {file = "ruff-0.6.6-py3-none-win32.whl", hash = "sha256:aadf81ddc8ab5b62da7aae78a91ec933cbae9f8f1663ec0325dae2c364e4ad84"}, - {file = "ruff-0.6.6-py3-none-win_amd64.whl", hash = "sha256:0adb801771bc1f1b8cf4e0a6fdc30776e7c1894810ff3b344e50da82ef50eeb1"}, - {file = "ruff-0.6.6-py3-none-win_arm64.whl", hash = "sha256:4b4d32c137bc781c298964dd4e52f07d6f7d57c03eae97a72d97856844aa510a"}, - {file = "ruff-0.6.6.tar.gz", hash = "sha256:0fc030b6fd14814d69ac0196396f6761921bd20831725c7361e1b8100b818034"}, + {file = "ruff-0.6.8-py3-none-linux_armv6l.whl", hash = "sha256:77944bca110ff0a43b768f05a529fecd0706aac7bcce36d7f1eeb4cbfca5f0f2"}, + {file = "ruff-0.6.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:27b87e1801e786cd6ede4ada3faa5e254ce774de835e6723fd94551464c56b8c"}, + {file = "ruff-0.6.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:cd48f945da2a6334f1793d7f701725a76ba93bf3d73c36f6b21fb04d5338dcf5"}, + {file = "ruff-0.6.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:677e03c00f37c66cea033274295a983c7c546edea5043d0c798833adf4cf4c6f"}, + {file = "ruff-0.6.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9f1476236b3eacfacfc0f66aa9e6cd39f2a624cb73ea99189556015f27c0bdeb"}, + {file = "ruff-0.6.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6f5a2f17c7d32991169195d52a04c95b256378bbf0de8cb98478351eb70d526f"}, + {file = "ruff-0.6.8-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:5fd0d4b7b1457c49e435ee1e437900ced9b35cb8dc5178921dfb7d98d65a08d0"}, + {file = "ruff-0.6.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8034b19b993e9601f2ddf2c517451e17a6ab5cdb1c13fdff50c1442a7171d87"}, + {file = "ruff-0.6.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6cfb227b932ba8ef6e56c9f875d987973cd5e35bc5d05f5abf045af78ad8e098"}, + {file = "ruff-0.6.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6ef0411eccfc3909269fed47c61ffebdcb84a04504bafa6b6df9b85c27e813b0"}, + {file = "ruff-0.6.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:007dee844738c3d2e6c24ab5bc7d43c99ba3e1943bd2d95d598582e9c1b27750"}, + {file = "ruff-0.6.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:ce60058d3cdd8490e5e5471ef086b3f1e90ab872b548814e35930e21d848c9ce"}, + {file = "ruff-0.6.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1085c455d1b3fdb8021ad534379c60353b81ba079712bce7a900e834859182fa"}, + {file = "ruff-0.6.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:70edf6a93b19481affd287d696d9e311388d808671bc209fb8907b46a8c3af44"}, + {file = "ruff-0.6.8-py3-none-win32.whl", hash = "sha256:792213f7be25316f9b46b854df80a77e0da87ec66691e8f012f887b4a671ab5a"}, + {file = "ruff-0.6.8-py3-none-win_amd64.whl", hash = "sha256:ec0517dc0f37cad14a5319ba7bba6e7e339d03fbf967a6d69b0907d61be7a263"}, + {file = "ruff-0.6.8-py3-none-win_arm64.whl", hash = "sha256:8d3bb2e3fbb9875172119021a13eed38849e762499e3cfde9588e4b4d70968dc"}, + {file = "ruff-0.6.8.tar.gz", hash = "sha256:a5bf44b1aa0adaf6d9d20f86162b34f7c593bfedabc51239953e446aefc8ce18"}, ] [[package]] From 4248594ac55c2adfcb84918c69ae29d351ca19b3 Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Fri, 27 Sep 2024 18:10:39 -0400 Subject: [PATCH 116/123] feat(server): better transcoding logs (#13000) * better transcoding logs * pr feedback --- server/src/interfaces/logger.interface.ts | 1 + server/src/interfaces/media.interface.ts | 10 +- server/src/repositories/media.repository.ts | 62 ++- server/src/services/media.service.spec.ts | 405 ++++++++++-------- server/src/services/media.service.ts | 37 +- server/src/utils/media.ts | 1 + .../repositories/logger.repository.mock.ts | 2 +- 7 files changed, 308 insertions(+), 210 deletions(-) diff --git a/server/src/interfaces/logger.interface.ts b/server/src/interfaces/logger.interface.ts index ce9a8e64fe27f..42523afa6b513 100644 --- a/server/src/interfaces/logger.interface.ts +++ b/server/src/interfaces/logger.interface.ts @@ -6,6 +6,7 @@ export interface ILoggerRepository { setAppName(name: string): void; setContext(message: string): void; setLogLevel(level: LogLevel): void; + isLevelEnabled(level: LogLevel): boolean; verbose(message: any, ...args: any): void; debug(message: any, ...args: any): void; diff --git a/server/src/interfaces/media.interface.ts b/server/src/interfaces/media.interface.ts index 459e33fc3669b..7193684e7acc1 100644 --- a/server/src/interfaces/media.interface.ts +++ b/server/src/interfaces/media.interface.ts @@ -62,6 +62,10 @@ export interface TranscodeCommand { inputOptions: string[]; outputOptions: string[]; twoPass: boolean; + progress: { + frameCount: number; + percentInterval: number; + }; } export interface BitrateDistribution { @@ -79,6 +83,10 @@ export interface VideoCodecHWConfig extends VideoCodecSWConfig { getSupportedCodecs(): Array; } +export interface ProbeOptions { + countFrames: boolean; +} + export interface IMediaRepository { // image extract(input: string, output: string): Promise; @@ -87,6 +95,6 @@ export interface IMediaRepository { getImageDimensions(input: string): Promise; // video - probe(input: string): Promise; + probe(input: string, options?: ProbeOptions): Promise; transcode(input: string, output: string | Writable, command: TranscodeCommand): Promise; } diff --git a/server/src/repositories/media.repository.ts b/server/src/repositories/media.repository.ts index 5d1aced5eba65..d001aa3158b0f 100644 --- a/server/src/repositories/media.repository.ts +++ b/server/src/repositories/media.repository.ts @@ -1,15 +1,16 @@ import { Inject, Injectable } from '@nestjs/common'; import { exiftool } from 'exiftool-vendored'; import ffmpeg, { FfprobeData } from 'fluent-ffmpeg'; +import { Duration } from 'luxon'; import fs from 'node:fs/promises'; import { Writable } from 'node:stream'; -import { promisify } from 'node:util'; import sharp from 'sharp'; -import { Colorspace } from 'src/enum'; +import { Colorspace, LogLevel } from 'src/enum'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IMediaRepository, ImageDimensions, + ProbeOptions, ThumbnailOptions, TranscodeCommand, VideoInfo, @@ -17,10 +18,22 @@ import { import { Instrumentation } from 'src/utils/instrumentation'; import { handlePromiseError } from 'src/utils/misc'; -const probe = promisify(ffmpeg.ffprobe); +const probe = (input: string, options: string[]): Promise => + new Promise((resolve, reject) => + ffmpeg.ffprobe(input, options, (error, data) => (error ? reject(error) : resolve(data))), + ); sharp.concurrency(0); sharp.cache({ files: 0 }); +type ProgressEvent = { + frames: number; + currentFps: number; + currentKbps: number; + targetSize: number; + timemark: string; + percent?: number; +}; + @Instrumentation() @Injectable() export class MediaRepository implements IMediaRepository { @@ -65,8 +78,8 @@ export class MediaRepository implements IMediaRepository { .toFile(output); } - async probe(input: string): Promise { - const results = await probe(input); + async probe(input: string, options?: ProbeOptions): Promise { + const results = await probe(input, options?.countFrames ? ['-count_packets'] : []); // gets frame count quickly: https://stackoverflow.com/a/28376817 return { format: { formatName: results.format.format_name, @@ -83,10 +96,10 @@ export class MediaRepository implements IMediaRepository { width: stream.width || 0, codecName: stream.codec_name === 'h265' ? 'hevc' : stream.codec_name, codecType: stream.codec_type, - frameCount: Number.parseInt(stream.nb_frames ?? '0'), - rotation: Number.parseInt(`${stream.rotation ?? 0}`), + frameCount: this.parseInt(options?.countFrames ? stream.nb_read_packets : stream.nb_frames), + rotation: this.parseInt(stream.rotation), isHDR: stream.color_transfer === 'smpte2084' || stream.color_transfer === 'arib-std-b67', - bitrate: Number.parseInt(stream.bit_rate ?? '0'), + bitrate: this.parseInt(stream.bit_rate), })), audioStreams: results.streams .filter((stream) => stream.codec_type === 'audio') @@ -94,7 +107,7 @@ export class MediaRepository implements IMediaRepository { index: stream.index, codecType: stream.codec_type, codecName: stream.codec_name, - frameCount: Number.parseInt(stream.nb_frames ?? '0'), + frameCount: this.parseInt(options?.countFrames ? stream.nb_read_packets : stream.nb_frames), })), }; } @@ -156,10 +169,37 @@ export class MediaRepository implements IMediaRepository { } private configureFfmpegCall(input: string, output: string | Writable, options: TranscodeCommand) { - return ffmpeg(input, { niceness: 10 }) + const ffmpegCall = ffmpeg(input, { niceness: 10 }) .inputOptions(options.inputOptions) .outputOptions(options.outputOptions) .output(output) - .on('error', (error, stdout, stderr) => this.logger.error(stderr || error)); + .on('start', (command: string) => this.logger.debug(command)) + .on('error', (error, _, stderr) => this.logger.error(stderr || error)); + + const { frameCount, percentInterval } = options.progress; + const frameInterval = Math.ceil(frameCount / (100 / percentInterval)); + if (this.logger.isLevelEnabled(LogLevel.DEBUG) && frameCount && frameInterval) { + let lastProgressFrame: number = 0; + ffmpegCall.on('progress', (progress: ProgressEvent) => { + if (progress.frames - lastProgressFrame < frameInterval) { + return; + } + + lastProgressFrame = progress.frames; + const percent = ((progress.frames / frameCount) * 100).toFixed(2); + const ms = Math.floor((frameCount - progress.frames) / progress.currentFps) * 1000; + const duration = ms ? Duration.fromMillis(ms).rescale().toHuman({ unitDisplay: 'narrow' }) : ''; + const outputText = output instanceof Writable ? 'stream' : output.split('/').pop(); + this.logger.debug( + `Transcoding ${percent}% done${duration ? `, estimated ${duration} remaining` : ''} for output ${outputText}`, + ); + }); + } + + return ffmpegCall; + } + + private parseInt(value: string | number | undefined): number { + return Number.parseInt(value as string) || 0; } } diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts index ce6168408f9a3..ddda8f64fc74f 100644 --- a/server/src/services/media.service.spec.ts +++ b/server/src/services/media.service.spec.ts @@ -349,7 +349,7 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', - { + expect.objectContaining({ inputOptions: ['-skip_frame nointra', '-sws_flags accurate_rnd+full_chroma_int'], outputOptions: [ '-fps_mode vfr', @@ -359,7 +359,7 @@ describe(MediaService.name, () => { String.raw`-vf fps=12: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_color_matrix=601:out_range=pc,format=yuv420p`, ], twoPass: false, - }, + }), ); expect(assetMock.upsertFile).toHaveBeenCalledWith({ assetId: 'asset-id', @@ -377,7 +377,7 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', - { + expect.objectContaining({ inputOptions: ['-skip_frame nointra', '-sws_flags accurate_rnd+full_chroma_int'], outputOptions: [ '-fps_mode vfr', @@ -387,7 +387,7 @@ describe(MediaService.name, () => { String.raw`-vf fps=12: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,zscale=t=linear:npl=100,tonemap=hable:desat=0,zscale=p=bt709:t=601:m=bt470bg:range=pc,format=yuv420p`, ], twoPass: false, - }, + }), ); expect(assetMock.upsertFile).toHaveBeenCalledWith({ assetId: 'asset-id', @@ -407,7 +407,7 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', - { + expect.objectContaining({ inputOptions: ['-skip_frame nointra', '-sws_flags accurate_rnd+full_chroma_int'], outputOptions: [ '-fps_mode vfr', @@ -417,7 +417,7 @@ describe(MediaService.name, () => { String.raw`-vf fps=12: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,zscale=t=linear:npl=100,tonemap=hable:desat=0,zscale=p=bt709:t=601:m=bt470bg:range=pc,format=yuv420p`, ], twoPass: false, - }, + }), ); }); @@ -430,11 +430,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining([expect.stringContaining('scale=-2:1440')]), twoPass: false, - }, + }), ); }); @@ -731,21 +731,22 @@ describe(MediaService.name, () => { it('should transcode the longest stream', async () => { assetMock.getByIds.mockResolvedValue([assetStub.video]); + loggerMock.isLevelEnabled.mockReturnValue(false); mediaMock.probe.mockResolvedValue(probeStub.multipleVideoStreams); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.probe).toHaveBeenCalledWith('/original/path.ext'); + expect(mediaMock.probe).toHaveBeenCalledWith('/original/path.ext', { countFrames: false }); expect(systemMock.get).toHaveBeenCalled(); expect(storageMock.mkdirSync).toHaveBeenCalled(); expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-map 0:0', '-map 0:1']), twoPass: false, - }, + }), ); }); @@ -771,11 +772,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.any(Array), twoPass: false, - }, + }), ); }); @@ -786,11 +787,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.any(Array), twoPass: false, - }, + }), ); }); @@ -801,11 +802,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.any(Array), twoPass: false, - }, + }), ); }); @@ -816,11 +817,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.not.arrayContaining([expect.stringContaining('scale')]), twoPass: false, - }, + }), ); }); @@ -832,11 +833,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining([expect.stringMatching(/scale(_.+)?=-2:720/)]), twoPass: false, - }, + }), ); }); @@ -848,11 +849,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining([expect.stringMatching(/scale(_.+)?=720:-2/)]), twoPass: false, - }, + }), ); }); @@ -864,11 +865,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining([expect.stringMatching(/scale(_.+)?=-2:354/)]), twoPass: false, - }, + }), ); }); @@ -880,11 +881,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining([expect.stringMatching(/scale(_.+)?=354:-2/)]), twoPass: false, - }, + }), ); }); @@ -898,11 +899,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-c:v copy', '-c:a aac']), twoPass: false, - }, + }), ); }); @@ -920,11 +921,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.not.arrayContaining(['-tag:v hvc1']), twoPass: false, - }, + }), ); }); @@ -942,11 +943,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-c:v copy', '-tag:v hvc1']), twoPass: false, - }, + }), ); }); @@ -958,11 +959,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-c:v h264', '-c:a copy']), twoPass: false, - }, + }), ); }); @@ -973,11 +974,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-c:v copy', '-c:a copy']), twoPass: false, - }, + }), ); }); @@ -1036,11 +1037,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-c:v h264', '-maxrate 4500k', '-bufsize 9000k']), twoPass: false, - }, + }), ); }); @@ -1052,11 +1053,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-c:v h264', '-b:v 3104k', '-minrate 1552k', '-maxrate 4500k']), twoPass: true, - }, + }), ); }); @@ -1068,11 +1069,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-c:v h264', '-c:a copy']), twoPass: false, - }, + }), ); }); @@ -1090,11 +1091,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-b:v 3104k', '-minrate 1552k', '-maxrate 4500k']), twoPass: true, - }, + }), ); }); @@ -1112,11 +1113,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.not.arrayContaining([expect.stringContaining('-maxrate')]), twoPass: true, - }, + }), ); }); @@ -1128,11 +1129,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-cpu-used 2']), twoPass: false, - }, + }), ); }); @@ -1144,11 +1145,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.not.arrayContaining([expect.stringContaining('-cpu-used')]), twoPass: false, - }, + }), ); }); @@ -1160,11 +1161,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-threads 2']), twoPass: false, - }, + }), ); }); @@ -1176,11 +1177,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-threads 1', '-x264-params frame-threads=1:pools=none']), twoPass: false, - }, + }), ); }); @@ -1192,11 +1193,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.not.arrayContaining([expect.stringContaining('-threads')]), twoPass: false, - }, + }), ); }); @@ -1208,11 +1209,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-c:v hevc', '-threads 1', '-x265-params frame-threads=1:pools=none']), twoPass: false, - }, + }), ); }); @@ -1224,11 +1225,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.not.arrayContaining([expect.stringContaining('-threads')]), twoPass: false, - }, + }), ); }); @@ -1240,7 +1241,7 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining([ '-c:v av1', @@ -1255,7 +1256,7 @@ describe(MediaService.name, () => { '-crf 23', ]), twoPass: false, - }, + }), ); }); @@ -1267,11 +1268,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-preset 4']), twoPass: false, - }, + }), ); }); @@ -1283,11 +1284,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-svtav1-params mbr=2M']), twoPass: false, - }, + }), ); }); @@ -1299,11 +1300,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-svtav1-params lp=4']), twoPass: false, - }, + }), ); }); @@ -1315,11 +1316,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-svtav1-params lp=4:mbr=2M']), twoPass: false, - }, + }), ); }); @@ -1361,7 +1362,7 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining(['-init_hw_device cuda=cuda:0', '-filter_hw_device cuda']), outputOptions: expect.arrayContaining([ '-tune hq', @@ -1382,7 +1383,7 @@ describe(MediaService.name, () => { '-cq:v 23', ]), twoPass: false, - }, + }), ); }); @@ -1400,11 +1401,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining(['-init_hw_device cuda=cuda:0', '-filter_hw_device cuda']), outputOptions: expect.arrayContaining([expect.stringContaining('-multipass')]), twoPass: false, - }, + }), ); }); @@ -1416,11 +1417,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining(['-init_hw_device cuda=cuda:0', '-filter_hw_device cuda']), outputOptions: expect.arrayContaining(['-cq:v 23', '-maxrate 10000k', '-bufsize 6897k']), twoPass: false, - }, + }), ); }); @@ -1432,11 +1433,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining(['-init_hw_device cuda=cuda:0', '-filter_hw_device cuda']), outputOptions: expect.not.stringContaining('-maxrate'), twoPass: false, - }, + }), ); }); @@ -1448,11 +1449,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining(['-init_hw_device cuda=cuda:0', '-filter_hw_device cuda']), outputOptions: expect.not.arrayContaining([expect.stringContaining('-preset')]), twoPass: false, - }, + }), ); }); @@ -1464,11 +1465,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining(['-init_hw_device cuda=cuda:0', '-filter_hw_device cuda']), outputOptions: expect.not.arrayContaining([expect.stringContaining('-multipass')]), twoPass: false, - }, + }), ); }); @@ -1482,7 +1483,7 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining([ '-hwaccel cuda', '-hwaccel_output_format cuda', @@ -1491,7 +1492,7 @@ describe(MediaService.name, () => { ]), outputOptions: expect.arrayContaining([expect.stringContaining('scale_cuda=-2:720:format=nv12')]), twoPass: false, - }, + }), ); }); @@ -1505,7 +1506,7 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining(['-hwaccel cuda', '-hwaccel_output_format cuda']), outputOptions: expect.arrayContaining([ expect.stringContaining( @@ -1513,7 +1514,7 @@ describe(MediaService.name, () => { ), ]), twoPass: false, - }, + }), ); }); @@ -1526,7 +1527,7 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining(['-init_hw_device qsv=hw', '-filter_hw_device hw']), outputOptions: expect.arrayContaining([ `-c:v h264_qsv`, @@ -1547,7 +1548,7 @@ describe(MediaService.name, () => { '-bufsize 20000k', ]), twoPass: false, - }, + }), ); }); @@ -1566,14 +1567,14 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining([ '-init_hw_device qsv=hw,child_device=/dev/dri/renderD128', '-filter_hw_device hw', ]), outputOptions: expect.any(Array), twoPass: false, - }, + }), ); }); @@ -1586,11 +1587,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining(['-init_hw_device qsv=hw', '-filter_hw_device hw']), outputOptions: expect.not.arrayContaining([expect.stringContaining('-preset')]), twoPass: false, - }, + }), ); }); @@ -1603,11 +1604,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining(['-init_hw_device qsv=hw', '-filter_hw_device hw']), outputOptions: expect.arrayContaining(['-low_power 1']), twoPass: false, - }, + }), ); }); @@ -1633,7 +1634,7 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining([ '-hwaccel qsv', '-hwaccel_output_format qsv', @@ -1645,7 +1646,7 @@ describe(MediaService.name, () => { expect.stringContaining('scale_qsv=-1:720:async_depth=4:mode=hq:format=nv12'), ]), twoPass: false, - }, + }), ); }); @@ -1662,7 +1663,7 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining([ '-hwaccel qsv', '-hwaccel_output_format qsv', @@ -1675,7 +1676,7 @@ describe(MediaService.name, () => { ), ]), twoPass: false, - }, + }), ); }); @@ -1691,11 +1692,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining(['-hwaccel qsv', '-qsv_device /dev/dri/renderD129']), outputOptions: expect.any(Array), twoPass: false, - }, + }), ); }); @@ -1708,7 +1709,7 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining([ '-init_hw_device vaapi=accel:/dev/dri/renderD128', '-filter_hw_device accel', @@ -1728,7 +1729,7 @@ describe(MediaService.name, () => { '-rc_mode 1', ]), twoPass: false, - }, + }), ); }); @@ -1741,7 +1742,7 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining([ '-init_hw_device vaapi=accel:/dev/dri/renderD128', '-filter_hw_device accel', @@ -1754,7 +1755,7 @@ describe(MediaService.name, () => { '-rc_mode 3', ]), twoPass: false, - }, + }), ); }); @@ -1767,7 +1768,7 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining([ '-init_hw_device vaapi=accel:/dev/dri/renderD128', '-filter_hw_device accel', @@ -1780,7 +1781,7 @@ describe(MediaService.name, () => { '-rc_mode 1', ]), twoPass: false, - }, + }), ); }); @@ -1793,14 +1794,14 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining([ '-init_hw_device vaapi=accel:/dev/dri/renderD128', '-filter_hw_device accel', ]), outputOptions: expect.not.arrayContaining([expect.stringContaining('-compression_level')]), twoPass: false, - }, + }), ); }); @@ -1813,14 +1814,14 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining([ '-init_hw_device vaapi=accel:/dev/dri/card1', '-filter_hw_device accel', ]), outputOptions: expect.arrayContaining([`-c:v h264_vaapi`]), twoPass: false, - }, + }), ); }); @@ -1833,14 +1834,14 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining([ '-init_hw_device vaapi=accel:/dev/dri/renderD130', '-filter_hw_device accel', ]), outputOptions: expect.arrayContaining([`-c:v h264_vaapi`]), twoPass: false, - }, + }), ); }); @@ -1855,14 +1856,14 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining([ '-init_hw_device vaapi=accel:/dev/dri/renderD128', '-filter_hw_device accel', ]), outputOptions: expect.arrayContaining([`-c:v h264_vaapi`]), twoPass: false, - }, + }), ); }); @@ -1877,11 +1878,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenLastCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-c:v h264']), twoPass: false, - }, + }), ); }); @@ -1904,7 +1905,7 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining([ '-hwaccel rkmpp', '-hwaccel_output_format drm_prime', @@ -1927,7 +1928,7 @@ describe(MediaService.name, () => { '-qp_init 23', ]), twoPass: false, - }, + }), ); }); @@ -1948,11 +1949,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining(['-hwaccel rkmpp', '-hwaccel_output_format drm_prime', '-afbc rga']), outputOptions: expect.arrayContaining([`-c:v hevc_rkmpp`, '-level 153', '-rc_mode AVBR', '-b:v 10000k']), twoPass: false, - }, + }), ); }); @@ -1968,11 +1969,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining(['-hwaccel rkmpp', '-hwaccel_output_format drm_prime', '-afbc rga']), outputOptions: expect.arrayContaining([`-c:v h264_rkmpp`, '-level 51', '-rc_mode CQP', '-qp_init 30']), twoPass: false, - }, + }), ); }); @@ -1988,7 +1989,7 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining(['-hwaccel rkmpp', '-hwaccel_output_format drm_prime', '-afbc rga']), outputOptions: expect.arrayContaining([ expect.stringContaining( @@ -1996,7 +1997,7 @@ describe(MediaService.name, () => { ), ]), twoPass: false, - }, + }), ); }); @@ -2012,7 +2013,7 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: [], outputOptions: expect.arrayContaining([ expect.stringContaining( @@ -2020,7 +2021,7 @@ describe(MediaService.name, () => { ), ]), twoPass: false, - }, + }), ); }); @@ -2036,7 +2037,7 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: [], outputOptions: expect.arrayContaining([ expect.stringContaining( @@ -2044,69 +2045,101 @@ describe(MediaService.name, () => { ), ]), twoPass: false, + }), + ); + }); + + it('should tonemap when policy is required and video is hdr', async () => { + mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); + systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.REQUIRED } }); + assetMock.getByIds.mockResolvedValue([assetStub.video]); + await sut.handleVideoConversion({ id: assetStub.video.id }); + expect(mediaMock.transcode).toHaveBeenCalledWith( + '/original/path.ext', + 'upload/encoded-video/user-id/as/se/asset-id.mp4', + expect.objectContaining({ + inputOptions: expect.any(Array), + outputOptions: expect.arrayContaining([ + '-c:v h264', + '-c:a copy', + '-vf zscale=t=linear:npl=100,tonemap=hable:desat=0,zscale=p=bt709:t=bt709:m=bt709:range=pc,format=yuv420p', + ]), + twoPass: false, + }), + ); + }); + + it('should tonemap when policy is optimal and video is hdr', async () => { + mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); + systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.OPTIMAL } }); + assetMock.getByIds.mockResolvedValue([assetStub.video]); + await sut.handleVideoConversion({ id: assetStub.video.id }); + expect(mediaMock.transcode).toHaveBeenCalledWith( + '/original/path.ext', + 'upload/encoded-video/user-id/as/se/asset-id.mp4', + expect.objectContaining({ + inputOptions: expect.any(Array), + outputOptions: expect.arrayContaining([ + '-c:v h264', + '-c:a copy', + '-vf zscale=t=linear:npl=100,tonemap=hable:desat=0,zscale=p=bt709:t=bt709:m=bt709:range=pc,format=yuv420p', + ]), + twoPass: false, + }), + ); + }); + + it('should set npl to 250 for reinhard and mobius tone-mapping algorithms', async () => { + mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); + systemMock.get.mockResolvedValue({ ffmpeg: { tonemap: ToneMapping.MOBIUS } }); + assetMock.getByIds.mockResolvedValue([assetStub.video]); + await sut.handleVideoConversion({ id: assetStub.video.id }); + expect(mediaMock.transcode).toHaveBeenCalledWith( + '/original/path.ext', + 'upload/encoded-video/user-id/as/se/asset-id.mp4', + expect.objectContaining({ + inputOptions: expect.any(Array), + outputOptions: expect.arrayContaining([ + '-c:v h264', + '-c:a copy', + '-vf zscale=t=linear:npl=250,tonemap=mobius:desat=0,zscale=p=bt709:t=bt709:m=bt709:range=pc,format=yuv420p', + ]), + twoPass: false, + }), + ); + }); + + it('should count frames for progress when log level is debug', async () => { + mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); + loggerMock.isLevelEnabled.mockReturnValue(true); + assetMock.getByIds.mockResolvedValue([assetStub.video]); + + await sut.handleVideoConversion({ id: assetStub.video.id }); + + expect(mediaMock.probe).toHaveBeenCalledWith(assetStub.video.originalPath, { countFrames: true }); + expect(mediaMock.transcode).toHaveBeenCalledWith( + assetStub.video.originalPath, + 'upload/encoded-video/user-id/as/se/asset-id.mp4', + { + inputOptions: expect.any(Array), + outputOptions: expect.any(Array), + twoPass: false, + progress: { + frameCount: probeStub.videoStream2160p.videoStreams[0].frameCount, + percentInterval: expect.any(Number), + }, }, ); }); - }); - it('should tonemap when policy is required and video is hdr', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); - systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.REQUIRED } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); - await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( - '/original/path.ext', - 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { - inputOptions: expect.any(Array), - outputOptions: expect.arrayContaining([ - '-c:v h264', - '-c:a copy', - '-vf zscale=t=linear:npl=100,tonemap=hable:desat=0,zscale=p=bt709:t=bt709:m=bt709:range=pc,format=yuv420p', - ]), - twoPass: false, - }, - ); - }); + it('should not count frames for progress when log level is not debug', async () => { + mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p); + loggerMock.isLevelEnabled.mockReturnValue(false); + assetMock.getByIds.mockResolvedValue([assetStub.video]); + await sut.handleVideoConversion({ id: assetStub.video.id }); - it('should tonemap when policy is optimal and video is hdr', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); - systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.OPTIMAL } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); - await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( - '/original/path.ext', - 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { - inputOptions: expect.any(Array), - outputOptions: expect.arrayContaining([ - '-c:v h264', - '-c:a copy', - '-vf zscale=t=linear:npl=100,tonemap=hable:desat=0,zscale=p=bt709:t=bt709:m=bt709:range=pc,format=yuv420p', - ]), - twoPass: false, - }, - ); - }); - - it('should set npl to 250 for reinhard and mobius tone-mapping algorithms', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); - systemMock.get.mockResolvedValue({ ffmpeg: { tonemap: ToneMapping.MOBIUS } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); - await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( - '/original/path.ext', - 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { - inputOptions: expect.any(Array), - outputOptions: expect.arrayContaining([ - '-c:v h264', - '-c:a copy', - '-vf zscale=t=linear:npl=250,tonemap=mobius:desat=0,zscale=p=bt709:t=bt709:m=bt709:range=pc,format=yuv420p', - ]), - twoPass: false, - }, - ); + expect(mediaMock.probe).toHaveBeenCalledWith(assetStub.video.originalPath, { countFrames: false }); + }); }); describe('isSRGB', () => { diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index 55a4ee015757b..720bef6c7661b 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -11,6 +11,7 @@ import { AudioCodec, Colorspace, ImageFormat, + LogLevel, StorageFolder, TranscodeHWAccel, TranscodePolicy, @@ -31,7 +32,13 @@ import { QueueName, } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; -import { AudioStreamInfo, IMediaRepository, VideoFormat, VideoStreamInfo } from 'src/interfaces/media.interface'; +import { + AudioStreamInfo, + IMediaRepository, + TranscodeCommand, + VideoFormat, + VideoStreamInfo, +} from 'src/interfaces/media.interface'; import { IMoveRepository } from 'src/interfaces/move.interface'; import { IPersonRepository } from 'src/interfaces/person.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; @@ -346,7 +353,9 @@ export class MediaService { const output = StorageCore.getEncodedVideoPath(asset); this.storageCore.ensureFolders(output); - const { videoStreams, audioStreams, format } = await this.mediaRepository.probe(input); + const { videoStreams, audioStreams, format } = await this.mediaRepository.probe(input, { + countFrames: this.logger.isLevelEnabled(LogLevel.DEBUG), // makes frame count more reliable for progress logs + }); const mainVideoStream = this.getMainStream(videoStreams); const mainAudioStream = this.getMainStream(audioStreams); if (!mainVideoStream || !format.formatName) { @@ -365,12 +374,14 @@ export class MediaService { this.logger.log(`Transcoded video exists for asset ${asset.id}, but is no longer required. Deleting...`); await this.jobRepository.queue({ name: JobName.DELETE_FILES, data: { files: [asset.encodedVideoPath] } }); await this.assetRepository.update({ id: asset.id, encodedVideoPath: null }); + } else { + this.logger.verbose(`Asset ${asset.id} does not require transcoding based on current policy, skipping`); } return JobStatus.SKIPPED; } - let command; + let command: TranscodeCommand; try { const config = BaseConfig.create(ffmpeg, await this.getDevices(), await this.hasMaliOpenCL()); command = config.getCommand(target, mainVideoStream, mainAudioStream); @@ -379,16 +390,20 @@ export class MediaService { return JobStatus.FAILED; } - this.logger.log(`Started encoding video ${asset.id} ${JSON.stringify(command)}`); + if (ffmpeg.accel === TranscodeHWAccel.DISABLED) { + this.logger.log(`Encoding video ${asset.id} without hardware acceleration`); + } else { + this.logger.log(`Encoding video ${asset.id} with ${ffmpeg.accel.toUpperCase()} acceleration`); + } + try { await this.mediaRepository.transcode(input, output, command); - } catch (error) { - this.logger.error(error); - if (ffmpeg.accel !== TranscodeHWAccel.DISABLED) { - this.logger.error( - `Error occurred during transcoding. Retrying with ${ffmpeg.accel.toUpperCase()} acceleration disabled.`, - ); + } catch (error: any) { + this.logger.error(`Error occurred during transcoding: ${error.message}`); + if (ffmpeg.accel === TranscodeHWAccel.DISABLED) { + return JobStatus.FAILED; } + this.logger.error(`Retrying with ${ffmpeg.accel.toUpperCase()} acceleration disabled`); const config = BaseConfig.create({ ...ffmpeg, accel: TranscodeHWAccel.DISABLED }); command = config.getCommand(target, mainVideoStream, mainAudioStream); await this.mediaRepository.transcode(input, output, command); @@ -555,7 +570,7 @@ export class MediaService { const maliDeviceStat = await this.storageRepository.stat('/dev/mali0'); this.maliOpenCL = maliIcdStat.isFile() && maliDeviceStat.isCharacterDevice(); } catch { - this.logger.debug('OpenCL not available for transcoding, using CPU decoding instead.'); + this.logger.debug('OpenCL not available for transcoding, so RKMPP acceleration will use CPU decoding'); this.maliOpenCL = false; } } diff --git a/server/src/utils/media.ts b/server/src/utils/media.ts index d80651eece3a8..6f0ab4ef81d90 100644 --- a/server/src/utils/media.ts +++ b/server/src/utils/media.ts @@ -80,6 +80,7 @@ export class BaseConfig implements VideoCodecSWConfig { inputOptions: this.getBaseInputOptions(videoStream), outputOptions: [...this.getBaseOutputOptions(target, videoStream, audioStream), '-v verbose'], twoPass: this.eligibleForTwoPass(), + progress: { frameCount: videoStream.frameCount, percentInterval: 5 }, } as TranscodeCommand; if ([TranscodeTarget.ALL, TranscodeTarget.VIDEO].includes(target)) { const filters = this.getFilterOptions(videoStream); diff --git a/server/test/repositories/logger.repository.mock.ts b/server/test/repositories/logger.repository.mock.ts index 5f7262c7e5d92..6342e9e73cc85 100644 --- a/server/test/repositories/logger.repository.mock.ts +++ b/server/test/repositories/logger.repository.mock.ts @@ -6,7 +6,7 @@ export const newLoggerRepositoryMock = (): Mocked => { setLogLevel: vitest.fn(), setContext: vitest.fn(), setAppName: vitest.fn(), - + isLevelEnabled: vitest.fn(), verbose: vitest.fn(), debug: vitest.fn(), log: vitest.fn(), From 995f0fda475d40e969190925af455c20abb7a02b Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Sat, 28 Sep 2024 02:01:04 -0400 Subject: [PATCH 117/123] feat(server): separate quality for thumbnail and preview images (#13006) * allow different thumbnail and preview quality, better config structure * update web and api * wording * remove empty line? --- mobile/openapi/README.md | 1 + mobile/openapi/lib/api.dart | 1 + mobile/openapi/lib/api_client.dart | 2 + .../system_config_generated_image_dto.dart | 118 ++++++++++++++ .../lib/model/system_config_image_dto.dart | 58 ++----- open-api/immich-openapi-specs.json | 50 +++--- open-api/typescript-sdk/src/fetch-client.ts | 12 +- server/src/config.ts | 23 +-- server/src/dtos/system-config.dto.ts | 38 ++--- server/src/interfaces/media.interface.ts | 9 +- ...7-SeparateQualityForThumbnailAndPreview.ts | 37 +++++ server/src/services/media.service.spec.ts | 8 +- server/src/services/media.service.ts | 27 ++-- server/src/services/person.service.ts | 2 +- .../services/system-config.service.spec.ts | 15 +- .../settings/image/image-settings.svelte | 150 ++++++++++-------- web/src/lib/i18n/en.json | 16 +- 17 files changed, 369 insertions(+), 198 deletions(-) create mode 100644 mobile/openapi/lib/model/system_config_generated_image_dto.dart create mode 100644 server/src/migrations/1727471863507-SeparateQualityForThumbnailAndPreview.ts diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index fecbbf482be54..81827a9079e5a 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -416,6 +416,7 @@ Class | Method | HTTP request | Description - [SystemConfigDto](doc//SystemConfigDto.md) - [SystemConfigFFmpegDto](doc//SystemConfigFFmpegDto.md) - [SystemConfigFacesDto](doc//SystemConfigFacesDto.md) + - [SystemConfigGeneratedImageDto](doc//SystemConfigGeneratedImageDto.md) - [SystemConfigImageDto](doc//SystemConfigImageDto.md) - [SystemConfigJobDto](doc//SystemConfigJobDto.md) - [SystemConfigLibraryDto](doc//SystemConfigLibraryDto.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 22b48df2fbcb1..8be44029805d5 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -229,6 +229,7 @@ part 'model/stack_update_dto.dart'; part 'model/system_config_dto.dart'; part 'model/system_config_f_fmpeg_dto.dart'; part 'model/system_config_faces_dto.dart'; +part 'model/system_config_generated_image_dto.dart'; part 'model/system_config_image_dto.dart'; part 'model/system_config_job_dto.dart'; part 'model/system_config_library_dto.dart'; diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 3db3297acb091..9e38eaf30a8a9 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -512,6 +512,8 @@ class ApiClient { return SystemConfigFFmpegDto.fromJson(value); case 'SystemConfigFacesDto': return SystemConfigFacesDto.fromJson(value); + case 'SystemConfigGeneratedImageDto': + return SystemConfigGeneratedImageDto.fromJson(value); case 'SystemConfigImageDto': return SystemConfigImageDto.fromJson(value); case 'SystemConfigJobDto': diff --git a/mobile/openapi/lib/model/system_config_generated_image_dto.dart b/mobile/openapi/lib/model/system_config_generated_image_dto.dart new file mode 100644 index 0000000000000..2192a7cb0cbd5 --- /dev/null +++ b/mobile/openapi/lib/model/system_config_generated_image_dto.dart @@ -0,0 +1,118 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class SystemConfigGeneratedImageDto { + /// Returns a new [SystemConfigGeneratedImageDto] instance. + SystemConfigGeneratedImageDto({ + required this.format, + required this.quality, + required this.size, + }); + + ImageFormat format; + + /// Minimum value: 1 + /// Maximum value: 100 + int quality; + + /// Minimum value: 1 + int size; + + @override + bool operator ==(Object other) => identical(this, other) || other is SystemConfigGeneratedImageDto && + other.format == format && + other.quality == quality && + other.size == size; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (format.hashCode) + + (quality.hashCode) + + (size.hashCode); + + @override + String toString() => 'SystemConfigGeneratedImageDto[format=$format, quality=$quality, size=$size]'; + + Map toJson() { + final json = {}; + json[r'format'] = this.format; + json[r'quality'] = this.quality; + json[r'size'] = this.size; + return json; + } + + /// Returns a new [SystemConfigGeneratedImageDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SystemConfigGeneratedImageDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigGeneratedImageDto"); + if (value is Map) { + final json = value.cast(); + + return SystemConfigGeneratedImageDto( + format: ImageFormat.fromJson(json[r'format'])!, + quality: mapValueOfType(json, r'quality')!, + size: mapValueOfType(json, r'size')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = SystemConfigGeneratedImageDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = SystemConfigGeneratedImageDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SystemConfigGeneratedImageDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = SystemConfigGeneratedImageDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'format', + 'quality', + 'size', + }; +} + diff --git a/mobile/openapi/lib/model/system_config_image_dto.dart b/mobile/openapi/lib/model/system_config_image_dto.dart index 681a8c00c3bc0..5309f7745c44d 100644 --- a/mobile/openapi/lib/model/system_config_image_dto.dart +++ b/mobile/openapi/lib/model/system_config_image_dto.dart @@ -15,64 +15,42 @@ class SystemConfigImageDto { SystemConfigImageDto({ required this.colorspace, required this.extractEmbedded, - required this.previewFormat, - required this.previewSize, - required this.quality, - required this.thumbnailFormat, - required this.thumbnailSize, + required this.preview, + required this.thumbnail, }); Colorspace colorspace; bool extractEmbedded; - ImageFormat previewFormat; + SystemConfigGeneratedImageDto preview; - /// Minimum value: 1 - int previewSize; - - /// Minimum value: 1 - /// Maximum value: 100 - int quality; - - ImageFormat thumbnailFormat; - - /// Minimum value: 1 - int thumbnailSize; + SystemConfigGeneratedImageDto thumbnail; @override bool operator ==(Object other) => identical(this, other) || other is SystemConfigImageDto && other.colorspace == colorspace && other.extractEmbedded == extractEmbedded && - other.previewFormat == previewFormat && - other.previewSize == previewSize && - other.quality == quality && - other.thumbnailFormat == thumbnailFormat && - other.thumbnailSize == thumbnailSize; + other.preview == preview && + other.thumbnail == thumbnail; @override int get hashCode => // ignore: unnecessary_parenthesis (colorspace.hashCode) + (extractEmbedded.hashCode) + - (previewFormat.hashCode) + - (previewSize.hashCode) + - (quality.hashCode) + - (thumbnailFormat.hashCode) + - (thumbnailSize.hashCode); + (preview.hashCode) + + (thumbnail.hashCode); @override - String toString() => 'SystemConfigImageDto[colorspace=$colorspace, extractEmbedded=$extractEmbedded, previewFormat=$previewFormat, previewSize=$previewSize, quality=$quality, thumbnailFormat=$thumbnailFormat, thumbnailSize=$thumbnailSize]'; + String toString() => 'SystemConfigImageDto[colorspace=$colorspace, extractEmbedded=$extractEmbedded, preview=$preview, thumbnail=$thumbnail]'; Map toJson() { final json = {}; json[r'colorspace'] = this.colorspace; json[r'extractEmbedded'] = this.extractEmbedded; - json[r'previewFormat'] = this.previewFormat; - json[r'previewSize'] = this.previewSize; - json[r'quality'] = this.quality; - json[r'thumbnailFormat'] = this.thumbnailFormat; - json[r'thumbnailSize'] = this.thumbnailSize; + json[r'preview'] = this.preview; + json[r'thumbnail'] = this.thumbnail; return json; } @@ -87,11 +65,8 @@ class SystemConfigImageDto { return SystemConfigImageDto( colorspace: Colorspace.fromJson(json[r'colorspace'])!, extractEmbedded: mapValueOfType(json, r'extractEmbedded')!, - previewFormat: ImageFormat.fromJson(json[r'previewFormat'])!, - previewSize: mapValueOfType(json, r'previewSize')!, - quality: mapValueOfType(json, r'quality')!, - thumbnailFormat: ImageFormat.fromJson(json[r'thumbnailFormat'])!, - thumbnailSize: mapValueOfType(json, r'thumbnailSize')!, + preview: SystemConfigGeneratedImageDto.fromJson(json[r'preview'])!, + thumbnail: SystemConfigGeneratedImageDto.fromJson(json[r'thumbnail'])!, ); } return null; @@ -141,11 +116,8 @@ class SystemConfigImageDto { static const requiredKeys = { 'colorspace', 'extractEmbedded', - 'previewFormat', - 'previewSize', - 'quality', - 'thumbnailFormat', - 'thumbnailSize', + 'preview', + 'thumbnail', }; } diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 6afd0d792ff34..1077762ac3a56 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -11654,6 +11654,28 @@ ], "type": "object" }, + "SystemConfigGeneratedImageDto": { + "properties": { + "format": { + "$ref": "#/components/schemas/ImageFormat" + }, + "quality": { + "maximum": 100, + "minimum": 1, + "type": "integer" + }, + "size": { + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "format", + "quality", + "size" + ], + "type": "object" + }, "SystemConfigImageDto": { "properties": { "colorspace": { @@ -11662,34 +11684,18 @@ "extractEmbedded": { "type": "boolean" }, - "previewFormat": { - "$ref": "#/components/schemas/ImageFormat" + "preview": { + "$ref": "#/components/schemas/SystemConfigGeneratedImageDto" }, - "previewSize": { - "minimum": 1, - "type": "integer" - }, - "quality": { - "maximum": 100, - "minimum": 1, - "type": "integer" - }, - "thumbnailFormat": { - "$ref": "#/components/schemas/ImageFormat" - }, - "thumbnailSize": { - "minimum": 1, - "type": "integer" + "thumbnail": { + "$ref": "#/components/schemas/SystemConfigGeneratedImageDto" } }, "required": [ "colorspace", "extractEmbedded", - "previewFormat", - "previewSize", - "quality", - "thumbnailFormat", - "thumbnailSize" + "preview", + "thumbnail" ], "type": "object" }, diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index b1ae5d28764f2..e88f431e8c787 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -1100,14 +1100,16 @@ export type SystemConfigFFmpegDto = { transcode: TranscodePolicy; twoPass: boolean; }; +export type SystemConfigGeneratedImageDto = { + format: ImageFormat; + quality: number; + size: number; +}; export type SystemConfigImageDto = { colorspace: Colorspace; extractEmbedded: boolean; - previewFormat: ImageFormat; - previewSize: number; - quality: number; - thumbnailFormat: ImageFormat; - thumbnailSize: number; + preview: SystemConfigGeneratedImageDto; + thumbnail: SystemConfigGeneratedImageDto; }; export type JobSettingsDto = { concurrency: number; diff --git a/server/src/config.ts b/server/src/config.ts index 1522371487e3b..3317351f9ff3a 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -20,6 +20,7 @@ import { VideoContainer, } from 'src/enum'; import { ConcurrentQueueName, QueueName } from 'src/interfaces/job.interface'; +import { ImageOutputConfig } from 'src/interfaces/media.interface'; export interface SystemConfig { ffmpeg: { @@ -109,11 +110,8 @@ export interface SystemConfig { template: string; }; image: { - thumbnailFormat: ImageFormat; - thumbnailSize: number; - previewFormat: ImageFormat; - previewSize: number; - quality: number; + thumbnail: ImageOutputConfig; + preview: ImageOutputConfig; colorspace: Colorspace; extractEmbedded: boolean; }; @@ -259,11 +257,16 @@ export const defaults = Object.freeze({ template: '{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}', }, image: { - thumbnailFormat: ImageFormat.WEBP, - thumbnailSize: 250, - previewFormat: ImageFormat.JPEG, - previewSize: 1440, - quality: 80, + thumbnail: { + format: ImageFormat.WEBP, + size: 250, + quality: 80, + }, + preview: { + format: ImageFormat.JPEG, + size: 1440, + quality: 80, + }, colorspace: Colorspace.P3, extractEmbedded: false, }, diff --git a/server/src/dtos/system-config.dto.ts b/server/src/dtos/system-config.dto.ts index 4a3ca37691604..c12a54cd613e6 100644 --- a/server/src/dtos/system-config.dto.ts +++ b/server/src/dtos/system-config.dto.ts @@ -473,26 +473,10 @@ export class SystemConfigThemeDto { customCss!: string; } -class SystemConfigImageDto { +class SystemConfigGeneratedImageDto { @IsEnum(ImageFormat) @ApiProperty({ enumName: 'ImageFormat', enum: ImageFormat }) - thumbnailFormat!: ImageFormat; - - @IsInt() - @Min(1) - @Type(() => Number) - @ApiProperty({ type: 'integer' }) - thumbnailSize!: number; - - @IsEnum(ImageFormat) - @ApiProperty({ enumName: 'ImageFormat', enum: ImageFormat }) - previewFormat!: ImageFormat; - - @IsInt() - @Min(1) - @Type(() => Number) - @ApiProperty({ type: 'integer' }) - previewSize!: number; + format!: ImageFormat; @IsInt() @Min(1) @@ -501,6 +485,24 @@ class SystemConfigImageDto { @ApiProperty({ type: 'integer' }) quality!: number; + @IsInt() + @Min(1) + @Type(() => Number) + @ApiProperty({ type: 'integer' }) + size!: number; +} + +class SystemConfigImageDto { + @Type(() => SystemConfigGeneratedImageDto) + @ValidateNested() + @IsObject() + thumbnail!: SystemConfigGeneratedImageDto; + + @Type(() => SystemConfigGeneratedImageDto) + @ValidateNested() + @IsObject() + preview!: SystemConfigGeneratedImageDto; + @IsEnum(Colorspace) @ApiProperty({ enumName: 'Colorspace', enum: Colorspace }) colorspace!: Colorspace; diff --git a/server/src/interfaces/media.interface.ts b/server/src/interfaces/media.interface.ts index 7193684e7acc1..64ba6236e80f0 100644 --- a/server/src/interfaces/media.interface.ts +++ b/server/src/interfaces/media.interface.ts @@ -10,11 +10,14 @@ export interface CropOptions { height: number; } -export interface ThumbnailOptions { - size: number; +export interface ImageOutputConfig { format: ImageFormat; - colorspace: string; quality: number; + size: number; +} + +export interface ThumbnailOptions extends ImageOutputConfig { + colorspace: string; crop?: CropOptions; processInvalidImages: boolean; } diff --git a/server/src/migrations/1727471863507-SeparateQualityForThumbnailAndPreview.ts b/server/src/migrations/1727471863507-SeparateQualityForThumbnailAndPreview.ts new file mode 100644 index 0000000000000..e02203997f723 --- /dev/null +++ b/server/src/migrations/1727471863507-SeparateQualityForThumbnailAndPreview.ts @@ -0,0 +1,37 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class SeparateQualityForThumbnailAndPreview1727471863507 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + update system_metadata + set value = jsonb_set(value, '{image}', jsonb_strip_nulls( + jsonb_build_object( + 'preview', jsonb_build_object( + 'format', value->'image'->'previewFormat', + 'quality', value->'image'->'quality', + 'size', value->'image'->'previewSize'), + 'thumbnail', jsonb_build_object( + 'format', value->'image'->'thumbnailFormat', + 'quality', value->'image'->'quality', + 'size', value->'image'->'thumbnailSize'), + 'extractEmbedded', value->'extractEmbedded', + 'colorspace', value->'colorspace' + ))) + where key = 'system-config'`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + update system_metadata + set value = jsonb_set(value, '{image}', jsonb_strip_nulls(jsonb_build_object( + 'previewFormat', value->'image'->'preview'->'format', + 'previewSize', value->'image'->'preview'->'size', + 'thumbnailFormat', value->'image'->'thumbnail'->'format', + 'thumbnailSize', value->'image'->'thumbnail'->'size', + 'extractEmbedded', value->'extractEmbedded', + 'colorspace', value->'colorspace', + 'quality', value->'image'->'preview'->'quality' + ))) + where key = 'system-config'`); + } +} diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts index ddda8f64fc74f..c0903fa101412 100644 --- a/server/src/services/media.service.spec.ts +++ b/server/src/services/media.service.spec.ts @@ -285,7 +285,7 @@ describe(MediaService.name, () => { }); it.each(Object.values(ImageFormat))('should generate a %s preview for an image when specified', async (format) => { - systemMock.get.mockResolvedValue({ image: { previewFormat: format } }); + systemMock.get.mockResolvedValue({ image: { preview: { format } } }); assetMock.getByIds.mockResolvedValue([assetStub.image]); const previewPath = `upload/thumbs/user-id/as/se/asset-id-preview.${format}`; @@ -307,7 +307,7 @@ describe(MediaService.name, () => { }); it('should delete previous preview if different path', async () => { - systemMock.get.mockResolvedValue({ image: { thumbnailFormat: ImageFormat.WEBP } }); + systemMock.get.mockResolvedValue({ image: { thumbnail: { format: ImageFormat.WEBP } } }); assetMock.getByIds.mockResolvedValue([assetStub.image]); await sut.handleGeneratePreview({ id: assetStub.image.id }); @@ -464,7 +464,7 @@ describe(MediaService.name, () => { it.each(Object.values(ImageFormat))( 'should generate a %s thumbnail for an image when specified', async (format) => { - systemMock.get.mockResolvedValue({ image: { thumbnailFormat: format } }); + systemMock.get.mockResolvedValue({ image: { thumbnail: { format } } }); assetMock.getByIds.mockResolvedValue([assetStub.image]); const thumbnailPath = `upload/thumbs/user-id/as/se/asset-id-thumbnail.${format}`; @@ -487,7 +487,7 @@ describe(MediaService.name, () => { ); it('should delete previous thumbnail if different path', async () => { - systemMock.get.mockResolvedValue({ image: { thumbnailFormat: ImageFormat.WEBP } }); + systemMock.get.mockResolvedValue({ image: { thumbnail: { format: ImageFormat.WEBP } } }); assetMock.getByIds.mockResolvedValue([assetStub.image]); await sut.handleGenerateThumbnail({ id: assetStub.image.id }); diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index 720bef6c7661b..1b69c5acd5504 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -10,7 +10,6 @@ import { AssetType, AudioCodec, Colorspace, - ImageFormat, LogLevel, StorageFolder, TranscodeHWAccel, @@ -175,18 +174,15 @@ export class MediaService { return JobStatus.FAILED; } - await this.storageCore.moveAssetImage(asset, AssetPathType.PREVIEW, image.previewFormat); - await this.storageCore.moveAssetImage(asset, AssetPathType.THUMBNAIL, image.thumbnailFormat); + await this.storageCore.moveAssetImage(asset, AssetPathType.PREVIEW, image.preview.format); + await this.storageCore.moveAssetImage(asset, AssetPathType.THUMBNAIL, image.thumbnail.format); await this.storageCore.moveAssetVideo(asset); return JobStatus.SUCCESS; } async handleGeneratePreview({ id }: IEntityJob): Promise { - const [{ image }, [asset]] = await Promise.all([ - this.configCore.getConfig({ withCache: true }), - this.assetRepository.getByIds([id], { exifInfo: true, files: true }), - ]); + const [asset] = await this.assetRepository.getByIds([id], { exifInfo: true, files: true }); if (!asset) { return JobStatus.FAILED; } @@ -195,7 +191,7 @@ export class MediaService { return JobStatus.SKIPPED; } - const previewPath = await this.generateThumbnail(asset, AssetPathType.PREVIEW, image.previewFormat); + const previewPath = await this.generateThumbnail(asset, AssetPathType.PREVIEW); if (!previewPath) { return JobStatus.SKIPPED; } @@ -213,9 +209,9 @@ export class MediaService { return JobStatus.SUCCESS; } - private async generateThumbnail(asset: AssetEntity, type: GeneratedImageType, format: ImageFormat) { + private async generateThumbnail(asset: AssetEntity, type: GeneratedImageType) { const { image, ffmpeg } = await this.configCore.getConfig({ withCache: true }); - const size = type === AssetPathType.PREVIEW ? image.previewSize : image.thumbnailSize; + const { size, format, quality } = image[type]; const path = StorageCore.getImagePath(asset, type, format); this.storageCore.ensureFolders(path); @@ -226,13 +222,13 @@ export class MediaService { const didExtract = shouldExtract && (await this.mediaRepository.extract(asset.originalPath, extractedPath)); try { - const useExtracted = didExtract && (await this.shouldUseExtractedImage(extractedPath, image.previewSize)); + const useExtracted = didExtract && (await this.shouldUseExtractedImage(extractedPath, image.preview.size)); const colorspace = this.isSRGB(asset) ? Colorspace.SRGB : image.colorspace; const imageOptions = { format, size, colorspace, - quality: image.quality, + quality, processInvalidImages: process.env.IMMICH_PROCESS_INVALID_IMAGES === 'true', }; @@ -274,10 +270,7 @@ export class MediaService { } async handleGenerateThumbnail({ id }: IEntityJob): Promise { - const [{ image }, [asset]] = await Promise.all([ - this.configCore.getConfig({ withCache: true }), - this.assetRepository.getByIds([id], { exifInfo: true, files: true }), - ]); + const [asset] = await this.assetRepository.getByIds([id], { exifInfo: true, files: true }); if (!asset) { return JobStatus.FAILED; } @@ -286,7 +279,7 @@ export class MediaService { return JobStatus.SKIPPED; } - const thumbnailPath = await this.generateThumbnail(asset, AssetPathType.THUMBNAIL, image.thumbnailFormat); + const thumbnailPath = await this.generateThumbnail(asset, AssetPathType.THUMBNAIL); if (!thumbnailPath) { return JobStatus.SKIPPED; } diff --git a/server/src/services/person.service.ts b/server/src/services/person.service.ts index 7cb76d1a71535..651c8eebee54e 100644 --- a/server/src/services/person.service.ts +++ b/server/src/services/person.service.ts @@ -574,7 +574,7 @@ export class PersonService { format: ImageFormat.JPEG, size: FACE_THUMBNAIL_SIZE, colorspace: image.colorspace, - quality: image.quality, + quality: image.thumbnail.quality, crop: this.getCrop({ old: { width: oldWidth, height: oldHeight }, new: { width, height } }, { x1, y1, x2, y2 }), processInvalidImages: process.env.IMMICH_PROCESS_INVALID_IMAGES === 'true', } as const; diff --git a/server/src/services/system-config.service.spec.ts b/server/src/services/system-config.service.spec.ts index 8b4fb0bc2fd3c..514d8aa0f8d58 100644 --- a/server/src/services/system-config.service.spec.ts +++ b/server/src/services/system-config.service.spec.ts @@ -135,11 +135,16 @@ const updatedConfig = Object.freeze({ template: '{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}', }, image: { - thumbnailFormat: ImageFormat.WEBP, - thumbnailSize: 250, - previewFormat: ImageFormat.JPEG, - previewSize: 1440, - quality: 80, + thumbnail: { + size: 250, + format: ImageFormat.WEBP, + quality: 80, + }, + preview: { + size: 1440, + format: ImageFormat.JPEG, + quality: 80, + }, colorspace: Colorspace.P3, extractEmbedded: false, }, diff --git a/web/src/lib/components/admin-page/settings/image/image-settings.svelte b/web/src/lib/components/admin-page/settings/image/image-settings.svelte index d6fc814b98e4c..b5e381d5f87a0 100644 --- a/web/src/lib/components/admin-page/settings/image/image-settings.svelte +++ b/web/src/lib/components/admin-page/settings/image/image-settings.svelte @@ -11,6 +11,7 @@ SettingInputFieldType, } from '$lib/components/shared-components/settings/setting-input-field.svelte'; import { t } from 'svelte-i18n'; + import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte'; export let savedConfig: SystemConfigDto; export let defaultConfig: SystemConfigDto; @@ -24,73 +25,96 @@
    - + + - + - + + - + + - + + + + Date: Sat, 28 Sep 2024 13:47:24 -0400 Subject: [PATCH 118/123] feat(server): generate all thumbnails for an asset in one job (#13012) * wip cleanup add success logs, rename method do thumbhash too fixes fix tests handle `notify` wip refactor refactor * update tests * update sql * pr feedback * remove unused code * formatting --- server/src/config.ts | 6 +- server/src/dtos/system-config.dto.ts | 2 +- server/src/interfaces/asset.interface.ts | 9 +- server/src/interfaces/job.interface.ts | 8 +- server/src/interfaces/media.interface.ts | 44 +- server/src/queries/asset.repository.sql | 24 + server/src/repositories/asset.repository.ts | 9 +- server/src/repositories/job.repository.ts | 4 +- server/src/repositories/media.repository.ts | 68 ++- server/src/services/asset.service.spec.ts | 2 +- server/src/services/asset.service.ts | 2 +- server/src/services/job.service.spec.ts | 34 +- server/src/services/job.service.ts | 49 +- server/src/services/media.service.spec.ts | 567 +++++++++--------- server/src/services/media.service.ts | 221 +++---- server/src/services/microservices.service.ts | 4 +- .../src/services/notification.service.spec.ts | 2 +- server/src/services/notification.service.ts | 2 +- server/src/services/person.service.spec.ts | 47 +- server/src/services/person.service.ts | 6 +- .../repositories/asset.repository.mock.ts | 1 + .../repositories/media.repository.mock.ts | 5 +- 22 files changed, 574 insertions(+), 542 deletions(-) diff --git a/server/src/config.ts b/server/src/config.ts index 3317351f9ff3a..53374d581f6ba 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -20,7 +20,7 @@ import { VideoContainer, } from 'src/enum'; import { ConcurrentQueueName, QueueName } from 'src/interfaces/job.interface'; -import { ImageOutputConfig } from 'src/interfaces/media.interface'; +import { ImageOptions } from 'src/interfaces/media.interface'; export interface SystemConfig { ffmpeg: { @@ -110,8 +110,8 @@ export interface SystemConfig { template: string; }; image: { - thumbnail: ImageOutputConfig; - preview: ImageOutputConfig; + thumbnail: ImageOptions; + preview: ImageOptions; colorspace: Colorspace; extractEmbedded: boolean; }; diff --git a/server/src/dtos/system-config.dto.ts b/server/src/dtos/system-config.dto.ts index c12a54cd613e6..039dbd20ff36a 100644 --- a/server/src/dtos/system-config.dto.ts +++ b/server/src/dtos/system-config.dto.ts @@ -492,7 +492,7 @@ class SystemConfigGeneratedImageDto { size!: number; } -class SystemConfigImageDto { +export class SystemConfigImageDto { @Type(() => SystemConfigGeneratedImageDto) @ValidateNested() @IsObject() diff --git a/server/src/interfaces/asset.interface.ts b/server/src/interfaces/asset.interface.ts index c6808e3aa87a6..750a85209474c 100644 --- a/server/src/interfaces/asset.interface.ts +++ b/server/src/interfaces/asset.interface.ts @@ -141,6 +141,12 @@ export interface AssetUpdateDuplicateOptions { duplicateIds: string[]; } +export interface UpsertFileOptions { + assetId: string; + type: AssetFileType; + path: string; +} + export type AssetPathEntity = Pick; export const IAssetRepository = 'IAssetRepository'; @@ -194,5 +200,6 @@ export interface IAssetRepository { getDuplicates(options: AssetBuilderOptions): Promise; getAllForUserFullSync(options: AssetFullSyncOptions): Promise; getChangedDeltaSync(options: AssetDeltaSyncOptions): Promise; - upsertFile(options: { assetId: string; type: AssetFileType; path: string }): Promise; + upsertFile(file: UpsertFileOptions): Promise; + upsertFiles(files: UpsertFileOptions[]): Promise; } diff --git a/server/src/interfaces/job.interface.ts b/server/src/interfaces/job.interface.ts index af2726b858aee..aa3090675e3fe 100644 --- a/server/src/interfaces/job.interface.ts +++ b/server/src/interfaces/job.interface.ts @@ -37,9 +37,7 @@ export enum JobName { // thumbnails QUEUE_GENERATE_THUMBNAILS = 'queue-generate-thumbnails', - GENERATE_PREVIEW = 'generate-preview', - GENERATE_THUMBNAIL = 'generate-thumbnail', - GENERATE_THUMBHASH = 'generate-thumbhash', + GENERATE_THUMBNAILS = 'generate-thumbnails', GENERATE_PERSON_THUMBNAIL = 'generate-person-thumbnail', // metadata @@ -212,9 +210,7 @@ export type JobItem = // Thumbnails | { name: JobName.QUEUE_GENERATE_THUMBNAILS; data: IBaseJob } - | { name: JobName.GENERATE_PREVIEW; data: IEntityJob } - | { name: JobName.GENERATE_THUMBNAIL; data: IEntityJob } - | { name: JobName.GENERATE_THUMBHASH; data: IEntityJob } + | { name: JobName.GENERATE_THUMBNAILS; data: IEntityJob } // User | { name: JobName.USER_DELETE_CHECK; data?: IBaseJob } diff --git a/server/src/interfaces/media.interface.ts b/server/src/interfaces/media.interface.ts index 64ba6236e80f0..2bc8ccde36d8b 100644 --- a/server/src/interfaces/media.interface.ts +++ b/server/src/interfaces/media.interface.ts @@ -10,16 +10,44 @@ export interface CropOptions { height: number; } -export interface ImageOutputConfig { +export interface ImageOptions { format: ImageFormat; quality: number; size: number; } -export interface ThumbnailOptions extends ImageOutputConfig { +export interface RawImageInfo { + width: number; + height: number; + channels: 1 | 2 | 3 | 4; +} + +interface DecodeImageOptions { colorspace: string; crop?: CropOptions; processInvalidImages: boolean; + raw?: RawImageInfo; +} + +export interface DecodeToBufferOptions extends DecodeImageOptions { + size: number; +} + +export type GenerateThumbnailOptions = ImageOptions & DecodeImageOptions; + +export type GenerateThumbnailFromBufferOptions = GenerateThumbnailOptions & { raw: RawImageInfo }; + +export type GenerateThumbhashOptions = DecodeImageOptions; + +export type GenerateThumbhashFromBufferOptions = GenerateThumbhashOptions & { raw: RawImageInfo }; + +export interface GenerateThumbnailsOptions { + colorspace: string; + crop?: CropOptions; + preview?: ImageOptions; + processInvalidImages: boolean; + thumbhash?: boolean; + thumbnail?: ImageOptions; } export interface VideoStreamInfo { @@ -78,6 +106,11 @@ export interface BitrateDistribution { unit: string; } +export interface ImageBuffer { + data: Buffer; + info: RawImageInfo; +} + export interface VideoCodecSWConfig { getCommand(target: TranscodeTarget, videoStream: VideoStreamInfo, audioStream: AudioStreamInfo): TranscodeCommand; } @@ -93,8 +126,11 @@ export interface ProbeOptions { export interface IMediaRepository { // image extract(input: string, output: string): Promise; - generateThumbnail(input: string | Buffer, output: string, options: ThumbnailOptions): Promise; - generateThumbhash(imagePath: string): Promise; + decodeImage(input: string, options: DecodeToBufferOptions): Promise; + generateThumbnail(input: string, options: GenerateThumbnailOptions, outputFile: string): Promise; + generateThumbnail(input: Buffer, options: GenerateThumbnailFromBufferOptions, outputFile: string): Promise; + generateThumbhash(input: string, options: GenerateThumbhashOptions): Promise; + generateThumbhash(input: Buffer, options: GenerateThumbhashFromBufferOptions): Promise; getImageDimensions(input: string): Promise; // video diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index 69309325848ed..eda91482bb1d0 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -1132,3 +1132,27 @@ RETURNING "id", "createdAt", "updatedAt" + +-- AssetRepository.upsertFiles +INSERT INTO + "asset_files" ( + "id", + "assetId", + "createdAt", + "updatedAt", + "type", + "path" + ) +VALUES + (DEFAULT, $1, DEFAULT, DEFAULT, $2, $3) +ON CONFLICT ("assetId", "type") DO +UPDATE +SET + "assetId" = EXCLUDED."assetId", + "type" = EXCLUDED."type", + "path" = EXCLUDED."path", + "updatedAt" = DEFAULT +RETURNING + "id", + "createdAt", + "updatedAt" diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index 0ec347ed77ab7..8bca755c32e26 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -801,7 +801,12 @@ export class AssetRepository implements IAssetRepository { } @GenerateSql({ params: [{ assetId: DummyValue.UUID, type: AssetFileType.PREVIEW, path: '/path/to/file' }] }) - async upsertFile({ assetId, type, path }: { assetId: string; type: AssetFileType; path: string }): Promise { - await this.fileRepository.upsert({ assetId, type, path }, { conflictPaths: ['assetId', 'type'] }); + async upsertFile(file: { assetId: string; type: AssetFileType; path: string }): Promise { + await this.fileRepository.upsert(file, { conflictPaths: ['assetId', 'type'] }); + } + + @GenerateSql({ params: [{ assetId: DummyValue.UUID, type: AssetFileType.PREVIEW, path: '/path/to/file' }] }) + async upsertFiles(files: { assetId: string; type: AssetFileType; path: string }[]): Promise { + await this.fileRepository.upsert(files, { conflictPaths: ['assetId', 'type'] }); } } diff --git a/server/src/repositories/job.repository.ts b/server/src/repositories/job.repository.ts index cd4c7135be80f..3f154ee01615a 100644 --- a/server/src/repositories/job.repository.ts +++ b/server/src/repositories/job.repository.ts @@ -36,9 +36,7 @@ export const JOBS_TO_QUEUE: Record = { // thumbnails [JobName.QUEUE_GENERATE_THUMBNAILS]: QueueName.THUMBNAIL_GENERATION, - [JobName.GENERATE_PREVIEW]: QueueName.THUMBNAIL_GENERATION, - [JobName.GENERATE_THUMBNAIL]: QueueName.THUMBNAIL_GENERATION, - [JobName.GENERATE_THUMBHASH]: QueueName.THUMBNAIL_GENERATION, + [JobName.GENERATE_THUMBNAILS]: QueueName.THUMBNAIL_GENERATION, [JobName.GENERATE_PERSON_THUMBNAIL]: QueueName.THUMBNAIL_GENERATION, // tags diff --git a/server/src/repositories/media.repository.ts b/server/src/repositories/media.repository.ts index d001aa3158b0f..cca87f44f2b81 100644 --- a/server/src/repositories/media.repository.ts +++ b/server/src/repositories/media.repository.ts @@ -8,10 +8,12 @@ import sharp from 'sharp'; import { Colorspace, LogLevel } from 'src/enum'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { + DecodeToBufferOptions, + GenerateThumbhashOptions, + GenerateThumbnailOptions, IMediaRepository, ImageDimensions, ProbeOptions, - ThumbnailOptions, TranscodeCommand, VideoInfo, } from 'src/interfaces/media.interface'; @@ -57,19 +59,12 @@ export class MediaRepository implements IMediaRepository { return true; } - async generateThumbnail(input: string | Buffer, output: string, options: ThumbnailOptions): Promise { - // some invalid images can still be processed by sharp, but we want to fail on them by default to avoid crashes - const pipeline = sharp(input, { failOn: options.processInvalidImages ? 'none' : 'error', limitInputPixels: false }) - .pipelineColorspace(options.colorspace === Colorspace.SRGB ? 'srgb' : 'rgb16') - .rotate(); + decodeImage(input: string, options: DecodeToBufferOptions) { + return this.getImageDecodingPipeline(input, options).raw().toBuffer({ resolveWithObject: true }); + } - if (options.crop) { - pipeline.extract(options.crop); - } - - await pipeline - .resize(options.size, options.size, { fit: 'outside', withoutEnlargement: true }) - .withIccProfile(options.colorspace) + async generateThumbnail(input: string | Buffer, options: GenerateThumbnailOptions, output: string): Promise { + await this.getImageDecodingPipeline(input, options) .toFormat(options.format, { quality: options.quality, // this is default in libvips (except the threshold is 90), but we need to set it manually in sharp @@ -78,6 +73,40 @@ export class MediaRepository implements IMediaRepository { .toFile(output); } + private getImageDecodingPipeline(input: string | Buffer, options: DecodeToBufferOptions) { + let pipeline = sharp(input, { + // some invalid images can still be processed by sharp, but we want to fail on them by default to avoid crashes + failOn: options.processInvalidImages ? 'none' : 'error', + limitInputPixels: false, + raw: options.raw, + }); + + if (!options.raw) { + pipeline = pipeline + .pipelineColorspace(options.colorspace === Colorspace.SRGB ? 'srgb' : 'rgb16') + .withIccProfile(options.colorspace) + .rotate(); + } + + if (options.crop) { + pipeline = pipeline.extract(options.crop); + } + + return pipeline.resize(options.size, options.size, { fit: 'outside', withoutEnlargement: true }); + } + + async generateThumbhash(input: string | Buffer, options: GenerateThumbhashOptions): Promise { + const [{ rgbaToThumbHash }, { data, info }] = await Promise.all([ + import('thumbhash'), + sharp(input, options) + .resize(100, 100, { fit: 'inside', withoutEnlargement: true }) + .raw() + .ensureAlpha() + .toBuffer({ resolveWithObject: true }), + ]); + return Buffer.from(rgbaToThumbHash(info.width, info.height, data)); + } + async probe(input: string, options?: ProbeOptions): Promise { const results = await probe(input, options?.countFrames ? ['-count_packets'] : []); // gets frame count quickly: https://stackoverflow.com/a/28376817 return { @@ -150,19 +179,6 @@ export class MediaRepository implements IMediaRepository { }); } - async generateThumbhash(imagePath: string): Promise { - const maxSize = 100; - - const { data, info } = await sharp(imagePath) - .resize(maxSize, maxSize, { fit: 'inside', withoutEnlargement: true }) - .raw() - .ensureAlpha() - .toBuffer({ resolveWithObject: true }); - - const thumbhash = await import('thumbhash'); - return Buffer.from(thumbhash.rgbaToThumbHash(info.width, info.height, data)); - } - async getImageDimensions(input: string): Promise { const { width = 0, height = 0 } = await sharp(input).metadata(); return { width, height }; diff --git a/server/src/services/asset.service.spec.ts b/server/src/services/asset.service.spec.ts index 2e2d676939336..f36d26fa7c5b3 100755 --- a/server/src/services/asset.service.spec.ts +++ b/server/src/services/asset.service.spec.ts @@ -395,7 +395,7 @@ describe(AssetService.name, () => { it('should run the refresh thumbnails job', async () => { accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); await sut.run(authStub.admin, { assetIds: ['asset-1'], name: AssetJobName.REGENERATE_THUMBNAIL }); - expect(jobMock.queueAll).toHaveBeenCalledWith([{ name: JobName.GENERATE_PREVIEW, data: { id: 'asset-1' } }]); + expect(jobMock.queueAll).toHaveBeenCalledWith([{ name: JobName.GENERATE_THUMBNAILS, data: { id: 'asset-1' } }]); }); it('should run the transcode video', async () => { diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index b3f824f226c1a..aa88eaf9576b4 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -322,7 +322,7 @@ export class AssetService { } case AssetJobName.REGENERATE_THUMBNAIL: { - jobs.push({ name: JobName.GENERATE_PREVIEW, data: { id } }); + jobs.push({ name: JobName.GENERATE_THUMBNAILS, data: { id } }); break; } diff --git a/server/src/services/job.service.spec.ts b/server/src/services/job.service.spec.ts index 1c810facb453a..c2d7a29b9f0cb 100644 --- a/server/src/services/job.service.spec.ts +++ b/server/src/services/job.service.spec.ts @@ -288,7 +288,7 @@ describe(JobService.name, () => { }, { item: { name: JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE, data: { id: 'asset-1', source: 'upload' } }, - jobs: [JobName.GENERATE_PREVIEW], + jobs: [JobName.GENERATE_THUMBNAILS], }, { item: { name: JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE, data: { id: 'asset-1' } }, @@ -299,28 +299,16 @@ describe(JobService.name, () => { jobs: [], }, { - item: { name: JobName.GENERATE_PREVIEW, data: { id: 'asset-1' } }, - jobs: [JobName.GENERATE_THUMBNAIL, JobName.GENERATE_THUMBHASH], + item: { name: JobName.GENERATE_THUMBNAILS, data: { id: 'asset-1' } }, + jobs: [], }, { - item: { name: JobName.GENERATE_PREVIEW, data: { id: 'asset-1', source: 'upload' } }, - jobs: [ - JobName.GENERATE_THUMBNAIL, - JobName.GENERATE_THUMBHASH, - JobName.SMART_SEARCH, - JobName.FACE_DETECTION, - JobName.VIDEO_CONVERSION, - ], + item: { name: JobName.GENERATE_THUMBNAILS, data: { id: 'asset-1', source: 'upload' } }, + jobs: [JobName.SMART_SEARCH, JobName.FACE_DETECTION, JobName.VIDEO_CONVERSION], }, { - item: { name: JobName.GENERATE_PREVIEW, data: { id: 'asset-live-image', source: 'upload' } }, - jobs: [ - JobName.GENERATE_THUMBNAIL, - JobName.GENERATE_THUMBHASH, - JobName.SMART_SEARCH, - JobName.FACE_DETECTION, - JobName.VIDEO_CONVERSION, - ], + item: { name: JobName.GENERATE_THUMBNAILS, data: { id: 'asset-live-image', source: 'upload' } }, + jobs: [JobName.SMART_SEARCH, JobName.FACE_DETECTION, JobName.VIDEO_CONVERSION], }, { item: { name: JobName.SMART_SEARCH, data: { id: 'asset-1' } }, @@ -338,11 +326,11 @@ describe(JobService.name, () => { for (const { item, jobs } of tests) { it(`should queue ${jobs.length} jobs when a ${item.name} job finishes successfully`, async () => { - if (item.name === JobName.GENERATE_PREVIEW && item.data.source === 'upload') { + if (item.name === JobName.GENERATE_THUMBNAILS && item.data.source === 'upload') { if (item.data.id === 'asset-live-image') { - assetMock.getByIds.mockResolvedValue([assetStub.livePhotoStillAsset]); + assetMock.getByIdsWithAllRelations.mockResolvedValue([assetStub.livePhotoStillAsset]); } else { - assetMock.getByIds.mockResolvedValue([assetStub.livePhotoMotionAsset]); + assetMock.getByIdsWithAllRelations.mockResolvedValue([assetStub.livePhotoMotionAsset]); } } @@ -361,7 +349,7 @@ describe(JobService.name, () => { } }); - it(`should not queue any jobs when ${item.name} finishes with 'false'`, async () => { + it(`should not queue any jobs when ${item.name} fails`, async () => { await sut.init(makeMockHandlers(JobStatus.FAILED)); await jobMock.addHandler.mock.calls[0][2](item); diff --git a/server/src/services/job.service.ts b/server/src/services/job.service.ts index f978f334108d1..9c73e71cbf684 100644 --- a/server/src/services/job.service.ts +++ b/server/src/services/job.service.ts @@ -281,7 +281,7 @@ export class JobService { case JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE: { if (item.data.source === 'upload' || item.data.source === 'copy') { - await this.jobRepository.queue({ name: JobName.GENERATE_PREVIEW, data: item.data }); + await this.jobRepository.queue({ name: JobName.GENERATE_THUMBNAILS, data: item.data }); } break; } @@ -295,40 +295,33 @@ export class JobService { break; } - case JobName.GENERATE_PREVIEW: { - const jobs: JobItem[] = [ - { name: JobName.GENERATE_THUMBNAIL, data: item.data }, - { name: JobName.GENERATE_THUMBHASH, data: item.data }, - ]; - - if (item.data.source === 'upload') { - jobs.push({ name: JobName.SMART_SEARCH, data: item.data }, { name: JobName.FACE_DETECTION, data: item.data }); - - const [asset] = await this.assetRepository.getByIds([item.data.id]); - if (asset) { - if (asset.type === AssetType.VIDEO) { - jobs.push({ name: JobName.VIDEO_CONVERSION, data: item.data }); - } else if (asset.livePhotoVideoId) { - jobs.push({ name: JobName.VIDEO_CONVERSION, data: { id: asset.livePhotoVideoId } }); - } - } - } - - await this.jobRepository.queueAll(jobs); - break; - } - - case JobName.GENERATE_THUMBNAIL: { - if (!(item.data.notify || item.data.source === 'upload')) { + case JobName.GENERATE_THUMBNAILS: { + if (!item.data.notify && item.data.source !== 'upload') { break; } const [asset] = await this.assetRepository.getByIdsWithAllRelations([item.data.id]); + if (!asset) { + this.logger.warn(`Could not find asset ${item.data.id} after generating thumbnails`); + break; + } - // Only live-photo motion part will be marked as not visible immediately on upload. Skip notifying clients - if (asset && asset.isVisible) { + const jobs: JobItem[] = [ + { name: JobName.SMART_SEARCH, data: item.data }, + { name: JobName.FACE_DETECTION, data: item.data }, + ]; + + if (asset.type === AssetType.VIDEO) { + jobs.push({ name: JobName.VIDEO_CONVERSION, data: item.data }); + } else if (asset.livePhotoVideoId) { + jobs.push({ name: JobName.VIDEO_CONVERSION, data: { id: asset.livePhotoVideoId } }); + } + + await this.jobRepository.queueAll(jobs); + if (asset.isVisible) { this.eventRepository.clientSend(ClientEvent.UPLOAD_SUCCESS, asset.ownerId, mapAsset(asset)); } + break; } diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts index c0903fa101412..88e9f478bdf35 100644 --- a/server/src/services/media.service.spec.ts +++ b/server/src/services/media.service.spec.ts @@ -15,7 +15,7 @@ import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interfac import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; -import { IMediaRepository } from 'src/interfaces/media.interface'; +import { IMediaRepository, RawImageInfo } from 'src/interfaces/media.interface'; import { IMoveRepository } from 'src/interfaces/move.interface'; import { IPersonRepository } from 'src/interfaces/person.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; @@ -94,7 +94,7 @@ describe(MediaService.name, () => { expect(assetMock.getWithout).not.toHaveBeenCalled(); expect(jobMock.queueAll).toHaveBeenCalledWith([ { - name: JobName.GENERATE_PREVIEW, + name: JobName.GENERATE_THUMBNAILS, data: { id: assetStub.image.id }, }, ]); @@ -127,7 +127,7 @@ describe(MediaService.name, () => { expect(assetMock.getWithout).not.toHaveBeenCalled(); expect(jobMock.queueAll).toHaveBeenCalledWith([ { - name: JobName.GENERATE_PREVIEW, + name: JobName.GENERATE_THUMBNAILS, data: { id: assetStub.trashed.id }, }, ]); @@ -152,7 +152,7 @@ describe(MediaService.name, () => { expect(assetMock.getWithout).not.toHaveBeenCalled(); expect(jobMock.queueAll).toHaveBeenCalledWith([ { - name: JobName.GENERATE_PREVIEW, + name: JobName.GENERATE_THUMBNAILS, data: { id: assetStub.archived.id }, }, ]); @@ -202,7 +202,7 @@ describe(MediaService.name, () => { expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.THUMBNAIL); expect(jobMock.queueAll).toHaveBeenCalledWith([ { - name: JobName.GENERATE_PREVIEW, + name: JobName.GENERATE_THUMBNAILS, data: { id: assetStub.image.id }, }, ]); @@ -226,7 +226,7 @@ describe(MediaService.name, () => { expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.THUMBNAIL); expect(jobMock.queueAll).toHaveBeenCalledWith([ { - name: JobName.GENERATE_THUMBNAIL, + name: JobName.GENERATE_THUMBNAILS, data: { id: assetStub.image.id }, }, ]); @@ -250,7 +250,7 @@ describe(MediaService.name, () => { expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.THUMBNAIL); expect(jobMock.queueAll).toHaveBeenCalledWith([ { - name: JobName.GENERATE_THUMBHASH, + name: JobName.GENERATE_THUMBNAILS, data: { id: assetStub.image.id }, }, ]); @@ -259,10 +259,19 @@ describe(MediaService.name, () => { }); }); - describe('handleGeneratePreview', () => { + describe('handleGenerateThumbnails', () => { + let rawBuffer: Buffer; + let rawInfo: RawImageInfo; + + beforeEach(() => { + rawBuffer = Buffer.from('image data'); + rawInfo = { width: 100, height: 100, channels: 3 }; + mediaMock.decodeImage.mockResolvedValue({ data: rawBuffer, info: rawInfo }); + }); + it('should skip thumbnail generation if asset not found', async () => { - assetMock.getByIds.mockResolvedValue([]); - await sut.handleGeneratePreview({ id: assetStub.image.id }); + await sut.handleGenerateThumbnails({ id: assetStub.image.id }); + expect(mediaMock.generateThumbnail).not.toHaveBeenCalled(); expect(assetMock.update).not.toHaveBeenCalledWith(); }); @@ -270,80 +279,100 @@ describe(MediaService.name, () => { it('should skip video thumbnail generation if no video stream', async () => { mediaMock.probe.mockResolvedValue(probeStub.noVideoStreams); assetMock.getByIds.mockResolvedValue([assetStub.video]); - await sut.handleGeneratePreview({ id: assetStub.image.id }); + await sut.handleGenerateThumbnails({ id: assetStub.image.id }); expect(mediaMock.generateThumbnail).not.toHaveBeenCalled(); expect(assetMock.update).not.toHaveBeenCalledWith(); }); it('should skip invisible assets', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.livePhotoMotionAsset]); + assetMock.getById.mockResolvedValue(assetStub.livePhotoMotionAsset); - expect(await sut.handleGeneratePreview({ id: assetStub.livePhotoMotionAsset.id })).toEqual(JobStatus.SKIPPED); + expect(await sut.handleGenerateThumbnails({ id: assetStub.livePhotoMotionAsset.id })).toEqual(JobStatus.SKIPPED); expect(mediaMock.generateThumbnail).not.toHaveBeenCalled(); expect(assetMock.update).not.toHaveBeenCalledWith(); }); - it.each(Object.values(ImageFormat))('should generate a %s preview for an image when specified', async (format) => { - systemMock.get.mockResolvedValue({ image: { preview: { format } } }); - assetMock.getByIds.mockResolvedValue([assetStub.image]); - const previewPath = `upload/thumbs/user-id/as/se/asset-id-preview.${format}`; - - await sut.handleGeneratePreview({ id: assetStub.image.id }); - - expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se'); - expect(mediaMock.generateThumbnail).toHaveBeenCalledWith('/original/path.jpg', previewPath, { - size: 1440, - format, - quality: 80, - colorspace: Colorspace.SRGB, - processInvalidImages: false, - }); - expect(assetMock.upsertFile).toHaveBeenCalledWith({ - assetId: 'asset-id', - type: AssetFileType.PREVIEW, - path: previewPath, - }); - }); - it('should delete previous preview if different path', async () => { systemMock.get.mockResolvedValue({ image: { thumbnail: { format: ImageFormat.WEBP } } }); - assetMock.getByIds.mockResolvedValue([assetStub.image]); + assetMock.getById.mockResolvedValue(assetStub.image); - await sut.handleGeneratePreview({ id: assetStub.image.id }); + await sut.handleGenerateThumbnails({ id: assetStub.image.id }); expect(storageMock.unlink).toHaveBeenCalledWith('/uploads/user-id/thumbs/path.jpg'); }); - it('should generate a P3 thumbnail for a wide gamut image', async () => { - assetMock.getByIds.mockResolvedValue([ - { ...assetStub.image, exifInfo: { profileDescription: 'Adobe RGB', bitsPerSample: 14 } as ExifEntity }, - ]); - await sut.handleGeneratePreview({ id: assetStub.image.id }); + it('should generate P3 thumbnails for a wide gamut image', async () => { + assetMock.getById.mockResolvedValue({ + ...assetStub.image, + exifInfo: { profileDescription: 'Adobe RGB', bitsPerSample: 14 } as ExifEntity, + }); + const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8'); + mediaMock.generateThumbhash.mockResolvedValue(thumbhashBuffer); + + await sut.handleGenerateThumbnails({ id: assetStub.image.id }); expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se'); - expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( - '/original/path.jpg', - 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', - { - size: 1440, - format: ImageFormat.JPEG, - quality: 80, - colorspace: Colorspace.P3, - processInvalidImages: false, - }, - ); - expect(assetMock.upsertFile).toHaveBeenCalledWith({ - assetId: 'asset-id', - type: AssetFileType.PREVIEW, - path: 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', + + expect(mediaMock.decodeImage).toHaveBeenCalledOnce(); + expect(mediaMock.decodeImage).toHaveBeenCalledWith(assetStub.image.originalPath, { + colorspace: Colorspace.P3, + processInvalidImages: false, + size: 1440, }); + + expect(mediaMock.generateThumbnail).toHaveBeenCalledTimes(2); + expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( + rawBuffer, + { + colorspace: Colorspace.P3, + format: ImageFormat.JPEG, + size: 1440, + quality: 80, + processInvalidImages: false, + raw: rawInfo, + }, + 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', + ); + expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( + rawBuffer, + { + colorspace: Colorspace.P3, + format: ImageFormat.WEBP, + size: 250, + quality: 80, + processInvalidImages: false, + raw: rawInfo, + }, + 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', + ); + + expect(mediaMock.generateThumbhash).toHaveBeenCalledOnce(); + expect(mediaMock.generateThumbhash).toHaveBeenCalledWith(rawBuffer, { + colorspace: Colorspace.P3, + processInvalidImages: false, + raw: rawInfo, + }); + + expect(assetMock.upsertFiles).toHaveBeenCalledWith([ + { + assetId: 'asset-id', + type: AssetFileType.PREVIEW, + path: 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', + }, + { + assetId: 'asset-id', + type: AssetFileType.THUMBNAIL, + path: 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', + }, + ]); + expect(assetMock.update).toHaveBeenCalledWith({ id: 'asset-id', thumbhash: thumbhashBuffer }); }); it('should generate a thumbnail for a video', async () => { mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p); - assetMock.getByIds.mockResolvedValue([assetStub.video]); - await sut.handleGeneratePreview({ id: assetStub.video.id }); + assetMock.getById.mockResolvedValue(assetStub.video); + await sut.handleGenerateThumbnails({ id: assetStub.video.id }); expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se'); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -361,17 +390,24 @@ describe(MediaService.name, () => { twoPass: false, }), ); - expect(assetMock.upsertFile).toHaveBeenCalledWith({ - assetId: 'asset-id', - type: AssetFileType.PREVIEW, - path: 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', - }); + expect(assetMock.upsertFiles).toHaveBeenCalledWith([ + { + assetId: 'asset-id', + type: AssetFileType.PREVIEW, + path: 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', + }, + { + assetId: 'asset-id', + type: AssetFileType.THUMBNAIL, + path: 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', + }, + ]); }); it('should tonemap thumbnail for hdr video', async () => { mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); - assetMock.getByIds.mockResolvedValue([assetStub.video]); - await sut.handleGeneratePreview({ id: assetStub.video.id }); + assetMock.getById.mockResolvedValue(assetStub.video); + await sut.handleGenerateThumbnails({ id: assetStub.video.id }); expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se'); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -389,11 +425,18 @@ describe(MediaService.name, () => { twoPass: false, }), ); - expect(assetMock.upsertFile).toHaveBeenCalledWith({ - assetId: 'asset-id', - type: AssetFileType.PREVIEW, - path: 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', - }); + expect(assetMock.upsertFiles).toHaveBeenCalledWith([ + { + assetId: 'asset-id', + type: AssetFileType.PREVIEW, + path: 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', + }, + { + assetId: 'asset-id', + type: AssetFileType.THUMBNAIL, + path: 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', + }, + ]); }); it('should always generate video thumbnail in one pass', async () => { @@ -401,8 +444,8 @@ describe(MediaService.name, () => { systemMock.get.mockResolvedValue({ ffmpeg: { twoPass: true, maxBitrate: '5000k' }, }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); - await sut.handleGeneratePreview({ id: assetStub.video.id }); + assetMock.getById.mockResolvedValue(assetStub.video); + await sut.handleGenerateThumbnails({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -424,8 +467,8 @@ describe(MediaService.name, () => { it('should use scaling divisible by 2 even when using quick sync', async () => { mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p); systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); - await sut.handleGeneratePreview({ id: assetStub.video.id }); + assetMock.getById.mockResolvedValue(assetStub.video); + await sut.handleGenerateThumbnails({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -438,233 +481,207 @@ describe(MediaService.name, () => { ); }); - it('should run successfully', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.image]); - await sut.handleGeneratePreview({ id: assetStub.image.id }); - }); - }); + it.each(Object.values(ImageFormat))('should generate an image preview in %s format', async (format) => { + systemMock.get.mockResolvedValue({ image: { preview: { format } } }); + assetMock.getById.mockResolvedValue(assetStub.image); + const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8'); + mediaMock.generateThumbhash.mockResolvedValue(thumbhashBuffer); + const previewPath = `upload/thumbs/user-id/as/se/asset-id-preview.${format}`; + const thumbnailPath = `upload/thumbs/user-id/as/se/asset-id-thumbnail.webp`; - describe('handleGenerateThumbnail', () => { - it('should skip thumbnail generation if asset not found', async () => { - assetMock.getByIds.mockResolvedValue([]); - await sut.handleGenerateThumbnail({ id: assetStub.image.id }); - expect(mediaMock.generateThumbnail).not.toHaveBeenCalled(); - expect(assetMock.update).not.toHaveBeenCalledWith(); - }); + await sut.handleGenerateThumbnails({ id: assetStub.image.id }); - it('should skip invisible assets', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.livePhotoMotionAsset]); + expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se'); + expect(mediaMock.decodeImage).toHaveBeenCalledOnce(); + expect(mediaMock.decodeImage).toHaveBeenCalledWith(assetStub.image.originalPath, { + colorspace: Colorspace.SRGB, + processInvalidImages: false, + size: 1440, + }); - expect(await sut.handleGenerateThumbnail({ id: assetStub.livePhotoMotionAsset.id })).toEqual(JobStatus.SKIPPED); - - expect(mediaMock.generateThumbnail).not.toHaveBeenCalled(); - expect(assetMock.update).not.toHaveBeenCalledWith(); - }); - - it.each(Object.values(ImageFormat))( - 'should generate a %s thumbnail for an image when specified', - async (format) => { - systemMock.get.mockResolvedValue({ image: { thumbnail: { format } } }); - assetMock.getByIds.mockResolvedValue([assetStub.image]); - const thumbnailPath = `upload/thumbs/user-id/as/se/asset-id-thumbnail.${format}`; - - await sut.handleGenerateThumbnail({ id: assetStub.image.id }); - - expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se'); - expect(mediaMock.generateThumbnail).toHaveBeenCalledWith('/original/path.jpg', thumbnailPath, { - size: 250, - format, - quality: 80, + expect(mediaMock.generateThumbnail).toHaveBeenCalledTimes(2); + expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( + rawBuffer, + { colorspace: Colorspace.SRGB, + format, + size: 1440, + quality: 80, processInvalidImages: false, - }); - expect(assetMock.upsertFile).toHaveBeenCalledWith({ - assetId: 'asset-id', - type: AssetFileType.THUMBNAIL, - path: thumbnailPath, - }); - }, - ); + raw: rawInfo, + }, + previewPath, + ); + expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( + rawBuffer, + { + colorspace: Colorspace.SRGB, + format: ImageFormat.WEBP, + size: 250, + quality: 80, + processInvalidImages: false, + raw: rawInfo, + }, + thumbnailPath, + ); + }); + + it.each(Object.values(ImageFormat))('should generate an image thumbnail in %s format', async (format) => { + systemMock.get.mockResolvedValue({ image: { thumbnail: { format } } }); + assetMock.getById.mockResolvedValue(assetStub.image); + const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8'); + mediaMock.generateThumbhash.mockResolvedValue(thumbhashBuffer); + const previewPath = `upload/thumbs/user-id/as/se/asset-id-preview.jpeg`; + const thumbnailPath = `upload/thumbs/user-id/as/se/asset-id-thumbnail.${format}`; + + await sut.handleGenerateThumbnails({ id: assetStub.image.id }); + + expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se'); + expect(mediaMock.decodeImage).toHaveBeenCalledOnce(); + expect(mediaMock.decodeImage).toHaveBeenCalledWith(assetStub.image.originalPath, { + colorspace: Colorspace.SRGB, + processInvalidImages: false, + size: 1440, + }); + + expect(mediaMock.generateThumbnail).toHaveBeenCalledTimes(2); + expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( + rawBuffer, + { + colorspace: Colorspace.SRGB, + format: ImageFormat.JPEG, + size: 1440, + quality: 80, + processInvalidImages: false, + raw: rawInfo, + }, + previewPath, + ); + expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( + rawBuffer, + { + colorspace: Colorspace.SRGB, + format, + size: 250, + quality: 80, + processInvalidImages: false, + raw: rawInfo, + }, + thumbnailPath, + ); + }); it('should delete previous thumbnail if different path', async () => { systemMock.get.mockResolvedValue({ image: { thumbnail: { format: ImageFormat.WEBP } } }); - assetMock.getByIds.mockResolvedValue([assetStub.image]); + assetMock.getById.mockResolvedValue(assetStub.image); - await sut.handleGenerateThumbnail({ id: assetStub.image.id }); + await sut.handleGenerateThumbnails({ id: assetStub.image.id }); expect(storageMock.unlink).toHaveBeenCalledWith('/uploads/user-id/webp/path.ext'); }); - }); - it('should generate a P3 thumbnail for a wide gamut image', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.imageDng]); - await sut.handleGenerateThumbnail({ id: assetStub.image.id }); + it('should extract embedded image if enabled and available', async () => { + mediaMock.extract.mockResolvedValue(true); + mediaMock.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 }); + systemMock.get.mockResolvedValue({ image: { extractEmbedded: true } }); + assetMock.getById.mockResolvedValue(assetStub.imageDng); - expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se'); - expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( - assetStub.imageDng.originalPath, - 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', - { - format: ImageFormat.WEBP, - size: 250, - quality: 80, + await sut.handleGenerateThumbnails({ id: assetStub.image.id }); + + const extractedPath = mediaMock.extract.mock.calls.at(-1)?.[1].toString(); + expect(mediaMock.decodeImage).toHaveBeenCalledOnce(); + expect(mediaMock.decodeImage).toHaveBeenCalledWith(extractedPath, { colorspace: Colorspace.P3, processInvalidImages: false, - }, - ); - expect(assetMock.upsertFile).toHaveBeenCalledWith({ - assetId: 'asset-id', - type: AssetFileType.THUMBNAIL, - path: 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', + size: 1440, + }); + expect(extractedPath?.endsWith('.tmp')).toBe(true); + expect(storageMock.unlink).toHaveBeenCalledWith(extractedPath); }); - }); - it('should extract embedded image if enabled and available', async () => { - mediaMock.extract.mockResolvedValue(true); - mediaMock.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 }); - systemMock.get.mockResolvedValue({ image: { extractEmbedded: true } }); - assetMock.getByIds.mockResolvedValue([assetStub.imageDng]); + it('should resize original image if embedded image is too small', async () => { + mediaMock.extract.mockResolvedValue(true); + mediaMock.getImageDimensions.mockResolvedValue({ width: 1000, height: 1000 }); + systemMock.get.mockResolvedValue({ image: { extractEmbedded: true } }); + assetMock.getById.mockResolvedValue(assetStub.imageDng); - await sut.handleGenerateThumbnail({ id: assetStub.image.id }); + await sut.handleGenerateThumbnails({ id: assetStub.image.id }); - const extractedPath = mediaMock.extract.mock.calls.at(-1)?.[1].toString(); - expect(mediaMock.generateThumbnail.mock.calls).toEqual([ - [ - extractedPath, - 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', - { - format: ImageFormat.WEBP, - size: 250, - quality: 80, - colorspace: Colorspace.P3, - processInvalidImages: false, - }, - ], - ]); - expect(extractedPath?.endsWith('.tmp')).toBe(true); - expect(storageMock.unlink).toHaveBeenCalledWith(extractedPath); - }); + expect(mediaMock.decodeImage).toHaveBeenCalledOnce(); + expect(mediaMock.decodeImage).toHaveBeenCalledWith(assetStub.imageDng.originalPath, { + colorspace: Colorspace.P3, + processInvalidImages: false, + size: 1440, + }); + const extractedPath = mediaMock.extract.mock.calls.at(-1)?.[1].toString(); + expect(extractedPath?.endsWith('.tmp')).toBe(true); + expect(storageMock.unlink).toHaveBeenCalledWith(extractedPath); + }); - it('should resize original image if embedded image is too small', async () => { - mediaMock.extract.mockResolvedValue(true); - mediaMock.getImageDimensions.mockResolvedValue({ width: 1000, height: 1000 }); - systemMock.get.mockResolvedValue({ image: { extractEmbedded: true } }); - assetMock.getByIds.mockResolvedValue([assetStub.imageDng]); + it('should resize original image if embedded image not found', async () => { + systemMock.get.mockResolvedValue({ image: { extractEmbedded: true } }); + assetMock.getById.mockResolvedValue(assetStub.imageDng); - await sut.handleGenerateThumbnail({ id: assetStub.image.id }); + await sut.handleGenerateThumbnails({ id: assetStub.image.id }); - expect(mediaMock.generateThumbnail.mock.calls).toEqual([ - [ + expect(mediaMock.decodeImage).toHaveBeenCalledOnce(); + expect(mediaMock.decodeImage).toHaveBeenCalledWith(assetStub.imageDng.originalPath, { + colorspace: Colorspace.P3, + processInvalidImages: false, + size: 1440, + }); + expect(mediaMock.getImageDimensions).not.toHaveBeenCalled(); + }); + + it('should resize original image if embedded image extraction is not enabled', async () => { + systemMock.get.mockResolvedValue({ image: { extractEmbedded: false } }); + assetMock.getById.mockResolvedValue(assetStub.imageDng); + + await sut.handleGenerateThumbnails({ id: assetStub.image.id }); + + expect(mediaMock.extract).not.toHaveBeenCalled(); + expect(mediaMock.decodeImage).toHaveBeenCalledOnce(); + expect(mediaMock.decodeImage).toHaveBeenCalledWith(assetStub.imageDng.originalPath, { + colorspace: Colorspace.P3, + processInvalidImages: false, + size: 1440, + }); + expect(mediaMock.getImageDimensions).not.toHaveBeenCalled(); + }); + + it('should process invalid images if enabled', async () => { + vi.stubEnv('IMMICH_PROCESS_INVALID_IMAGES', 'true'); + + assetMock.getById.mockResolvedValue(assetStub.imageDng); + + await sut.handleGenerateThumbnails({ id: assetStub.image.id }); + + expect(mediaMock.decodeImage).toHaveBeenCalledOnce(); + expect(mediaMock.decodeImage).toHaveBeenCalledWith( assetStub.imageDng.originalPath, + expect.objectContaining({ processInvalidImages: true }), + ); + + expect(mediaMock.generateThumbnail).toHaveBeenCalledTimes(2); + expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( + rawBuffer, + expect.objectContaining({ processInvalidImages: true }), + 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', + ); + expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( + rawBuffer, + expect.objectContaining({ processInvalidImages: true }), 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', - { - format: ImageFormat.WEBP, - size: 250, - quality: 80, - colorspace: Colorspace.P3, - processInvalidImages: false, - }, - ], - ]); - const extractedPath = mediaMock.extract.mock.calls.at(-1)?.[1].toString(); - expect(extractedPath?.endsWith('.tmp')).toBe(true); - expect(storageMock.unlink).toHaveBeenCalledWith(extractedPath); - }); + ); - it('should resize original image if embedded image not found', async () => { - systemMock.get.mockResolvedValue({ image: { extractEmbedded: true } }); - assetMock.getByIds.mockResolvedValue([assetStub.imageDng]); + expect(mediaMock.generateThumbhash).toHaveBeenCalledOnce(); + expect(mediaMock.generateThumbhash).toHaveBeenCalledWith( + rawBuffer, + expect.objectContaining({ processInvalidImages: true }), + ); - await sut.handleGenerateThumbnail({ id: assetStub.image.id }); - - expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( - assetStub.imageDng.originalPath, - 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', - { - format: ImageFormat.WEBP, - size: 250, - quality: 80, - colorspace: Colorspace.P3, - processInvalidImages: false, - }, - ); - expect(mediaMock.getImageDimensions).not.toHaveBeenCalled(); - }); - - it('should resize original image if embedded image extraction is not enabled', async () => { - systemMock.get.mockResolvedValue({ image: { extractEmbedded: false } }); - assetMock.getByIds.mockResolvedValue([assetStub.imageDng]); - - await sut.handleGenerateThumbnail({ id: assetStub.image.id }); - - expect(mediaMock.extract).not.toHaveBeenCalled(); - expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( - assetStub.imageDng.originalPath, - 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', - { - format: ImageFormat.WEBP, - size: 250, - quality: 80, - colorspace: Colorspace.P3, - processInvalidImages: false, - }, - ); - expect(mediaMock.getImageDimensions).not.toHaveBeenCalled(); - }); - - it('should process invalid images if enabled', async () => { - vi.stubEnv('IMMICH_PROCESS_INVALID_IMAGES', 'true'); - - assetMock.getByIds.mockResolvedValue([assetStub.imageDng]); - - await sut.handleGenerateThumbnail({ id: assetStub.image.id }); - - expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( - assetStub.imageDng.originalPath, - 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', - { - format: ImageFormat.WEBP, - size: 250, - quality: 80, - colorspace: Colorspace.P3, - processInvalidImages: true, - }, - ); - expect(mediaMock.getImageDimensions).not.toHaveBeenCalled(); - vi.unstubAllEnvs(); - }); - - describe('handleGenerateThumbhash', () => { - it('should skip thumbhash generation if asset not found', async () => { - assetMock.getByIds.mockResolvedValue([]); - await sut.handleGenerateThumbhash({ id: assetStub.image.id }); - expect(mediaMock.generateThumbhash).not.toHaveBeenCalled(); - }); - - it('should skip thumbhash generation if resize path is missing', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.noResizePath]); - await sut.handleGenerateThumbhash({ id: assetStub.noResizePath.id }); - expect(mediaMock.generateThumbhash).not.toHaveBeenCalled(); - }); - - it('should skip invisible assets', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.livePhotoMotionAsset]); - - expect(await sut.handleGenerateThumbhash({ id: assetStub.livePhotoMotionAsset.id })).toEqual(JobStatus.SKIPPED); - - expect(mediaMock.generateThumbhash).not.toHaveBeenCalled(); - expect(assetMock.update).not.toHaveBeenCalledWith(); - }); - - it('should generate a thumbhash', async () => { - const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8'); - assetMock.getByIds.mockResolvedValue([assetStub.image]); - mediaMock.generateThumbhash.mockResolvedValue(thumbhashBuffer); - - await sut.handleGenerateThumbhash({ id: assetStub.image.id }); - - expect(mediaMock.generateThumbhash).toHaveBeenCalledWith('/uploads/user-id/thumbs/path.jpg'); - expect(assetMock.update).toHaveBeenCalledWith({ id: 'asset-id', thumbhash: thumbhashBuffer }); + expect(mediaMock.getImageDimensions).not.toHaveBeenCalled(); + vi.unstubAllEnvs(); }); }); diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index 1b69c5acd5504..71f432e040c43 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -1,6 +1,7 @@ -import { Inject, Injectable, UnsupportedMediaTypeException } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; import { dirname } from 'node:path'; -import { GeneratedImageType, StorageCore } from 'src/cores/storage.core'; + +import { StorageCore } from 'src/cores/storage.core'; import { SystemConfigCore } from 'src/cores/system-config.core'; import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto'; import { AssetEntity } from 'src/entities/asset.entity'; @@ -18,7 +19,7 @@ import { VideoCodec, VideoContainer, } from 'src/enum'; -import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; +import { IAssetRepository, UpsertFileOptions, WithoutProperty } from 'src/interfaces/asset.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { IBaseJob, @@ -95,18 +96,10 @@ export class MediaService { for (const asset of assets) { const { previewFile, thumbnailFile } = getAssetFiles(asset.files); - if (!previewFile || force) { - jobs.push({ name: JobName.GENERATE_PREVIEW, data: { id: asset.id } }); + if (!previewFile || !thumbnailFile || !asset.thumbhash || force) { + jobs.push({ name: JobName.GENERATE_THUMBNAILS, data: { id: asset.id } }); continue; } - - if (!thumbnailFile) { - jobs.push({ name: JobName.GENERATE_THUMBNAIL, data: { id: asset.id } }); - } - - if (!asset.thumbhash) { - jobs.push({ name: JobName.GENERATE_THUMBHASH, data: { id: asset.id } }); - } } await this.jobRepository.queueAll(jobs); @@ -181,141 +174,127 @@ export class MediaService { return JobStatus.SUCCESS; } - async handleGeneratePreview({ id }: IEntityJob): Promise { - const [asset] = await this.assetRepository.getByIds([id], { exifInfo: true, files: true }); + async handleGenerateThumbnails({ id }: IEntityJob): Promise { + const asset = await this.assetRepository.getById(id, { exifInfo: true, files: true }); if (!asset) { + this.logger.warn(`Thumbnail generation failed for asset ${id}: not found`); return JobStatus.FAILED; } if (!asset.isVisible) { + this.logger.verbose(`Thumbnail generation skipped for asset ${id}: not visible`); return JobStatus.SKIPPED; } - const previewPath = await this.generateThumbnail(asset, AssetPathType.PREVIEW); - if (!previewPath) { + let generated: { previewPath: string; thumbnailPath: string; thumbhash: Buffer }; + if (asset.type === AssetType.IMAGE) { + generated = await this.generateImageThumbnails(asset); + } else if (asset.type === AssetType.VIDEO) { + generated = await this.generateVideoThumbnails(asset); + } else { + this.logger.warn(`Skipping thumbnail generation for asset ${id}: ${asset.type} is not an image or video`); return JobStatus.SKIPPED; } - const { previewFile } = getAssetFiles(asset.files); - if (previewFile && previewFile.path !== previewPath) { + const { previewFile, thumbnailFile } = getAssetFiles(asset.files); + const toUpsert: UpsertFileOptions[] = []; + if (previewFile?.path !== generated.previewPath) { + toUpsert.push({ assetId: asset.id, path: generated.previewPath, type: AssetFileType.PREVIEW }); + } + + if (thumbnailFile?.path !== generated.thumbnailPath) { + toUpsert.push({ assetId: asset.id, path: generated.thumbnailPath, type: AssetFileType.THUMBNAIL }); + } + + if (toUpsert.length > 0) { + await this.assetRepository.upsertFiles(toUpsert); + } + + const pathsToDelete = []; + if (previewFile && previewFile.path !== generated.previewPath) { this.logger.debug(`Deleting old preview for asset ${asset.id}`); - await this.storageRepository.unlink(previewFile.path); + pathsToDelete.push(previewFile.path); } - await this.assetRepository.upsertFile({ assetId: asset.id, type: AssetFileType.PREVIEW, path: previewPath }); - await this.assetRepository.update({ id: asset.id, updatedAt: new Date() }); - await this.assetRepository.upsertJobStatus({ assetId: asset.id, previewAt: new Date() }); - - return JobStatus.SUCCESS; - } - - private async generateThumbnail(asset: AssetEntity, type: GeneratedImageType) { - const { image, ffmpeg } = await this.configCore.getConfig({ withCache: true }); - const { size, format, quality } = image[type]; - const path = StorageCore.getImagePath(asset, type, format); - this.storageCore.ensureFolders(path); - - switch (asset.type) { - case AssetType.IMAGE: { - const shouldExtract = image.extractEmbedded && mimeTypes.isRaw(asset.originalPath); - const extractedPath = StorageCore.getTempPathInDir(dirname(path)); - const didExtract = shouldExtract && (await this.mediaRepository.extract(asset.originalPath, extractedPath)); - - try { - const useExtracted = didExtract && (await this.shouldUseExtractedImage(extractedPath, image.preview.size)); - const colorspace = this.isSRGB(asset) ? Colorspace.SRGB : image.colorspace; - const imageOptions = { - format, - size, - colorspace, - quality, - processInvalidImages: process.env.IMMICH_PROCESS_INVALID_IMAGES === 'true', - }; - - const outputPath = useExtracted ? extractedPath : asset.originalPath; - await this.mediaRepository.generateThumbnail(outputPath, path, imageOptions); - } finally { - if (didExtract) { - await this.storageRepository.unlink(extractedPath); - } - } - break; - } - - case AssetType.VIDEO: { - const { audioStreams, videoStreams } = await this.mediaRepository.probe(asset.originalPath); - const mainVideoStream = this.getMainStream(videoStreams); - if (!mainVideoStream) { - this.logger.warn(`Skipped thumbnail generation for asset ${asset.id}: no video streams found`); - return; - } - const mainAudioStream = this.getMainStream(audioStreams); - const config = ThumbnailConfig.create({ ...ffmpeg, targetResolution: size.toString() }); - const options = config.getCommand(TranscodeTarget.VIDEO, mainVideoStream, mainAudioStream); - await this.mediaRepository.transcode(asset.originalPath, path, options); - break; - } - - default: { - throw new UnsupportedMediaTypeException(`Unsupported asset type for thumbnail generation: ${asset.type}`); - } - } - - const assetLabel = asset.isExternal ? asset.originalPath : asset.id; - this.logger.log( - `Successfully generated ${format.toUpperCase()} ${asset.type.toLowerCase()} ${type} for asset ${assetLabel}`, - ); - - return path; - } - - async handleGenerateThumbnail({ id }: IEntityJob): Promise { - const [asset] = await this.assetRepository.getByIds([id], { exifInfo: true, files: true }); - if (!asset) { - return JobStatus.FAILED; - } - - if (!asset.isVisible) { - return JobStatus.SKIPPED; - } - - const thumbnailPath = await this.generateThumbnail(asset, AssetPathType.THUMBNAIL); - if (!thumbnailPath) { - return JobStatus.SKIPPED; - } - - const { thumbnailFile } = getAssetFiles(asset.files); - if (thumbnailFile && thumbnailFile.path !== thumbnailPath) { + if (thumbnailFile && thumbnailFile.path !== generated.thumbnailPath) { this.logger.debug(`Deleting old thumbnail for asset ${asset.id}`); - await this.storageRepository.unlink(thumbnailFile.path); + pathsToDelete.push(thumbnailFile.path); } - await this.assetRepository.upsertFile({ assetId: asset.id, type: AssetFileType.THUMBNAIL, path: thumbnailPath }); - await this.assetRepository.update({ id: asset.id, updatedAt: new Date() }); - await this.assetRepository.upsertJobStatus({ assetId: asset.id, thumbnailAt: new Date() }); + if (pathsToDelete.length > 0) { + await Promise.all(pathsToDelete.map((path) => this.storageRepository.unlink(path))); + } + + if (asset.thumbhash != generated.thumbhash) { + await this.assetRepository.update({ id: asset.id, thumbhash: generated.thumbhash }); + } + + await this.assetRepository.upsertJobStatus({ assetId: asset.id, previewAt: new Date(), thumbnailAt: new Date() }); return JobStatus.SUCCESS; } - async handleGenerateThumbhash({ id }: IEntityJob): Promise { - const [asset] = await this.assetRepository.getByIds([id], { files: true }); - if (!asset) { - return JobStatus.FAILED; + private async generateImageThumbnails(asset: AssetEntity) { + const { image } = await this.configCore.getConfig({ withCache: true }); + const previewPath = StorageCore.getImagePath(asset, AssetPathType.PREVIEW, image.preview.format); + const thumbnailPath = StorageCore.getImagePath(asset, AssetPathType.THUMBNAIL, image.thumbnail.format); + this.storageCore.ensureFolders(previewPath); + + const shouldExtract = image.extractEmbedded && mimeTypes.isRaw(asset.originalPath); + const extractedPath = StorageCore.getTempPathInDir(dirname(previewPath)); + const didExtract = shouldExtract && (await this.mediaRepository.extract(asset.originalPath, extractedPath)); + + try { + const useExtracted = didExtract && (await this.shouldUseExtractedImage(extractedPath, image.preview.size)); + const inputPath = useExtracted ? extractedPath : asset.originalPath; + const colorspace = this.isSRGB(asset) ? Colorspace.SRGB : image.colorspace; + const processInvalidImages = process.env.IMMICH_PROCESS_INVALID_IMAGES === 'true'; + + const decodeOptions = { colorspace, processInvalidImages, size: image.preview.size }; + const { data, info } = await this.mediaRepository.decodeImage(inputPath, decodeOptions); + + const options = { colorspace, processInvalidImages, raw: info }; + const outputs = await Promise.all([ + this.mediaRepository.generateThumbnail(data, { ...image.thumbnail, ...options }, thumbnailPath), + this.mediaRepository.generateThumbnail(data, { ...image.preview, ...options }, previewPath), + this.mediaRepository.generateThumbhash(data, options), + ]); + + return { previewPath, thumbnailPath, thumbhash: outputs[2] }; + } finally { + if (didExtract) { + await this.storageRepository.unlink(extractedPath); + } } + } - if (!asset.isVisible) { - return JobStatus.SKIPPED; + private async generateVideoThumbnails(asset: AssetEntity) { + const { image, ffmpeg } = await this.configCore.getConfig({ withCache: true }); + const previewPath = StorageCore.getImagePath(asset, AssetPathType.PREVIEW, image.preview.format); + const thumbnailPath = StorageCore.getImagePath(asset, AssetPathType.THUMBNAIL, image.thumbnail.format); + this.storageCore.ensureFolders(previewPath); + + const { 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 mainAudioStream = this.getMainStream(audioStreams); - const { previewFile } = getAssetFiles(asset.files); - if (!previewFile) { - return JobStatus.FAILED; - } + const previewConfig = ThumbnailConfig.create({ ...ffmpeg, targetResolution: image.preview.size.toString() }); + const thumbnailConfig = ThumbnailConfig.create({ ...ffmpeg, targetResolution: image.thumbnail.size.toString() }); - const thumbhash = await this.mediaRepository.generateThumbhash(previewFile.path); - await this.assetRepository.update({ id: asset.id, thumbhash }); + const previewOptions = previewConfig.getCommand(TranscodeTarget.VIDEO, mainVideoStream, mainAudioStream); + const thumbnailOptions = thumbnailConfig.getCommand(TranscodeTarget.VIDEO, mainVideoStream, mainAudioStream); + await this.mediaRepository.transcode(asset.originalPath, previewPath, previewOptions); + await this.mediaRepository.transcode(asset.originalPath, thumbnailPath, thumbnailOptions); - return JobStatus.SUCCESS; + const thumbhash = await this.mediaRepository.generateThumbhash(previewPath, { + colorspace: image.colorspace, + processInvalidImages: process.env.IMMICH_PROCESS_INVALID_IMAGES === 'true', + }); + + return { previewPath, thumbnailPath, thumbhash }; } async handleQueueVideoConversion(job: IBaseJob): Promise { diff --git a/server/src/services/microservices.service.ts b/server/src/services/microservices.service.ts index 80f1b2be415aa..0afefefff3402 100644 --- a/server/src/services/microservices.service.ts +++ b/server/src/services/microservices.service.ts @@ -68,9 +68,7 @@ export class MicroservicesService { [JobName.MIGRATE_ASSET]: (data) => this.mediaService.handleAssetMigration(data), [JobName.MIGRATE_PERSON]: (data) => this.personService.handlePersonMigration(data), [JobName.QUEUE_GENERATE_THUMBNAILS]: (data) => this.mediaService.handleQueueGenerateThumbnails(data), - [JobName.GENERATE_PREVIEW]: (data) => this.mediaService.handleGeneratePreview(data), - [JobName.GENERATE_THUMBNAIL]: (data) => this.mediaService.handleGenerateThumbnail(data), - [JobName.GENERATE_THUMBHASH]: (data) => this.mediaService.handleGenerateThumbhash(data), + [JobName.GENERATE_THUMBNAILS]: (data) => this.mediaService.handleGenerateThumbnails(data), [JobName.QUEUE_VIDEO_CONVERSION]: (data) => this.mediaService.handleQueueVideoConversion(data), [JobName.VIDEO_CONVERSION]: (data) => this.mediaService.handleVideoConversion(data), [JobName.QUEUE_METADATA_EXTRACTION]: (data) => this.metadataService.handleQueueMetadataExtraction(data), diff --git a/server/src/services/notification.service.spec.ts b/server/src/services/notification.service.spec.ts index a0b9436f75435..b3a1e73541527 100644 --- a/server/src/services/notification.service.spec.ts +++ b/server/src/services/notification.service.spec.ts @@ -155,7 +155,7 @@ describe(NotificationService.name, () => { it('should queue the generate thumbnail job', async () => { await sut.onAssetShow({ assetId: 'asset-id', userId: 'user-id' }); expect(jobMock.queue).toHaveBeenCalledWith({ - name: JobName.GENERATE_THUMBNAIL, + name: JobName.GENERATE_THUMBNAILS, data: { id: 'asset-id', notify: true }, }); }); diff --git a/server/src/services/notification.service.ts b/server/src/services/notification.service.ts index bdb23ce700ab2..fdb8257ffad89 100644 --- a/server/src/services/notification.service.ts +++ b/server/src/services/notification.service.ts @@ -65,7 +65,7 @@ export class NotificationService { @OnEmit({ event: 'asset.show' }) async onAssetShow({ assetId }: ArgOf<'asset.show'>) { - await this.jobRepository.queue({ name: JobName.GENERATE_THUMBNAIL, data: { id: assetId, notify: true } }); + await this.jobRepository.queue({ name: JobName.GENERATE_THUMBNAILS, data: { id: assetId, notify: true } }); } @OnEmit({ event: 'asset.trash' }) diff --git a/server/src/services/person.service.spec.ts b/server/src/services/person.service.spec.ts index 03da110ac6049..c2b8f182216d8 100644 --- a/server/src/services/person.service.spec.ts +++ b/server/src/services/person.service.spec.ts @@ -2,7 +2,7 @@ import { BadRequestException, NotFoundException } from '@nestjs/common'; import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto'; import { PersonResponseDto, mapFaces, mapPerson } from 'src/dtos/person.dto'; import { AssetFaceEntity } from 'src/entities/asset-face.entity'; -import { CacheControl, Colorspace, SourceType, SystemMetadataKey } from 'src/enum'; +import { CacheControl, Colorspace, ImageFormat, SourceType, SystemMetadataKey } from 'src/enum'; import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; @@ -961,12 +961,11 @@ describe(PersonService.name, () => { expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/admin_id/pe/rs'); expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( assetStub.primaryImage.originalPath, - 'upload/thumbs/admin_id/pe/rs/person-1.jpeg', { - format: 'jpeg', + colorspace: Colorspace.P3, + format: ImageFormat.JPEG, size: 250, quality: 80, - colorspace: Colorspace.P3, crop: { left: 238, top: 163, @@ -975,6 +974,7 @@ describe(PersonService.name, () => { }, processInvalidImages: false, }, + 'upload/thumbs/admin_id/pe/rs/person-1.jpeg', ); expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', @@ -990,13 +990,12 @@ describe(PersonService.name, () => { await sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id }); expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( - assetStub.image.originalPath, - 'upload/thumbs/admin_id/pe/rs/person-1.jpeg', + assetStub.primaryImage.originalPath, { - format: 'jpeg', + colorspace: Colorspace.P3, + format: ImageFormat.JPEG, size: 250, quality: 80, - colorspace: Colorspace.P3, crop: { left: 0, top: 85, @@ -1005,6 +1004,7 @@ describe(PersonService.name, () => { }, processInvalidImages: false, }, + 'upload/thumbs/admin_id/pe/rs/person-1.jpeg', ); }); @@ -1017,12 +1017,11 @@ describe(PersonService.name, () => { expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( assetStub.primaryImage.originalPath, - 'upload/thumbs/admin_id/pe/rs/person-1.jpeg', { - format: 'jpeg', + colorspace: Colorspace.P3, + format: ImageFormat.JPEG, size: 250, quality: 80, - colorspace: Colorspace.P3, crop: { left: 591, top: 591, @@ -1031,33 +1030,7 @@ describe(PersonService.name, () => { }, processInvalidImages: false, }, - ); - }); - - it('should use preview path for videos', async () => { - personMock.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.end.assetId }); - personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.end); - assetMock.getById.mockResolvedValue(assetStub.video); - mediaMock.getImageDimensions.mockResolvedValue({ width: 2560, height: 1440 }); - - await sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id }); - - expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( - '/uploads/user-id/thumbs/path.jpg', 'upload/thumbs/admin_id/pe/rs/person-1.jpeg', - { - format: 'jpeg', - size: 250, - quality: 80, - colorspace: Colorspace.P3, - crop: { - left: 1741, - top: 851, - width: 588, - height: 588, - }, - processInvalidImages: false, - }, ); }); }); diff --git a/server/src/services/person.service.ts b/server/src/services/person.service.ts index 651c8eebee54e..e8e16adb1737f 100644 --- a/server/src/services/person.service.ts +++ b/server/src/services/person.service.ts @@ -571,15 +571,15 @@ export class PersonService { this.storageCore.ensureFolders(thumbnailPath); const thumbnailOptions = { + colorspace: image.colorspace, format: ImageFormat.JPEG, size: FACE_THUMBNAIL_SIZE, - colorspace: image.colorspace, quality: image.thumbnail.quality, crop: this.getCrop({ old: { width: oldWidth, height: oldHeight }, new: { width, height } }, { x1, y1, x2, y2 }), processInvalidImages: process.env.IMMICH_PROCESS_INVALID_IMAGES === 'true', - } as const; + }; - await this.mediaRepository.generateThumbnail(inputPath, thumbnailPath, thumbnailOptions); + await this.mediaRepository.generateThumbnail(inputPath, thumbnailOptions, thumbnailPath); await this.repository.update({ id: person.id, thumbnailPath }); return JobStatus.SUCCESS; diff --git a/server/test/repositories/asset.repository.mock.ts b/server/test/repositories/asset.repository.mock.ts index ba2f5e10d98cc..50fff31e55e4c 100644 --- a/server/test/repositories/asset.repository.mock.ts +++ b/server/test/repositories/asset.repository.mock.ts @@ -39,5 +39,6 @@ export const newAssetRepositoryMock = (): Mocked => { getChangedDeltaSync: vitest.fn(), getDuplicates: vitest.fn(), upsertFile: vitest.fn(), + upsertFiles: vitest.fn(), }; }; diff --git a/server/test/repositories/media.repository.mock.ts b/server/test/repositories/media.repository.mock.ts index 4c344a986679f..a809b08162347 100644 --- a/server/test/repositories/media.repository.mock.ts +++ b/server/test/repositories/media.repository.mock.ts @@ -3,8 +3,9 @@ import { Mocked, vitest } from 'vitest'; export const newMediaRepositoryMock = (): Mocked => { return { - generateThumbnail: vitest.fn(), - generateThumbhash: vitest.fn(), + generateThumbnail: vitest.fn().mockImplementation(() => Promise.resolve()), + generateThumbhash: vitest.fn().mockImplementation(() => Promise.resolve()), + decodeImage: vitest.fn().mockResolvedValue({ data: Buffer.from(''), info: {} }), extract: vitest.fn().mockResolvedValue(false), probe: vitest.fn(), transcode: vitest.fn(), From fa9bb8074cec18cbaa2d1df29e48e8f4cbec5e9d Mon Sep 17 00:00:00 2001 From: Alex Date: Sun, 29 Sep 2024 15:22:02 +0700 Subject: [PATCH 119/123] feat(mobile): enhance download operations (#12973) * add packages * create download task * show progress * save video and image * show progress info * live photo wip * download and link live photos * Update list of assets * wip * correct progress * add state to download * revert unncessary change * repository pattern * translation * remove unused code * update method call from repository * remove unused variable * handle multiple livephotos download * remove logging statement * lint * not removing all records --- mobile/assets/i18n/en-US.json | 15 +- mobile/ios/Podfile.lock | 6 + mobile/lib/interfaces/download.interface.dart | 14 ++ mobile/lib/main.dart | 25 ++- .../asset_viewer_page_state.model.dart | 55 ----- .../models/download/download_state.model.dart | 109 ++++++++++ .../download/livephotos_medatada.model.dart | 60 ++++++ mobile/lib/pages/common/download_panel.dart | 150 ++++++++++++++ .../lib/pages/common/gallery_viewer.page.dart | 2 + .../asset_viewer/download.provider.dart | 191 +++++++++++++++++ .../image_viewer_page_state.provider.dart | 99 --------- .../lib/repositories/download.repository.dart | 68 ++++++ mobile/lib/services/download.service.dart | 193 ++++++++++++++++++ mobile/lib/services/image_viewer.service.dart | 117 ----------- mobile/lib/utils/download.dart | 3 + .../asset_viewer/bottom_gallery_bar.dart | 25 ++- .../widgets/asset_viewer/gallery_app_bar.dart | 4 +- .../lib/widgets/forms/login/login_form.dart | 2 +- mobile/pubspec.lock | 12 +- mobile/pubspec.yaml | 3 +- 20 files changed, 868 insertions(+), 285 deletions(-) create mode 100644 mobile/lib/interfaces/download.interface.dart delete mode 100644 mobile/lib/models/asset_viewer/asset_viewer_page_state.model.dart create mode 100644 mobile/lib/models/download/download_state.model.dart create mode 100644 mobile/lib/models/download/livephotos_medatada.model.dart create mode 100644 mobile/lib/pages/common/download_panel.dart create mode 100644 mobile/lib/providers/asset_viewer/download.provider.dart delete mode 100644 mobile/lib/providers/asset_viewer/image_viewer_page_state.provider.dart create mode 100644 mobile/lib/repositories/download.repository.dart create mode 100644 mobile/lib/services/download.service.dart delete mode 100644 mobile/lib/services/image_viewer.service.dart create mode 100644 mobile/lib/utils/download.dart diff --git a/mobile/assets/i18n/en-US.json b/mobile/assets/i18n/en-US.json index 324c9069fdf46..bb4f3efd267c6 100644 --- a/mobile/assets/i18n/en-US.json +++ b/mobile/assets/i18n/en-US.json @@ -588,5 +588,16 @@ "version_announcement_overlay_title": "New Server Version Available \uD83C\uDF89", "viewer_remove_from_stack": "Remove from Stack", "viewer_stack_use_as_main_asset": "Use as Main Asset", - "viewer_unstack": "Un-Stack" -} \ No newline at end of file + "viewer_unstack": "Un-Stack", + "downloading_media": "Downloading media", + "download_finished": "Download finished", + "download_filename": "file: {}", + "downloading": "Downloading...", + "download_complete": "Download complete", + "download_failed": "Download failed", + "download_canceled": "Download canceled", + "download_paused": "Download paused", + "download_enqueue": "Download enqueued", + "download_notfound": "Download not found", + "download_waiting_to_retry": "Waiting to retry" +} diff --git a/mobile/ios/Podfile.lock b/mobile/ios/Podfile.lock index 3b361c4e1902f..6a9d34ab83bfe 100644 --- a/mobile/ios/Podfile.lock +++ b/mobile/ios/Podfile.lock @@ -1,4 +1,6 @@ PODS: + - background_downloader (0.0.1): + - Flutter - connectivity_plus (0.0.1): - Flutter - ReachabilitySwift @@ -99,6 +101,7 @@ PODS: - Flutter DEPENDENCIES: + - background_downloader (from `.symlinks/plugins/background_downloader/ios`) - connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`) - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) - file_picker (from `.symlinks/plugins/file_picker/ios`) @@ -137,6 +140,8 @@ SPEC REPOS: - Toast EXTERNAL SOURCES: + background_downloader: + :path: ".symlinks/plugins/background_downloader/ios" connectivity_plus: :path: ".symlinks/plugins/connectivity_plus/ios" device_info_plus: @@ -189,6 +194,7 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/wakelock_plus/ios" SPEC CHECKSUMS: + background_downloader: 9f788ffc5de45acf87d6380e91ca0841066c18cf connectivity_plus: bf0076dd84a130856aa636df1c71ccaff908fa1d device_info_plus: c6fb39579d0f423935b0c9ce7ee2f44b71b9fce6 DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c diff --git a/mobile/lib/interfaces/download.interface.dart b/mobile/lib/interfaces/download.interface.dart new file mode 100644 index 0000000000000..dc4f0f57f8c44 --- /dev/null +++ b/mobile/lib/interfaces/download.interface.dart @@ -0,0 +1,14 @@ +import 'package:background_downloader/background_downloader.dart'; + +abstract interface class IDownloadRepository { + void Function(TaskStatusUpdate)? onImageDownloadStatus; + void Function(TaskStatusUpdate)? onVideoDownloadStatus; + void Function(TaskStatusUpdate)? onLivePhotoDownloadStatus; + void Function(TaskProgressUpdate)? onTaskProgress; + + Future> getLiveVideoTasks(); + Future download(DownloadTask task); + Future cancel(String id); + Future deleteAllTrackingRecords(); + Future deleteRecordsWithIds(List id); +} diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index dc1df746cb964..40eda30204e01 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:io'; +import 'package:background_downloader/background_downloader.dart'; import 'package:device_info_plus/device_info_plus.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/foundation.dart'; @@ -9,6 +10,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_displaymode/flutter_displaymode.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/utils/download.dart'; import 'package:timezone/data/latest.dart'; import 'package:immich_mobile/constants/locales.dart'; import 'package:immich_mobile/services/background.service.dart'; @@ -72,7 +74,6 @@ Future initApp() async { var log = Logger("ImmichErrorLogger"); FlutterError.onError = (details) { - debugPrint("FlutterError - Catch all: $details"); FlutterError.presentError(details); log.severe( 'FlutterError - Catch all', @@ -82,11 +83,29 @@ Future initApp() async { }; PlatformDispatcher.instance.onError = (error, stack) { + debugPrint("FlutterError - Catch all: $error"); log.severe('PlatformDispatcher - Catch all', error, stack); return true; }; initializeTimeZones(); + + FileDownloader().configureNotification( + running: TaskNotification( + 'downloading_media'.tr(), + 'file: {filename}', + ), + complete: TaskNotification( + 'download_finished'.tr(), + 'file: {filename}', + ), + progressBar: true, + ); + + FileDownloader().trackTasksInGroup( + downloadGroupLivePhoto, + markDownloadedComplete: false, + ); } Future loadDb() async { @@ -188,8 +207,8 @@ class ImmichAppState extends ConsumerState @override Widget build(BuildContext context) { - var router = ref.watch(appRouterProvider); - var immichTheme = ref.watch(immichThemeProvider); + final router = ref.watch(appRouterProvider); + final immichTheme = ref.watch(immichThemeProvider); return MaterialApp( localizationsDelegates: context.localizationDelegates, diff --git a/mobile/lib/models/asset_viewer/asset_viewer_page_state.model.dart b/mobile/lib/models/asset_viewer/asset_viewer_page_state.model.dart deleted file mode 100644 index 0a354781f81c3..0000000000000 --- a/mobile/lib/models/asset_viewer/asset_viewer_page_state.model.dart +++ /dev/null @@ -1,55 +0,0 @@ -import 'dart:convert'; - -enum DownloadAssetStatus { idle, loading, success, error } - -class AssetViewerPageState { - // enum - final DownloadAssetStatus downloadAssetStatus; - - AssetViewerPageState({ - required this.downloadAssetStatus, - }); - - AssetViewerPageState copyWith({ - DownloadAssetStatus? downloadAssetStatus, - }) { - return AssetViewerPageState( - downloadAssetStatus: downloadAssetStatus ?? this.downloadAssetStatus, - ); - } - - Map toMap() { - final result = {}; - - result.addAll({'downloadAssetStatus': downloadAssetStatus.index}); - - return result; - } - - factory AssetViewerPageState.fromMap(Map map) { - return AssetViewerPageState( - downloadAssetStatus: - DownloadAssetStatus.values[map['downloadAssetStatus'] ?? 0], - ); - } - - String toJson() => json.encode(toMap()); - - factory AssetViewerPageState.fromJson(String source) => - AssetViewerPageState.fromMap(json.decode(source)); - - @override - String toString() => - 'ImageViewerPageState(downloadAssetStatus: $downloadAssetStatus)'; - - @override - bool operator ==(Object other) { - if (identical(this, other)) return true; - - return other is AssetViewerPageState && - other.downloadAssetStatus == downloadAssetStatus; - } - - @override - int get hashCode => downloadAssetStatus.hashCode; -} diff --git a/mobile/lib/models/download/download_state.model.dart b/mobile/lib/models/download/download_state.model.dart new file mode 100644 index 0000000000000..edd2fa183ec5d --- /dev/null +++ b/mobile/lib/models/download/download_state.model.dart @@ -0,0 +1,109 @@ +// ignore_for_file: public_member_api_docs, sort_constructors_first +import 'dart:convert'; + +import 'package:background_downloader/background_downloader.dart'; +import 'package:collection/collection.dart'; + +class DownloadInfo { + final String fileName; + final double progress; + // enum + final TaskStatus status; + + DownloadInfo({ + required this.fileName, + required this.progress, + required this.status, + }); + + DownloadInfo copyWith({ + String? fileName, + double? progress, + TaskStatus? status, + }) { + return DownloadInfo( + fileName: fileName ?? this.fileName, + progress: progress ?? this.progress, + status: status ?? this.status, + ); + } + + Map toMap() { + return { + 'fileName': fileName, + 'progress': progress, + 'status': status.index, + }; + } + + factory DownloadInfo.fromMap(Map map) { + return DownloadInfo( + fileName: map['fileName'] as String, + progress: map['progress'] as double, + status: TaskStatus.values[map['status'] as int], + ); + } + + String toJson() => json.encode(toMap()); + + factory DownloadInfo.fromJson(String source) => + DownloadInfo.fromMap(json.decode(source) as Map); + + @override + String toString() => + 'DownloadInfo(fileName: $fileName, progress: $progress, status: $status)'; + + @override + bool operator ==(covariant DownloadInfo other) { + if (identical(this, other)) return true; + + return other.fileName == fileName && + other.progress == progress && + other.status == status; + } + + @override + int get hashCode => fileName.hashCode ^ progress.hashCode ^ status.hashCode; +} + +class DownloadState { + // enum + final TaskStatus downloadStatus; + final Map taskProgress; + final bool showProgress; + DownloadState({ + required this.downloadStatus, + required this.taskProgress, + required this.showProgress, + }); + + DownloadState copyWith({ + TaskStatus? downloadStatus, + Map? taskProgress, + bool? showProgress, + }) { + return DownloadState( + downloadStatus: downloadStatus ?? this.downloadStatus, + taskProgress: taskProgress ?? this.taskProgress, + showProgress: showProgress ?? this.showProgress, + ); + } + + @override + String toString() => + 'DownloadState(downloadStatus: $downloadStatus, taskProgress: $taskProgress, showProgress: $showProgress)'; + + @override + bool operator ==(covariant DownloadState other) { + if (identical(this, other)) return true; + final mapEquals = const DeepCollectionEquality().equals; + + return other.downloadStatus == downloadStatus && + mapEquals(other.taskProgress, taskProgress) && + other.showProgress == showProgress; + } + + @override + int get hashCode => + downloadStatus.hashCode ^ taskProgress.hashCode ^ showProgress.hashCode; +} diff --git a/mobile/lib/models/download/livephotos_medatada.model.dart b/mobile/lib/models/download/livephotos_medatada.model.dart new file mode 100644 index 0000000000000..9c0c7ae4e95c7 --- /dev/null +++ b/mobile/lib/models/download/livephotos_medatada.model.dart @@ -0,0 +1,60 @@ +// ignore_for_file: public_member_api_docs, sort_constructors_first +import 'dart:convert'; + +enum LivePhotosPart { + video, + image, +} + +class LivePhotosMetadata { + // enum + LivePhotosPart part; + + String id; + LivePhotosMetadata({ + required this.part, + required this.id, + }); + + LivePhotosMetadata copyWith({ + LivePhotosPart? part, + String? id, + }) { + return LivePhotosMetadata( + part: part ?? this.part, + id: id ?? this.id, + ); + } + + Map toMap() { + return { + 'part': part.index, + 'id': id, + }; + } + + factory LivePhotosMetadata.fromMap(Map map) { + return LivePhotosMetadata( + part: LivePhotosPart.values[map['part'] as int], + id: map['id'] as String, + ); + } + + String toJson() => json.encode(toMap()); + + factory LivePhotosMetadata.fromJson(String source) => + LivePhotosMetadata.fromMap(json.decode(source) as Map); + + @override + String toString() => 'LivePhotosMetadata(part: $part, id: $id)'; + + @override + bool operator ==(covariant LivePhotosMetadata other) { + if (identical(this, other)) return true; + + return other.part == part && other.id == id; + } + + @override + int get hashCode => part.hashCode ^ id.hashCode; +} diff --git a/mobile/lib/pages/common/download_panel.dart b/mobile/lib/pages/common/download_panel.dart new file mode 100644 index 0000000000000..95cefd742af0a --- /dev/null +++ b/mobile/lib/pages/common/download_panel.dart @@ -0,0 +1,150 @@ +import 'package:background_downloader/background_downloader.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/providers/asset_viewer/download.provider.dart'; + +class DownloadPanel extends ConsumerWidget { + const DownloadPanel({ + super.key, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final showProgress = ref.watch( + downloadStateProvider.select((state) => state.showProgress), + ); + + final tasks = ref + .watch( + downloadStateProvider.select((state) => state.taskProgress), + ) + .entries + .toList(); + + onCancelDownload(String id) { + ref.watch(downloadStateProvider.notifier).cancelDownload(id); + } + + return Positioned( + bottom: 140, + left: 16, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + child: showProgress + ? ConstrainedBox( + constraints: + BoxConstraints.loose(Size(context.width - 32, 300)), + child: ListView.builder( + shrinkWrap: true, + itemCount: tasks.length, + itemBuilder: (context, index) { + final task = tasks[index]; + return DownloadTaskTile( + progress: task.value.progress, + fileName: task.value.fileName, + status: task.value.status, + onCancelDownload: () => onCancelDownload(task.key), + ); + }, + ), + ) + : const SizedBox.shrink(key: ValueKey('no_progress')), + ), + ); + } +} + +class DownloadTaskTile extends StatelessWidget { + final double progress; + final String fileName; + final TaskStatus status; + final VoidCallback onCancelDownload; + + const DownloadTaskTile({ + super.key, + required this.progress, + required this.fileName, + required this.status, + required this.onCancelDownload, + }); + + @override + Widget build(BuildContext context) { + final progressPercent = (progress * 100).round(); + + getStatusText() { + switch (status) { + case TaskStatus.running: + return 'downloading'.tr(); + case TaskStatus.complete: + return 'download_complete'.tr(); + case TaskStatus.failed: + return 'download_failed'.tr(); + case TaskStatus.canceled: + return 'download_canceled'.tr(); + case TaskStatus.paused: + return 'download_paused'.tr(); + case TaskStatus.enqueued: + return 'download_enqueue'.tr(); + case TaskStatus.notFound: + return 'download_notfound'.tr(); + case TaskStatus.waitingToRetry: + return 'download_waiting_to_retry'.tr(); + } + } + + return SizedBox( + key: const ValueKey('download_progress'), + width: MediaQuery.of(context).size.width - 32, + child: Card( + clipBehavior: Clip.antiAlias, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: ListTile( + minVerticalPadding: 18, + leading: const Icon(Icons.video_file_outlined), + title: Text( + getStatusText(), + style: context.textTheme.labelLarge, + ), + trailing: IconButton( + icon: Icon(Icons.close, color: context.colorScheme.onError), + onPressed: onCancelDownload, + style: ElevatedButton.styleFrom( + backgroundColor: context.colorScheme.error.withAlpha(200), + ), + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + fileName, + style: context.textTheme.labelMedium, + ), + Row( + children: [ + Expanded( + child: LinearProgressIndicator( + minHeight: 8.0, + value: progress, + borderRadius: + const BorderRadius.all(Radius.circular(10.0)), + ), + ), + const SizedBox(width: 8), + Text( + '$progressPercent%', + style: context.textTheme.labelSmall, + ), + ], + ), + ], + ), + ), + ), + ); + } +} diff --git a/mobile/lib/pages/common/gallery_viewer.page.dart b/mobile/lib/pages/common/gallery_viewer.page.dart index 1434d1cca5f59..57c75ca84df84 100644 --- a/mobile/lib/pages/common/gallery_viewer.page.dart +++ b/mobile/lib/pages/common/gallery_viewer.page.dart @@ -11,6 +11,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/pages/common/download_panel.dart'; import 'package:immich_mobile/pages/common/video_viewer.page.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/asset_stack.provider.dart'; @@ -421,6 +422,7 @@ class GalleryViewerPage extends HookConsumerWidget { ], ), ), + const DownloadPanel(), ], ), ), diff --git a/mobile/lib/providers/asset_viewer/download.provider.dart b/mobile/lib/providers/asset_viewer/download.provider.dart new file mode 100644 index 0000000000000..d4aa2823b5b2f --- /dev/null +++ b/mobile/lib/providers/asset_viewer/download.provider.dart @@ -0,0 +1,191 @@ +import 'package:background_downloader/background_downloader.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:fluttertoast/fluttertoast.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/models/download/download_state.model.dart'; +import 'package:immich_mobile/models/download/livephotos_medatada.model.dart'; +import 'package:immich_mobile/services/download.service.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/services/share.service.dart'; +import 'package:immich_mobile/widgets/common/immich_toast.dart'; +import 'package:immich_mobile/widgets/common/share_dialog.dart'; + +class DownloadStateNotifier extends StateNotifier { + final DownloadService _downloadService; + final ShareService _shareService; + + DownloadStateNotifier( + this._downloadService, + this._shareService, + ) : super( + DownloadState( + downloadStatus: TaskStatus.complete, + showProgress: false, + taskProgress: {}, + ), + ) { + _downloadService.onImageDownloadStatus = _downloadImageCallback; + _downloadService.onVideoDownloadStatus = _downloadVideoCallback; + _downloadService.onLivePhotoDownloadStatus = _downloadLivePhotoCallback; + _downloadService.onTaskProgress = _taskProgressCallback; + } + + void _updateDownloadStatus(String taskId, TaskStatus status) { + if (status == TaskStatus.canceled) { + return; + } + + state = state.copyWith( + taskProgress: {} + ..addAll(state.taskProgress) + ..addAll({ + taskId: DownloadInfo( + progress: state.taskProgress[taskId]?.progress ?? 0, + fileName: state.taskProgress[taskId]?.fileName ?? '', + status: status, + ), + }), + ); + } + + // Download live photo callback + void _downloadLivePhotoCallback(TaskStatusUpdate update) { + _updateDownloadStatus(update.task.taskId, update.status); + + switch (update.status) { + case TaskStatus.complete: + if (update.task.metaData.isEmpty) { + return; + } + final livePhotosId = + LivePhotosMetadata.fromJson(update.task.metaData).id; + _downloadService.saveLivePhotos(update.task, livePhotosId); + _onDownloadComplete(update.task.taskId); + break; + + default: + break; + } + } + + // Download image callback + void _downloadImageCallback(TaskStatusUpdate update) { + _updateDownloadStatus(update.task.taskId, update.status); + + switch (update.status) { + case TaskStatus.complete: + _downloadService.saveImage(update.task); + _onDownloadComplete(update.task.taskId); + break; + + default: + break; + } + } + + // Download video callback + void _downloadVideoCallback(TaskStatusUpdate update) { + _updateDownloadStatus(update.task.taskId, update.status); + + switch (update.status) { + case TaskStatus.complete: + _downloadService.saveVideo(update.task); + _onDownloadComplete(update.task.taskId); + break; + + default: + break; + } + } + + void _taskProgressCallback(TaskProgressUpdate update) { + // Ignore if the task is cancled or completed + if (update.progress == -2 || update.progress == -1) { + return; + } + + state = state.copyWith( + showProgress: true, + taskProgress: {} + ..addAll(state.taskProgress) + ..addAll({ + update.task.taskId: DownloadInfo( + progress: update.progress, + fileName: update.task.filename, + status: TaskStatus.running, + ), + }), + ); + } + + void _onDownloadComplete(String id) { + Future.delayed(const Duration(seconds: 2), () { + state = state.copyWith( + taskProgress: {} + ..addAll(state.taskProgress) + ..remove(id), + ); + + if (state.taskProgress.isEmpty) { + state = state.copyWith( + showProgress: false, + ); + } + }); + } + + void downloadAsset(Asset asset, BuildContext context) async { + await _downloadService.download(asset); + } + + void cancelDownload(String id) async { + final isCanceled = await _downloadService.cancelDownload(id); + + if (isCanceled) { + state = state.copyWith( + taskProgress: {} + ..addAll(state.taskProgress) + ..remove(id), + ); + } + + if (state.taskProgress.isEmpty) { + state = state.copyWith( + showProgress: false, + ); + } + } + + void shareAsset(Asset asset, BuildContext context) async { + showDialog( + context: context, + builder: (BuildContext buildContext) { + _shareService.shareAsset(asset, context).then( + (bool status) { + if (!status) { + ImmichToast.show( + context: context, + msg: 'image_viewer_page_state_provider_share_error'.tr(), + toastType: ToastType.error, + gravity: ToastGravity.BOTTOM, + ); + } + buildContext.pop(); + }, + ); + return const ShareDialog(); + }, + barrierDismissible: false, + ); + } +} + +final downloadStateProvider = + StateNotifierProvider( + ((ref) => DownloadStateNotifier( + ref.watch(downloadServiceProvider), + ref.watch(shareServiceProvider), + )), +); diff --git a/mobile/lib/providers/asset_viewer/image_viewer_page_state.provider.dart b/mobile/lib/providers/asset_viewer/image_viewer_page_state.provider.dart deleted file mode 100644 index 631011f200bbd..0000000000000 --- a/mobile/lib/providers/asset_viewer/image_viewer_page_state.provider.dart +++ /dev/null @@ -1,99 +0,0 @@ -import 'dart:io'; - -import 'package:easy_localization/easy_localization.dart'; -import 'package:flutter/material.dart'; -import 'package:fluttertoast/fluttertoast.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/services/album.service.dart'; -import 'package:immich_mobile/models/asset_viewer/asset_viewer_page_state.model.dart'; -import 'package:immich_mobile/services/image_viewer.service.dart'; -import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:immich_mobile/services/share.service.dart'; -import 'package:immich_mobile/widgets/common/immich_toast.dart'; -import 'package:immich_mobile/widgets/common/share_dialog.dart'; - -class ImageViewerStateNotifier extends StateNotifier { - final ImageViewerService _imageViewerService; - final ShareService _shareService; - final AlbumService _albumService; - - ImageViewerStateNotifier( - this._imageViewerService, - this._shareService, - this._albumService, - ) : super( - AssetViewerPageState( - downloadAssetStatus: DownloadAssetStatus.idle, - ), - ); - - void downloadAsset(Asset asset, BuildContext context) async { - state = state.copyWith(downloadAssetStatus: DownloadAssetStatus.loading); - - ImmichToast.show( - context: context, - msg: 'download_started'.tr(), - toastType: ToastType.info, - gravity: ToastGravity.BOTTOM, - ); - - bool isSuccess = await _imageViewerService.downloadAsset(asset); - - if (isSuccess) { - state = state.copyWith(downloadAssetStatus: DownloadAssetStatus.success); - - ImmichToast.show( - context: context, - msg: Platform.isAndroid - ? 'download_sucess_android'.tr() - : 'download_sucess'.tr(), - toastType: ToastType.success, - gravity: ToastGravity.BOTTOM, - ); - _albumService.refreshDeviceAlbums(); - } else { - state = state.copyWith(downloadAssetStatus: DownloadAssetStatus.error); - ImmichToast.show( - context: context, - msg: 'download_error'.tr(), - toastType: ToastType.error, - gravity: ToastGravity.BOTTOM, - ); - } - - state = state.copyWith(downloadAssetStatus: DownloadAssetStatus.idle); - } - - void shareAsset(Asset asset, BuildContext context) async { - showDialog( - context: context, - builder: (BuildContext buildContext) { - _shareService.shareAsset(asset, context).then( - (bool status) { - if (!status) { - ImmichToast.show( - context: context, - msg: 'image_viewer_page_state_provider_share_error'.tr(), - toastType: ToastType.error, - gravity: ToastGravity.BOTTOM, - ); - } - buildContext.pop(); - }, - ); - return const ShareDialog(); - }, - barrierDismissible: false, - ); - } -} - -final imageViewerStateProvider = - StateNotifierProvider( - ((ref) => ImageViewerStateNotifier( - ref.watch(imageViewerServiceProvider), - ref.watch(shareServiceProvider), - ref.watch(albumServiceProvider), - )), -); diff --git a/mobile/lib/repositories/download.repository.dart b/mobile/lib/repositories/download.repository.dart new file mode 100644 index 0000000000000..5b42f66b02148 --- /dev/null +++ b/mobile/lib/repositories/download.repository.dart @@ -0,0 +1,68 @@ +import 'package:background_downloader/background_downloader.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/interfaces/download.interface.dart'; +import 'package:immich_mobile/utils/download.dart'; + +final downloadRepositoryProvider = Provider((ref) => DownloadRepository()); + +class DownloadRepository implements IDownloadRepository { + @override + void Function(TaskStatusUpdate)? onImageDownloadStatus; + + @override + void Function(TaskStatusUpdate)? onVideoDownloadStatus; + + @override + void Function(TaskStatusUpdate)? onLivePhotoDownloadStatus; + + @override + void Function(TaskProgressUpdate)? onTaskProgress; + + DownloadRepository() { + FileDownloader().registerCallbacks( + group: downloadGroupImage, + taskStatusCallback: (update) => onImageDownloadStatus?.call(update), + taskProgressCallback: (update) => onTaskProgress?.call(update), + ); + + FileDownloader().registerCallbacks( + group: downloadGroupVideo, + taskStatusCallback: (update) => onVideoDownloadStatus?.call(update), + taskProgressCallback: (update) => onTaskProgress?.call(update), + ); + + FileDownloader().registerCallbacks( + group: downloadGroupLivePhoto, + taskStatusCallback: (update) => onLivePhotoDownloadStatus?.call(update), + taskProgressCallback: (update) => onTaskProgress?.call(update), + ); + } + + @override + Future download(DownloadTask task) { + return FileDownloader().enqueue(task); + } + + @override + Future deleteAllTrackingRecords() { + return FileDownloader().database.deleteAllRecords(); + } + + @override + Future cancel(String id) { + return FileDownloader().cancelTaskWithId(id); + } + + @override + Future> getLiveVideoTasks() { + return FileDownloader().database.allRecordsWithStatus( + TaskStatus.complete, + group: downloadGroupLivePhoto, + ); + } + + @override + Future deleteRecordsWithIds(List ids) { + return FileDownloader().database.deleteRecordsWithIds(ids); + } +} diff --git a/mobile/lib/services/download.service.dart b/mobile/lib/services/download.service.dart new file mode 100644 index 0000000000000..996cbe61f192f --- /dev/null +++ b/mobile/lib/services/download.service.dart @@ -0,0 +1,193 @@ +import 'dart:io'; + +import 'package:background_downloader/background_downloader.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/interfaces/download.interface.dart'; +import 'package:immich_mobile/interfaces/file_media.interface.dart'; +import 'package:immich_mobile/models/download/livephotos_medatada.model.dart'; +import 'package:immich_mobile/repositories/download.repository.dart'; +import 'package:immich_mobile/repositories/file_media.repository.dart'; +import 'package:immich_mobile/services/api.service.dart'; +import 'package:immich_mobile/utils/download.dart'; + +final downloadServiceProvider = Provider( + (ref) => DownloadService( + ref.watch(fileMediaRepositoryProvider), + ref.watch(downloadRepositoryProvider), + ), +); + +class DownloadService { + final IDownloadRepository _downloadRepository; + final IFileMediaRepository _fileMediaRepository; + void Function(TaskStatusUpdate)? onImageDownloadStatus; + void Function(TaskStatusUpdate)? onVideoDownloadStatus; + void Function(TaskStatusUpdate)? onLivePhotoDownloadStatus; + void Function(TaskProgressUpdate)? onTaskProgress; + + DownloadService( + this._fileMediaRepository, + this._downloadRepository, + ) { + _downloadRepository.onImageDownloadStatus = _onImageDownloadCallback; + _downloadRepository.onVideoDownloadStatus = _onVideoDownloadCallback; + _downloadRepository.onLivePhotoDownloadStatus = + _onLivePhotoDownloadCallback; + _downloadRepository.onTaskProgress = _onTaskProgressCallback; + } + + void _onTaskProgressCallback(TaskProgressUpdate update) { + onTaskProgress?.call(update); + } + + void _onImageDownloadCallback(TaskStatusUpdate update) { + onImageDownloadStatus?.call(update); + } + + void _onVideoDownloadCallback(TaskStatusUpdate update) { + onVideoDownloadStatus?.call(update); + } + + void _onLivePhotoDownloadCallback(TaskStatusUpdate update) { + onLivePhotoDownloadStatus?.call(update); + } + + Future saveImage(Task task) async { + final filePath = await task.filePath(); + final title = task.filename; + final relativePath = Platform.isAndroid ? 'DCIM/Immich' : null; + final data = await File(filePath).readAsBytes(); + + final Asset? resultAsset = await _fileMediaRepository.saveImage( + data, + title: title, + relativePath: relativePath, + ); + + return resultAsset != null; + } + + Future saveVideo(Task task) async { + final filePath = await task.filePath(); + final title = task.filename; + final relativePath = Platform.isAndroid ? 'DCIM/Immich' : null; + final file = File(filePath); + + final Asset? resultAsset = await _fileMediaRepository.saveVideo( + file, + title: title, + relativePath: relativePath, + ); + + return resultAsset != null; + } + + Future saveLivePhotos( + Task task, + String livePhotosId, + ) async { + try { + final records = await _downloadRepository.getLiveVideoTasks(); + if (records.length < 2) { + return false; + } + + final imageRecord = records.firstWhere( + (record) { + final metadata = LivePhotosMetadata.fromJson(record.task.metaData); + return metadata.id == livePhotosId && + metadata.part == LivePhotosPart.image; + }, + ); + + final videoRecord = records.firstWhere((record) { + final metadata = LivePhotosMetadata.fromJson(record.task.metaData); + return metadata.id == livePhotosId && + metadata.part == LivePhotosPart.video; + }); + + final imageFilePath = await imageRecord.task.filePath(); + final videoFilePath = await videoRecord.task.filePath(); + + final resultAsset = await _fileMediaRepository.saveLivePhoto( + image: File(imageFilePath), + video: File(videoFilePath), + title: task.filename, + ); + + await _downloadRepository.deleteRecordsWithIds([ + imageRecord.task.taskId, + videoRecord.task.taskId, + ]); + + return resultAsset != null; + } catch (error) { + debugPrint("Error saving live photo: $error"); + return false; + } + } + + Future cancelDownload(String id) async { + return await FileDownloader().cancelTaskWithId(id); + } + + Future download(Asset asset) async { + if (asset.isImage && asset.livePhotoVideoId != null && Platform.isIOS) { + await _downloadRepository.download( + _buildDownloadTask( + asset.remoteId!, + asset.fileName, + group: downloadGroupLivePhoto, + metadata: LivePhotosMetadata( + part: LivePhotosPart.image, + id: asset.remoteId!, + ).toJson(), + ), + ); + + await _downloadRepository.download( + _buildDownloadTask( + asset.livePhotoVideoId!, + asset.fileName.toUpperCase().replaceAll(".HEIC", '.MOV'), + group: downloadGroupLivePhoto, + metadata: LivePhotosMetadata( + part: LivePhotosPart.video, + id: asset.remoteId!, + ).toJson(), + ), + ); + } else { + await _downloadRepository.download( + _buildDownloadTask( + asset.remoteId!, + asset.fileName, + group: asset.isImage ? downloadGroupImage : downloadGroupVideo, + ), + ); + } + } + + DownloadTask _buildDownloadTask( + String id, + String filename, { + String? group, + String? metadata, + }) { + final path = r'/assets/{id}/original'.replaceAll('{id}', id); + final serverEndpoint = Store.get(StoreKey.serverEndpoint); + final headers = ApiService.getRequestHeaders(); + + return DownloadTask( + taskId: id, + url: serverEndpoint + path, + headers: headers, + filename: filename, + updates: Updates.statusAndProgress, + group: group ?? '', + metaData: metadata ?? '', + ); + } +} diff --git a/mobile/lib/services/image_viewer.service.dart b/mobile/lib/services/image_viewer.service.dart deleted file mode 100644 index c94244175b529..0000000000000 --- a/mobile/lib/services/image_viewer.service.dart +++ /dev/null @@ -1,117 +0,0 @@ -import 'dart:io'; - -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/extensions/response_extensions.dart'; -import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:immich_mobile/interfaces/file_media.interface.dart'; -import 'package:immich_mobile/providers/api.provider.dart'; -import 'package:immich_mobile/repositories/file_media.repository.dart'; -import 'package:immich_mobile/services/api.service.dart'; -import 'package:logging/logging.dart'; - -import 'package:path_provider/path_provider.dart'; - -final imageViewerServiceProvider = Provider( - (ref) => ImageViewerService( - ref.watch(apiServiceProvider), - ref.watch(fileMediaRepositoryProvider), - ), -); - -class ImageViewerService { - final ApiService _apiService; - final IFileMediaRepository _fileMediaRepository; - final Logger _log = Logger("ImageViewerService"); - - ImageViewerService(this._apiService, this._fileMediaRepository); - - Future downloadAsset(Asset asset) async { - File? imageFile; - File? videoFile; - try { - // Download LivePhotos image and motion part - if (asset.isImage && asset.livePhotoVideoId != null && Platform.isIOS) { - var imageResponse = - await _apiService.assetsApi.downloadAssetWithHttpInfo( - asset.remoteId!, - ); - - var motionResponse = - await _apiService.assetsApi.downloadAssetWithHttpInfo( - asset.livePhotoVideoId!, - ); - - if (imageResponse.statusCode != 200 || - motionResponse.statusCode != 200) { - final failedResponse = - imageResponse.statusCode != 200 ? imageResponse : motionResponse; - _log.severe( - "Motion asset download failed", - failedResponse.toLoggerString(), - ); - return false; - } - - Asset? resultAsset; - - final tempDir = await getTemporaryDirectory(); - videoFile = await File('${tempDir.path}/livephoto.mov').create(); - imageFile = await File('${tempDir.path}/livephoto.heic').create(); - videoFile.writeAsBytesSync(motionResponse.bodyBytes); - imageFile.writeAsBytesSync(imageResponse.bodyBytes); - - resultAsset = await _fileMediaRepository.saveLivePhoto( - image: imageFile, - video: videoFile, - title: asset.fileName, - ); - - if (resultAsset == null) { - _log.warning( - "Asset cannot be saved as a live photo. This is most likely a motion photo. Saving only the image file", - ); - resultAsset = await _fileMediaRepository - .saveImage(imageResponse.bodyBytes, title: asset.fileName); - } - - return resultAsset != null; - } else { - var res = await _apiService.assetsApi - .downloadAssetWithHttpInfo(asset.remoteId!); - - if (res.statusCode != 200) { - _log.severe("Asset download failed", res.toLoggerString()); - return false; - } - - final Asset? resultAsset; - final relativePath = Platform.isAndroid ? 'DCIM/Immich' : null; - - if (asset.isImage) { - resultAsset = await _fileMediaRepository.saveImage( - res.bodyBytes, - title: asset.fileName, - relativePath: relativePath, - ); - } else { - final tempDir = await getTemporaryDirectory(); - videoFile = await File('${tempDir.path}/${asset.fileName}').create(); - videoFile.writeAsBytesSync(res.bodyBytes); - resultAsset = await _fileMediaRepository.saveVideo( - videoFile, - title: asset.fileName, - relativePath: relativePath, - ); - } - return resultAsset != null; - } - } catch (error, stack) { - _log.severe("Error saving downloaded asset", error, stack); - return false; - } finally { - // Clear temp files - imageFile?.delete(); - videoFile?.delete(); - } - } -} diff --git a/mobile/lib/utils/download.dart b/mobile/lib/utils/download.dart new file mode 100644 index 0000000000000..c701f353a2e6e --- /dev/null +++ b/mobile/lib/utils/download.dart @@ -0,0 +1,3 @@ +const downloadGroupImage = 'group_image'; +const downloadGroupVideo = 'group_video'; +const downloadGroupLivePhoto = 'group_livephoto'; diff --git a/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart b/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart index 8b5684d0fa241..c3f1390dba04a 100644 --- a/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart +++ b/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart @@ -9,7 +9,7 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/providers/album/current_album.provider.dart'; import 'package:immich_mobile/providers/album/shared_album.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/asset_stack.provider.dart'; -import 'package:immich_mobile/providers/asset_viewer/image_viewer_page_state.provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/download.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart'; import 'package:immich_mobile/services/stack.service.dart'; import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart'; @@ -172,7 +172,16 @@ class BottomGalleryBar extends ConsumerWidget { } shareAsset() { - ref.read(imageViewerStateProvider.notifier).shareAsset(asset, context); + if (asset.isOffline) { + ImmichToast.show( + durationInSecond: 1, + context: context, + msg: 'asset_action_share_err_offline'.tr(), + gravity: ToastGravity.BOTTOM, + ); + return; + } + ref.read(downloadStateProvider.notifier).shareAsset(asset, context); } void handleEdit() async { @@ -202,7 +211,17 @@ class BottomGalleryBar extends ConsumerWidget { if (asset.isLocal) { return; } - ref.read(imageViewerStateProvider.notifier).downloadAsset( + if (asset.isOffline) { + ImmichToast.show( + durationInSecond: 1, + context: context, + msg: 'asset_action_share_err_offline'.tr(), + gravity: ToastGravity.BOTTOM, + ); + return; + } + + ref.read(downloadStateProvider.notifier).downloadAsset( asset, context, ); diff --git a/mobile/lib/widgets/asset_viewer/gallery_app_bar.dart b/mobile/lib/widgets/asset_viewer/gallery_app_bar.dart index 6de8f5da33944..f400224e0a0be 100644 --- a/mobile/lib/widgets/asset_viewer/gallery_app_bar.dart +++ b/mobile/lib/widgets/asset_viewer/gallery_app_bar.dart @@ -5,7 +5,7 @@ import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/providers/album/current_album.provider.dart'; import 'package:immich_mobile/widgets/album/add_to_album_bottom_sheet.dart'; -import 'package:immich_mobile/providers/asset_viewer/image_viewer_page_state.provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/download.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart'; import 'package:immich_mobile/widgets/asset_viewer/top_control_app_bar.dart'; import 'package:immich_mobile/providers/backup/manual_upload.provider.dart'; @@ -94,7 +94,7 @@ class GalleryAppBar extends ConsumerWidget { } handleDownloadAsset() { - ref.read(imageViewerStateProvider.notifier).downloadAsset(asset, context); + ref.read(downloadStateProvider.notifier).downloadAsset(asset, context); } return IgnorePointer( diff --git a/mobile/lib/widgets/forms/login/login_form.dart b/mobile/lib/widgets/forms/login/login_form.dart index 51383fe1950f0..01b717ef5b977 100644 --- a/mobile/lib/widgets/forms/login/login_form.dart +++ b/mobile/lib/widgets/forms/login/login_form.dart @@ -176,7 +176,7 @@ class LoginForm extends HookConsumerWidget { populateTestLoginInfo1() { usernameController.text = 'testuser@email.com'; passwordController.text = 'password'; - serverEndpointController.text = 'http://10.1.15.216:2283/api'; + serverEndpointController.text = 'http://192.168.1.16:2283/api'; } login() async { diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index aaea00d699bbe..9dadbd1028a64 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -78,6 +78,14 @@ packages: url: "https://pub.dev" source: hosted version: "9.0.0" + background_downloader: + dependency: "direct main" + description: + name: background_downloader + sha256: "6a945db1a1c7727a4bc9c1d7c882cfb1a819f873b77e01d5e5dd6a3fb231cb28" + url: "https://pub.dev" + source: hosted + version: "8.5.5" boolean_selector: dependency: transitive description: @@ -744,10 +752,10 @@ packages: dependency: "direct main" description: name: http - sha256: "5895291c13fa8a3bd82e76d5627f69e0d85ca6a30dcac95c4ea19a5d555879c2" + sha256: b9c29a161230ee03d3ccf545097fccd9b87a5264228c5d348202e0f0c28f9010 url: "https://pub.dev" source: hosted - version: "0.13.6" + version: "1.2.2" http_multi_server: dependency: transitive description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index dc1eb11ca7f24..092b0bb75cf1d 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -32,7 +32,7 @@ dependencies: flutter_svg: ^2.0.9 package_info_plus: ^8.0.1 url_launcher: ^6.2.4 - http: ^0.13.6 + http: ^1.1.0 cancellation_token_http: ^2.0.0 easy_localization: ^3.0.3 share_plus: ^10.0.0 @@ -56,6 +56,7 @@ dependencies: thumbhash: 0.1.0+1 async: ^2.11.0 dynamic_color: ^1.7.0 #package to apply system theme + background_downloader: ^8.5.5 #image editing packages crop_image: ^1.0.13 From 9b309e84c922b2874afdce21961627a2841861c4 Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Mon, 30 Sep 2024 00:11:42 -0400 Subject: [PATCH 120/123] docs: update config file (#13041) update config file --- docs/docs/install/config-file.md | 82 +++++++++++++++++++++----------- 1 file changed, 55 insertions(+), 27 deletions(-) diff --git a/docs/docs/install/config-file.md b/docs/docs/install/config-file.md index abbba8c6b39b5..b789d8653f168 100644 --- a/docs/docs/install/config-file.md +++ b/docs/docs/install/config-file.md @@ -20,6 +20,7 @@ The default configuration looks like this: "acceptedVideoCodecs": ["h264"], "targetAudioCodec": "aac", "acceptedAudioCodecs": ["aac", "mp3", "libopus"], + "acceptedContainers": ["mov", "ogg", "webm"], "targetResolution": "720", "maxBitrate": "0", "bframes": -1, @@ -32,7 +33,8 @@ The default configuration looks like this: "preferredHwDevice": "auto", "transcode": "required", "tonemap": "hable", - "accel": "disabled" + "accel": "disabled", + "accelDecode": false }, "job": { "backgroundTask": { @@ -60,10 +62,13 @@ The default configuration looks like this: "concurrency": 5 }, "thumbnailGeneration": { - "concurrency": 5 + "concurrency": 3 }, "videoConversion": { "concurrency": 1 + }, + "notifications": { + "concurrency": 5 } }, "logging": { @@ -78,40 +83,46 @@ The default configuration looks like this: "modelName": "ViT-B-32__openai" }, "duplicateDetection": { - "enabled": false, - "maxDistance": 0.03 + "enabled": true, + "maxDistance": 0.01 }, "facialRecognition": { "enabled": true, "modelName": "buffalo_l", "minScore": 0.7, - "maxDistance": 0.6, + "maxDistance": 0.5, "minFaces": 3 } }, "map": { "enabled": true, - "lightStyle": "", - "darkStyle": "" + "lightStyle": "https://tiles.immich.cloud/v1/style/light.json", + "darkStyle": "https://tiles.immich.cloud/v1/style/dark.json" }, "reverseGeocoding": { "enabled": true }, + "metadata": { + "faces": { + "import": false + } + }, "oauth": { - "enabled": false, - "issuerUrl": "", + "autoLaunch": false, + "autoRegister": true, + "buttonText": "Login with OAuth", "clientId": "", "clientSecret": "", + "defaultStorageQuota": 0, + "enabled": false, + "issuerUrl": "", + "mobileOverrideEnabled": false, + "mobileRedirectUri": "", "scope": "openid email profile", "signingAlgorithm": "RS256", + "profileSigningAlgorithm": "none", "storageLabelClaim": "preferred_username", - "storageQuotaClaim": "immich_quota", - "defaultStorageQuota": 0, - "buttonText": "Login with OAuth", - "autoRegister": true, - "autoLaunch": false, - "mobileOverrideEnabled": false, - "mobileRedirectUri": "" + "storageQuotaClaim": "immich_quota" }, "passwordLogin": { "enabled": true @@ -122,11 +133,16 @@ The default configuration looks like this: "template": "{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}" }, "image": { - "thumbnailFormat": "webp", - "thumbnailSize": 250, - "previewFormat": "jpeg", - "previewSize": 1440, - "quality": 80, + "thumbnail": { + "format": "webp", + "size": 250, + "quality": 80 + }, + "preview": { + "format": "jpeg", + "size": 1440, + "quality": 80 + }, "colorspace": "p3", "extractEmbedded": false }, @@ -140,23 +156,35 @@ The default configuration looks like this: "theme": { "customCss": "" }, - "user": { - "deleteDelay": 7 - }, "library": { "scan": { "enabled": true, "cronExpression": "0 0 * * *" }, "watch": { - "enabled": false, - "usePolling": false, - "interval": 10000 + "enabled": false } }, "server": { "externalDomain": "", "loginPageMessage": "" + }, + "notifications": { + "smtp": { + "enabled": false, + "from": "", + "replyTo": "", + "transport": { + "ignoreCert": false, + "host": "", + "port": 587, + "username": "", + "password": "" + } + } + }, + "user": { + "deleteDelay": 7 } } ``` From 2f13db51df15d90221cb4f964936482003da21f2 Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Mon, 30 Sep 2024 00:29:14 -0400 Subject: [PATCH 121/123] fix(server): "all" button for facial recognition deleting faces instead of unassigning them (#13042) * unassign faces instead of deleting them * formatting --- server/src/interfaces/person.interface.ts | 10 ++++-- server/src/repositories/person.repository.ts | 36 +++++++++++++------ server/src/services/person.service.spec.ts | 5 +-- server/src/services/person.service.ts | 14 ++------ .../repositories/person.repository.mock.ts | 3 +- 5 files changed, 39 insertions(+), 29 deletions(-) diff --git a/server/src/interfaces/person.interface.ts b/server/src/interfaces/person.interface.ts index 5708274a6e99f..65814e0046f46 100644 --- a/server/src/interfaces/person.interface.ts +++ b/server/src/interfaces/person.interface.ts @@ -1,6 +1,7 @@ import { AssetFaceEntity } from 'src/entities/asset-face.entity'; import { AssetEntity } from 'src/entities/asset.entity'; import { PersonEntity } from 'src/entities/person.entity'; +import { SourceType } from 'src/enum'; import { Paginated, PaginationOptions } from 'src/utils/pagination'; import { FindManyOptions, FindOptionsRelations, FindOptionsSelect } from 'typeorm'; @@ -40,10 +41,12 @@ export interface PeopleStatistics { hidden: number; } -export interface DeleteAllFacesOptions { - sourceType?: string; +export interface DeleteFacesOptions { + sourceType: SourceType; } +export type UnassignFacesOptions = DeleteFacesOptions; + export interface IPersonRepository { getAll(pagination: PaginationOptions, options?: FindManyOptions): Paginated; getAllForUser(pagination: PaginationOptions, userId: string, options: PersonSearchOptions): Paginated; @@ -59,7 +62,7 @@ export interface IPersonRepository { createFaces(entities: Partial[]): Promise; delete(entities: PersonEntity[]): Promise; deleteAll(): Promise; - deleteAllFaces(options: DeleteAllFacesOptions): Promise; + deleteFaces(options: DeleteFacesOptions): Promise; replaceFaces(assetId: string, entities: Partial[], sourceType?: string): Promise; getAllFaces(pagination: PaginationOptions, options?: FindManyOptions): Paginated; getFaceById(id: string): Promise; @@ -75,6 +78,7 @@ export interface IPersonRepository { reassignFace(assetFaceId: string, newPersonId: string): Promise; getNumberOfPeople(userId: string): Promise; reassignFaces(data: UpdateFacesData): Promise; + unassignFaces(options: UnassignFacesOptions): Promise; update(person: Partial): Promise; updateAll(people: Partial[]): Promise; getLatestFaceDate(): Promise; diff --git a/server/src/repositories/person.repository.ts b/server/src/repositories/person.repository.ts index 2607d2a9ec7c4..0350e8a953027 100644 --- a/server/src/repositories/person.repository.ts +++ b/server/src/repositories/person.repository.ts @@ -9,13 +9,14 @@ import { PersonEntity } from 'src/entities/person.entity'; import { PaginationMode, SourceType } from 'src/enum'; import { AssetFaceId, - DeleteAllFacesOptions, + DeleteFacesOptions, IPersonRepository, PeopleStatistics, PersonNameResponse, PersonNameSearchOptions, PersonSearchOptions, PersonStatistics, + UnassignFacesOptions, UpdateFacesData, } from 'src/interfaces/person.interface'; import { Instrumentation } from 'src/utils/instrumentation'; @@ -39,12 +40,23 @@ export class PersonRepository implements IPersonRepository { .createQueryBuilder() .update() .set({ personId: newPersonId }) - .where(_.omitBy({ personId: oldPersonId ?? undefined, id: faceIds ? In(faceIds) : undefined }, _.isUndefined)) + .where(_.omitBy({ personId: oldPersonId, id: faceIds ? In(faceIds) : undefined }, _.isUndefined)) .execute(); return result.affected ?? 0; } + async unassignFaces({ sourceType }: UnassignFacesOptions): Promise { + await this.assetFaceRepository + .createQueryBuilder() + .update() + .set({ personId: null }) + .where({ sourceType }) + .execute(); + + await this.vacuum({ reindexVectors: false }); + } + async delete(entities: PersonEntity[]): Promise { await this.personRepository.remove(entities); } @@ -53,21 +65,14 @@ export class PersonRepository implements IPersonRepository { await this.personRepository.clear(); } - async deleteAllFaces({ sourceType }: DeleteAllFacesOptions): Promise { - if (!sourceType) { - return this.assetFaceRepository.query('TRUNCATE TABLE asset_faces CASCADE'); - } - + async deleteFaces({ sourceType }: DeleteFacesOptions): Promise { await this.assetFaceRepository .createQueryBuilder('asset_faces') .delete() .andWhere('sourceType = :sourceType', { sourceType }) .execute(); - await this.assetFaceRepository.query('VACUUM ANALYZE asset_faces, face_search'); - if (sourceType === SourceType.MACHINE_LEARNING) { - await this.assetFaceRepository.query('REINDEX INDEX face_index'); - } + await this.vacuum({ reindexVectors: sourceType === SourceType.MACHINE_LEARNING }); } getAllFaces( @@ -331,4 +336,13 @@ export class PersonRepository implements IPersonRepository { const { id } = await this.personRepository.save(person); return this.personRepository.findOneByOrFail({ id }); } + + private async vacuum({ reindexVectors }: { reindexVectors: boolean }): Promise { + await this.assetFaceRepository.query('VACUUM ANALYZE asset_faces, face_search, person'); + await this.assetFaceRepository.query('REINDEX TABLE asset_faces'); + await this.assetFaceRepository.query('REINDEX TABLE person'); + if (reindexVectors) { + await this.assetFaceRepository.query('REINDEX TABLE face_search'); + } + } } diff --git a/server/src/services/person.service.spec.ts b/server/src/services/person.service.spec.ts index c2b8f182216d8..5214808de0344 100644 --- a/server/src/services/person.service.spec.ts +++ b/server/src/services/person.service.spec.ts @@ -660,7 +660,7 @@ describe(PersonService.name, () => { expect(systemMock.set).not.toHaveBeenCalled(); }); - it('should delete existing people and faces if forced', async () => { + it('should delete existing people if forced', async () => { jobMock.getJobCounts.mockResolvedValue({ active: 1, waiting: 0, paused: 0, completed: 0, failed: 0, delayed: 0 }); personMock.getAll.mockResolvedValue({ items: [faceStub.face1.person, personStub.randomPerson], @@ -675,7 +675,8 @@ describe(PersonService.name, () => { await sut.handleQueueRecognizeFaces({ force: true }); - expect(personMock.deleteAllFaces).toHaveBeenCalledWith({ sourceType: SourceType.MACHINE_LEARNING }); + expect(personMock.deleteFaces).not.toHaveBeenCalled(); + expect(personMock.unassignFaces).toHaveBeenCalledWith({ sourceType: SourceType.MACHINE_LEARNING }); expect(jobMock.queueAll).toHaveBeenCalledWith([ { name: JobName.FACIAL_RECOGNITION, diff --git a/server/src/services/person.service.ts b/server/src/services/person.service.ts index e8e16adb1737f..b009696b637fe 100644 --- a/server/src/services/person.service.ts +++ b/server/src/services/person.service.ts @@ -276,16 +276,6 @@ export class PersonService { this.logger.debug(`Deleted ${people.length} people`); } - private async deleteAllPeople() { - const personPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => - this.repository.getAll({ ...pagination, skip: 0 }), - ); - - for await (const people of personPagination) { - await this.delete(people); // deletes thumbnails too - } - } - async handlePersonCleanup(): Promise { const people = await this.repository.getAllWithoutFaces(); await this.delete(people); @@ -299,7 +289,7 @@ export class PersonService { } if (force) { - await this.repository.deleteAllFaces({ sourceType: SourceType.MACHINE_LEARNING }); + await this.repository.deleteFaces({ sourceType: SourceType.MACHINE_LEARNING }); await this.handlePersonCleanup(); } @@ -407,7 +397,7 @@ export class PersonService { const { waiting } = await this.jobRepository.getJobCounts(QueueName.FACIAL_RECOGNITION); if (force) { - await this.repository.deleteAllFaces({ sourceType: SourceType.MACHINE_LEARNING }); + await this.repository.unassignFaces({ sourceType: SourceType.MACHINE_LEARNING }); await this.handlePersonCleanup(); } else if (waiting) { this.logger.debug( diff --git a/server/test/repositories/person.repository.mock.ts b/server/test/repositories/person.repository.mock.ts index 77e8ccf010671..6ffe7bf97be1c 100644 --- a/server/test/repositories/person.repository.mock.ts +++ b/server/test/repositories/person.repository.mock.ts @@ -18,7 +18,7 @@ export const newPersonRepositoryMock = (): Mocked => { updateAll: vitest.fn(), delete: vitest.fn(), deleteAll: vitest.fn(), - deleteAllFaces: vitest.fn(), + deleteFaces: vitest.fn(), getStatistics: vitest.fn(), getAllFaces: vitest.fn(), @@ -26,6 +26,7 @@ export const newPersonRepositoryMock = (): Mocked => { getRandomFace: vitest.fn(), reassignFaces: vitest.fn(), + unassignFaces: vitest.fn(), createFaces: vitest.fn(), replaceFaces: vitest.fn(), getFaces: vitest.fn(), From 7adb35e59e5c8e00e5391abfb69bee7acb068bb2 Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Mon, 30 Sep 2024 00:29:35 -0400 Subject: [PATCH 122/123] fix(server): `/search/random` failing with certain options (#13040) * fix relation handling, remove pagination * update api, sql * update mock --- mobile/openapi/lib/api/search_api.dart | 9 +- .../openapi/lib/model/random_search_dto.dart | 20 +- open-api/immich-openapi-specs.json | 9 +- open-api/typescript-sdk/src/fetch-client.ts | 3 +- server/src/controllers/search.controller.ts | 2 +- server/src/dtos/search.dto.ts | 18 +- server/src/interfaces/search.interface.ts | 2 +- server/src/queries/search.repository.sql | 189 +++++++++++++++++- server/src/repositories/search.repository.ts | 41 +++- server/src/services/search.service.ts | 16 +- server/src/utils/database.ts | 2 +- .../repositories/search.repository.mock.ts | 1 + 12 files changed, 250 insertions(+), 62 deletions(-) diff --git a/mobile/openapi/lib/api/search_api.dart b/mobile/openapi/lib/api/search_api.dart index 3b981e0ccb5bb..985029f106d27 100644 --- a/mobile/openapi/lib/api/search_api.dart +++ b/mobile/openapi/lib/api/search_api.dart @@ -383,7 +383,7 @@ class SearchApi { /// Parameters: /// /// * [RandomSearchDto] randomSearchDto (required): - Future searchRandom(RandomSearchDto randomSearchDto,) async { + Future?> searchRandom(RandomSearchDto randomSearchDto,) async { final response = await searchRandomWithHttpInfo(randomSearchDto,); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); @@ -392,8 +392,11 @@ class SearchApi { // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" // FormatException when trying to decode an empty string. if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { - return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'SearchResponseDto',) as SearchResponseDto; - + final responseBody = await _decodeBodyBytes(response); + return (await apiClient.deserializeAsync(responseBody, 'List') as List) + .cast() + .toList(growable: false); + } return null; } diff --git a/mobile/openapi/lib/model/random_search_dto.dart b/mobile/openapi/lib/model/random_search_dto.dart index 419cb451e2a02..3fcab05bbb275 100644 --- a/mobile/openapi/lib/model/random_search_dto.dart +++ b/mobile/openapi/lib/model/random_search_dto.dart @@ -29,7 +29,6 @@ class RandomSearchDto { this.libraryId, this.make, this.model, - this.page, this.personIds = const [], this.size, this.state, @@ -145,15 +144,6 @@ class RandomSearchDto { String? model; - /// Minimum value: 1 - /// - /// Please note: This property should have been non-nullable! Since the specification file - /// does not include a default value (using the "default:" property), however, the generated - /// source code must fall back to having a nullable type. - /// Consider adding a "default:" property in the specification file to hide this note. - /// - num? page; - List personIds; /// Minimum value: 1 @@ -276,7 +266,6 @@ class RandomSearchDto { other.libraryId == libraryId && other.make == make && other.model == model && - other.page == page && _deepEquality.equals(other.personIds, personIds) && other.size == size && other.state == state && @@ -312,7 +301,6 @@ class RandomSearchDto { (libraryId == null ? 0 : libraryId!.hashCode) + (make == null ? 0 : make!.hashCode) + (model == null ? 0 : model!.hashCode) + - (page == null ? 0 : page!.hashCode) + (personIds.hashCode) + (size == null ? 0 : size!.hashCode) + (state == null ? 0 : state!.hashCode) + @@ -330,7 +318,7 @@ class RandomSearchDto { (withStacked == null ? 0 : withStacked!.hashCode); @override - String toString() => 'RandomSearchDto[city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, deviceId=$deviceId, isArchived=$isArchived, isEncoded=$isEncoded, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, isVisible=$isVisible, lensModel=$lensModel, libraryId=$libraryId, make=$make, model=$model, page=$page, personIds=$personIds, size=$size, state=$state, takenAfter=$takenAfter, takenBefore=$takenBefore, trashedAfter=$trashedAfter, trashedBefore=$trashedBefore, type=$type, updatedAfter=$updatedAfter, updatedBefore=$updatedBefore, withArchived=$withArchived, withDeleted=$withDeleted, withExif=$withExif, withPeople=$withPeople, withStacked=$withStacked]'; + String toString() => 'RandomSearchDto[city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, deviceId=$deviceId, isArchived=$isArchived, isEncoded=$isEncoded, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, isVisible=$isVisible, lensModel=$lensModel, libraryId=$libraryId, make=$make, model=$model, personIds=$personIds, size=$size, state=$state, takenAfter=$takenAfter, takenBefore=$takenBefore, trashedAfter=$trashedAfter, trashedBefore=$trashedBefore, type=$type, updatedAfter=$updatedAfter, updatedBefore=$updatedBefore, withArchived=$withArchived, withDeleted=$withDeleted, withExif=$withExif, withPeople=$withPeople, withStacked=$withStacked]'; Map toJson() { final json = {}; @@ -413,11 +401,6 @@ class RandomSearchDto { json[r'model'] = this.model; } else { // json[r'model'] = null; - } - if (this.page != null) { - json[r'page'] = this.page; - } else { - // json[r'page'] = null; } json[r'personIds'] = this.personIds; if (this.size != null) { @@ -514,7 +497,6 @@ class RandomSearchDto { libraryId: mapValueOfType(json, r'libraryId'), make: mapValueOfType(json, r'make'), model: mapValueOfType(json, r'model'), - page: num.parse('${json[r'page']}'), personIds: json[r'personIds'] is Iterable ? (json[r'personIds'] as Iterable).cast().toList(growable: false) : const [], diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 1077762ac3a56..970230f4e3d3e 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -4615,7 +4615,10 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SearchResponseDto" + "items": { + "$ref": "#/components/schemas/AssetResponseDto" + }, + "type": "array" } } }, @@ -10463,10 +10466,6 @@ "nullable": true, "type": "string" }, - "page": { - "minimum": 1, - "type": "number" - }, "personIds": { "items": { "format": "uuid", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index e88f431e8c787..aa3501079bf86 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -852,7 +852,6 @@ export type RandomSearchDto = { libraryId?: string | null; make?: string; model?: string | null; - page?: number; personIds?: string[]; size?: number; state?: string | null; @@ -2523,7 +2522,7 @@ export function searchRandom({ randomSearchDto }: { }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; - data: SearchResponseDto; + data: AssetResponseDto[]; }>("/search/random", oazapfts.json({ ...opts, method: "POST", diff --git a/server/src/controllers/search.controller.ts b/server/src/controllers/search.controller.ts index 5b6deb2981bc5..9fdb2746fc1d3 100644 --- a/server/src/controllers/search.controller.ts +++ b/server/src/controllers/search.controller.ts @@ -32,7 +32,7 @@ export class SearchController { @Post('random') @HttpCode(HttpStatus.OK) @Authenticated() - searchRandom(@Auth() auth: AuthDto, @Body() dto: RandomSearchDto): Promise { + searchRandom(@Auth() auth: AuthDto, @Body() dto: RandomSearchDto): Promise { return this.service.searchRandom(auth, dto); } diff --git a/server/src/dtos/search.dto.ts b/server/src/dtos/search.dto.ts index ddc6c192c5faa..5c5dce1a1190a 100644 --- a/server/src/dtos/search.dto.ts +++ b/server/src/dtos/search.dto.ts @@ -99,12 +99,6 @@ class BaseSearchDto { @Optional({ nullable: true, emptyToNull: true }) lensModel?: string | null; - @IsInt() - @Min(1) - @Type(() => Number) - @Optional() - page?: number; - @IsInt() @Min(1) @Max(1000) @@ -170,12 +164,24 @@ export class MetadataSearchDto extends RandomSearchDto { @Optional() @ApiProperty({ enumName: 'AssetOrder', enum: AssetOrder }) order?: AssetOrder; + + @IsInt() + @Min(1) + @Type(() => Number) + @Optional() + page?: number; } export class SmartSearchDto extends BaseSearchDto { @IsString() @IsNotEmpty() query!: string; + + @IsInt() + @Min(1) + @Type(() => Number) + @Optional() + page?: number; } export class SearchPlacesDto { diff --git a/server/src/interfaces/search.interface.ts b/server/src/interfaces/search.interface.ts index 0ba524c00a272..63d74a35fb626 100644 --- a/server/src/interfaces/search.interface.ts +++ b/server/src/interfaces/search.interface.ts @@ -116,7 +116,6 @@ export interface SearchPeopleOptions { export interface SearchOrderOptions { orderDirection?: 'ASC' | 'DESC'; - random?: boolean; } export interface SearchPaginationOptions { @@ -177,6 +176,7 @@ export interface ISearchRepository { searchSmart(pagination: SearchPaginationOptions, options: SmartSearchOptions): Paginated; searchDuplicates(options: AssetDuplicateSearch): Promise; searchFaces(search: FaceEmbeddingSearch): Promise; + searchRandom(size: number, options: AssetSearchOptions): Promise; upsert(assetId: string, embedding: number[]): Promise; searchPlaces(placeName: string): Promise; getAssetsByCity(userIds: string[]): Promise; diff --git a/server/src/queries/search.repository.sql b/server/src/queries/search.repository.sql index 58b2999012cf7..cd9a84b016d0f 100644 --- a/server/src/queries/search.repository.sql +++ b/server/src/queries/search.repository.sql @@ -77,10 +77,11 @@ FROM "asset"."fileCreatedAt" >= $1 AND "exifInfo"."lensModel" = $2 AND 1 = 1 + AND "asset"."ownerId" IN ($3) AND 1 = 1 AND ( - "asset"."isFavorite" = $3 - AND "asset"."isArchived" = $4 + "asset"."isFavorite" = $4 + AND "asset"."isArchived" = $5 ) ) AND ("asset"."deletedAt" IS NULL) @@ -91,6 +92,190 @@ ORDER BY LIMIT 101 +-- SearchRepository.searchRandom +SELECT DISTINCT + "distinctAlias"."asset_id" AS "ids_asset_id", + "distinctAlias"."asset_id" +FROM + ( + SELECT + "asset"."id" AS "asset_id", + "asset"."deviceAssetId" AS "asset_deviceAssetId", + "asset"."ownerId" AS "asset_ownerId", + "asset"."libraryId" AS "asset_libraryId", + "asset"."deviceId" AS "asset_deviceId", + "asset"."type" AS "asset_type", + "asset"."status" AS "asset_status", + "asset"."originalPath" AS "asset_originalPath", + "asset"."thumbhash" AS "asset_thumbhash", + "asset"."encodedVideoPath" AS "asset_encodedVideoPath", + "asset"."createdAt" AS "asset_createdAt", + "asset"."updatedAt" AS "asset_updatedAt", + "asset"."deletedAt" AS "asset_deletedAt", + "asset"."fileCreatedAt" AS "asset_fileCreatedAt", + "asset"."localDateTime" AS "asset_localDateTime", + "asset"."fileModifiedAt" AS "asset_fileModifiedAt", + "asset"."isFavorite" AS "asset_isFavorite", + "asset"."isArchived" AS "asset_isArchived", + "asset"."isExternal" AS "asset_isExternal", + "asset"."isOffline" AS "asset_isOffline", + "asset"."checksum" AS "asset_checksum", + "asset"."duration" AS "asset_duration", + "asset"."isVisible" AS "asset_isVisible", + "asset"."livePhotoVideoId" AS "asset_livePhotoVideoId", + "asset"."originalFileName" AS "asset_originalFileName", + "asset"."sidecarPath" AS "asset_sidecarPath", + "asset"."stackId" AS "asset_stackId", + "asset"."duplicateId" AS "asset_duplicateId", + "stack"."id" AS "stack_id", + "stack"."ownerId" AS "stack_ownerId", + "stack"."primaryAssetId" AS "stack_primaryAssetId", + "stackedAssets"."id" AS "stackedAssets_id", + "stackedAssets"."deviceAssetId" AS "stackedAssets_deviceAssetId", + "stackedAssets"."ownerId" AS "stackedAssets_ownerId", + "stackedAssets"."libraryId" AS "stackedAssets_libraryId", + "stackedAssets"."deviceId" AS "stackedAssets_deviceId", + "stackedAssets"."type" AS "stackedAssets_type", + "stackedAssets"."status" AS "stackedAssets_status", + "stackedAssets"."originalPath" AS "stackedAssets_originalPath", + "stackedAssets"."thumbhash" AS "stackedAssets_thumbhash", + "stackedAssets"."encodedVideoPath" AS "stackedAssets_encodedVideoPath", + "stackedAssets"."createdAt" AS "stackedAssets_createdAt", + "stackedAssets"."updatedAt" AS "stackedAssets_updatedAt", + "stackedAssets"."deletedAt" AS "stackedAssets_deletedAt", + "stackedAssets"."fileCreatedAt" AS "stackedAssets_fileCreatedAt", + "stackedAssets"."localDateTime" AS "stackedAssets_localDateTime", + "stackedAssets"."fileModifiedAt" AS "stackedAssets_fileModifiedAt", + "stackedAssets"."isFavorite" AS "stackedAssets_isFavorite", + "stackedAssets"."isArchived" AS "stackedAssets_isArchived", + "stackedAssets"."isExternal" AS "stackedAssets_isExternal", + "stackedAssets"."isOffline" AS "stackedAssets_isOffline", + "stackedAssets"."checksum" AS "stackedAssets_checksum", + "stackedAssets"."duration" AS "stackedAssets_duration", + "stackedAssets"."isVisible" AS "stackedAssets_isVisible", + "stackedAssets"."livePhotoVideoId" AS "stackedAssets_livePhotoVideoId", + "stackedAssets"."originalFileName" AS "stackedAssets_originalFileName", + "stackedAssets"."sidecarPath" AS "stackedAssets_sidecarPath", + "stackedAssets"."stackId" AS "stackedAssets_stackId", + "stackedAssets"."duplicateId" AS "stackedAssets_duplicateId" + FROM + "assets" "asset" + LEFT JOIN "exif" "exifInfo" ON "exifInfo"."assetId" = "asset"."id" + LEFT JOIN "asset_stack" "stack" ON "stack"."id" = "asset"."stackId" + LEFT JOIN "assets" "stackedAssets" ON "stackedAssets"."stackId" = "stack"."id" + AND ("stackedAssets"."deletedAt" IS NULL) + WHERE + ( + "asset"."fileCreatedAt" >= $1 + AND "exifInfo"."lensModel" = $2 + AND 1 = 1 + AND "asset"."ownerId" IN ($3) + AND 1 = 1 + AND ( + "asset"."isFavorite" = $4 + AND "asset"."isArchived" = $5 + ) + AND "asset"."id" > $6 + ) + AND ("asset"."deletedAt" IS NULL) + ) "distinctAlias" +ORDER BY + "distinctAlias"."asset_id" ASC, + "asset_id" ASC +LIMIT + 100 +SELECT DISTINCT + "distinctAlias"."asset_id" AS "ids_asset_id", + "distinctAlias"."asset_id" +FROM + ( + SELECT + "asset"."id" AS "asset_id", + "asset"."deviceAssetId" AS "asset_deviceAssetId", + "asset"."ownerId" AS "asset_ownerId", + "asset"."libraryId" AS "asset_libraryId", + "asset"."deviceId" AS "asset_deviceId", + "asset"."type" AS "asset_type", + "asset"."status" AS "asset_status", + "asset"."originalPath" AS "asset_originalPath", + "asset"."thumbhash" AS "asset_thumbhash", + "asset"."encodedVideoPath" AS "asset_encodedVideoPath", + "asset"."createdAt" AS "asset_createdAt", + "asset"."updatedAt" AS "asset_updatedAt", + "asset"."deletedAt" AS "asset_deletedAt", + "asset"."fileCreatedAt" AS "asset_fileCreatedAt", + "asset"."localDateTime" AS "asset_localDateTime", + "asset"."fileModifiedAt" AS "asset_fileModifiedAt", + "asset"."isFavorite" AS "asset_isFavorite", + "asset"."isArchived" AS "asset_isArchived", + "asset"."isExternal" AS "asset_isExternal", + "asset"."isOffline" AS "asset_isOffline", + "asset"."checksum" AS "asset_checksum", + "asset"."duration" AS "asset_duration", + "asset"."isVisible" AS "asset_isVisible", + "asset"."livePhotoVideoId" AS "asset_livePhotoVideoId", + "asset"."originalFileName" AS "asset_originalFileName", + "asset"."sidecarPath" AS "asset_sidecarPath", + "asset"."stackId" AS "asset_stackId", + "asset"."duplicateId" AS "asset_duplicateId", + "stack"."id" AS "stack_id", + "stack"."ownerId" AS "stack_ownerId", + "stack"."primaryAssetId" AS "stack_primaryAssetId", + "stackedAssets"."id" AS "stackedAssets_id", + "stackedAssets"."deviceAssetId" AS "stackedAssets_deviceAssetId", + "stackedAssets"."ownerId" AS "stackedAssets_ownerId", + "stackedAssets"."libraryId" AS "stackedAssets_libraryId", + "stackedAssets"."deviceId" AS "stackedAssets_deviceId", + "stackedAssets"."type" AS "stackedAssets_type", + "stackedAssets"."status" AS "stackedAssets_status", + "stackedAssets"."originalPath" AS "stackedAssets_originalPath", + "stackedAssets"."thumbhash" AS "stackedAssets_thumbhash", + "stackedAssets"."encodedVideoPath" AS "stackedAssets_encodedVideoPath", + "stackedAssets"."createdAt" AS "stackedAssets_createdAt", + "stackedAssets"."updatedAt" AS "stackedAssets_updatedAt", + "stackedAssets"."deletedAt" AS "stackedAssets_deletedAt", + "stackedAssets"."fileCreatedAt" AS "stackedAssets_fileCreatedAt", + "stackedAssets"."localDateTime" AS "stackedAssets_localDateTime", + "stackedAssets"."fileModifiedAt" AS "stackedAssets_fileModifiedAt", + "stackedAssets"."isFavorite" AS "stackedAssets_isFavorite", + "stackedAssets"."isArchived" AS "stackedAssets_isArchived", + "stackedAssets"."isExternal" AS "stackedAssets_isExternal", + "stackedAssets"."isOffline" AS "stackedAssets_isOffline", + "stackedAssets"."checksum" AS "stackedAssets_checksum", + "stackedAssets"."duration" AS "stackedAssets_duration", + "stackedAssets"."isVisible" AS "stackedAssets_isVisible", + "stackedAssets"."livePhotoVideoId" AS "stackedAssets_livePhotoVideoId", + "stackedAssets"."originalFileName" AS "stackedAssets_originalFileName", + "stackedAssets"."sidecarPath" AS "stackedAssets_sidecarPath", + "stackedAssets"."stackId" AS "stackedAssets_stackId", + "stackedAssets"."duplicateId" AS "stackedAssets_duplicateId" + FROM + "assets" "asset" + LEFT JOIN "exif" "exifInfo" ON "exifInfo"."assetId" = "asset"."id" + LEFT JOIN "asset_stack" "stack" ON "stack"."id" = "asset"."stackId" + LEFT JOIN "assets" "stackedAssets" ON "stackedAssets"."stackId" = "stack"."id" + AND ("stackedAssets"."deletedAt" IS NULL) + WHERE + ( + "asset"."fileCreatedAt" >= $1 + AND "exifInfo"."lensModel" = $2 + AND 1 = 1 + AND "asset"."ownerId" IN ($3) + AND 1 = 1 + AND ( + "asset"."isFavorite" = $4 + AND "asset"."isArchived" = $5 + ) + AND "asset"."id" < $6 + ) + AND ("asset"."deletedAt" IS NULL) + ) "distinctAlias" +ORDER BY + "distinctAlias"."asset_id" ASC, + "asset_id" ASC +LIMIT + 100 + -- SearchRepository.searchSmart START TRANSACTION SET diff --git a/server/src/repositories/search.repository.ts b/server/src/repositories/search.repository.ts index 60694b6bfe800..cb80c8d2f1c4e 100644 --- a/server/src/repositories/search.repository.ts +++ b/server/src/repositories/search.repository.ts @@ -1,5 +1,6 @@ import { Inject, Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; +import { randomUUID } from 'node:crypto'; import { getVectorExtension } from 'src/database.config'; import { DummyValue, GenerateSql } from 'src/decorators'; import { AssetFaceEntity } from 'src/entities/asset-face.entity'; @@ -63,22 +64,15 @@ export class SearchRepository implements ISearchRepository { { takenAfter: DummyValue.DATE, lensModel: DummyValue.STRING, - ownerId: DummyValue.UUID, withStacked: true, isFavorite: true, - ownerIds: [DummyValue.UUID], + userIds: [DummyValue.UUID], }, ], }) async searchMetadata(pagination: SearchPaginationOptions, options: AssetSearchOptions): Paginated { let builder = this.assetRepository.createQueryBuilder('asset'); - builder = searchAssetBuilder(builder, options); - builder.orderBy('asset.fileCreatedAt', options.orderDirection ?? 'DESC'); - - if (options.random) { - // TODO replace with complicated SQL magic after kysely migration - builder.addSelect('RANDOM() as r').orderBy('r'); - } + builder = searchAssetBuilder(builder, options).orderBy('asset.fileCreatedAt', options.orderDirection ?? 'DESC'); return paginatedBuilder(builder, { mode: PaginationMode.SKIP_TAKE, @@ -87,6 +81,35 @@ export class SearchRepository implements ISearchRepository { }); } + @GenerateSql({ + params: [ + 100, + { + takenAfter: DummyValue.DATE, + lensModel: DummyValue.STRING, + withStacked: true, + isFavorite: true, + userIds: [DummyValue.UUID], + }, + ], + }) + async searchRandom(size: number, options: AssetSearchOptions): Promise { + const builder1 = searchAssetBuilder(this.assetRepository.createQueryBuilder('asset'), options); + const builder2 = builder1.clone(); + + const uuid = randomUUID(); + builder1.andWhere('asset.id > :uuid', { uuid }).orderBy('asset.id').take(size); + builder2.andWhere('asset.id < :uuid', { uuid }).orderBy('asset.id').take(size); + + const [assets1, assets2] = await Promise.all([builder1.getMany(), builder2.getMany()]); + const missingCount = size - assets1.length; + for (let i = 0; i < missingCount && i < assets2.length; i++) { + assets1.push(assets2[i]); + } + + return assets1; + } + private createPersonFilter(builder: SelectQueryBuilder, personIds: string[]) { return builder .select(`${builder.alias}."assetId"`) diff --git a/server/src/services/search.service.ts b/server/src/services/search.service.ts index dc6e71f345943..c3cc5399c8d73 100644 --- a/server/src/services/search.service.ts +++ b/server/src/services/search.service.ts @@ -94,20 +94,10 @@ export class SearchService { return this.mapResponse(items, hasNextPage ? (page + 1).toString() : null, { auth }); } - async searchRandom(auth: AuthDto, dto: RandomSearchDto): Promise { + async searchRandom(auth: AuthDto, dto: RandomSearchDto): Promise { const userIds = await this.getUserIdsToSearch(auth); - const page = dto.page ?? 1; - const size = dto.size || 250; - const { hasNextPage, items } = await this.searchRepository.searchMetadata( - { page, size }, - { - ...dto, - userIds, - random: true, - }, - ); - - return this.mapResponse(items, hasNextPage ? (page + 1).toString() : null, { auth }); + const items = await this.searchRepository.searchRandom(dto.size || 250, { ...dto, userIds }); + return items.map((item) => mapAsset(item, { auth })); } async searchSmart(auth: AuthDto, dto: SmartSearchDto): Promise { diff --git a/server/src/utils/database.ts b/server/src/utils/database.ts index 5f4577f4df59b..498dd3456b932 100644 --- a/server/src/utils/database.ts +++ b/server/src/utils/database.ts @@ -120,7 +120,7 @@ export function searchAssetBuilder( } if (withPeople) { - builder.leftJoinAndSelect(`${builder.alias}.person`, 'person'); + builder.leftJoinAndSelect('faces.person', 'person'); } if (withSmartInfo) { diff --git a/server/test/repositories/search.repository.mock.ts b/server/test/repositories/search.repository.mock.ts index 5426316b650ef..be0e753e30577 100644 --- a/server/test/repositories/search.repository.mock.ts +++ b/server/test/repositories/search.repository.mock.ts @@ -7,6 +7,7 @@ export const newSearchRepositoryMock = (): Mocked => { searchSmart: vitest.fn(), searchDuplicates: vitest.fn(), searchFaces: vitest.fn(), + searchRandom: vitest.fn(), upsert: vitest.fn(), searchPlaces: vitest.fn(), getAssetsByCity: vitest.fn(), From 5bcbe77fb6d3b322e08f671d78093f2c3102611a Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 30 Sep 2024 12:02:30 +0100 Subject: [PATCH 123/123] chore(deps): update terraform cloudflare to v4.43.0 (#12860) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .../docs-release/.terraform.lock.hcl | 60 +++++++++---------- .../modules/cloudflare/docs-release/config.tf | 2 +- .../cloudflare/docs/.terraform.lock.hcl | 60 +++++++++---------- deployment/modules/cloudflare/docs/config.tf | 2 +- 4 files changed, 62 insertions(+), 62 deletions(-) diff --git a/deployment/modules/cloudflare/docs-release/.terraform.lock.hcl b/deployment/modules/cloudflare/docs-release/.terraform.lock.hcl index afa00e60677c1..6419c16dad324 100644 --- a/deployment/modules/cloudflare/docs-release/.terraform.lock.hcl +++ b/deployment/modules/cloudflare/docs-release/.terraform.lock.hcl @@ -2,37 +2,37 @@ # Manual edits may be lost in future updates. provider "registry.opentofu.org/cloudflare/cloudflare" { - version = "4.41.0" - constraints = "4.41.0" + version = "4.43.0" + constraints = "4.43.0" hashes = [ - "h1:0mc+YrjQrcctGrGYDmzlcqcgSv9MYB74rvMaZylIKC8=", - "h1:0zUx4vk4jOORQqn6xHBF7dO6N6bielFHdJ0mgF4Obn8=", - "h1:AsIZW3uLFNOZO7kL/K7/Y/S0IYxUV9Hz85NNk/3TTsA=", - "h1:FSgYM4+LHMbX/a4Y1kx7FPPWmXqS3/MQYzvjMJHHHWM=", - "h1:Tx6Nh3BWP1x9L3KK/Eyi+ET0T26g3+jf1jyiuqpNIis=", - "h1:VRI9wu8P43xxfpeTndRwsisLnqncfnmEYMOEH5zH4pQ=", - "h1:YxQqmiES/Yanq/VfGqBEqg+VIO7FGhO88aKoWFHyGIg=", - "h1:ZWHiaesjgDLKWlfdNj0oKyj/DWdxcfsO6NINu39zfpY=", - "h1:a2aCgDDBz3ccrr8YstIMl7VFnKo1xZAp+rOv59PPJ7U=", - "h1:aRyv8tB6wBAF9lKsLEdiHyCqnK5LfZq0FqMXCcUB4UU=", - "h1:lXpuO7zv2uD2GzPE1ARxznreRAh+QHTc2lAJ7iOoFgY=", - "h1:sA1xq0QNQ4fH8SHXouYNq50xirVD18SamKQwPsBQrrY=", - "h1:v7sHvKq7oqMYPn47ULHFyIQsKD9o+6Xg/uHbxQUixEw=", - "h1:wo/x4atWyXuWGlfR6h5nH0YwBAmBwTRY27HtWP8ycLo=", - "zh:339d26e06dc6fb299ea8aad9476a60fd65bb1d40631ae8eeb81cddf2dd2bebc8", - "zh:3dec2ad96ac2c283fd34ce65781b55c4edbb4d5c5cb53da8e31537176c0ed562", - "zh:5f63a5f8080319a2fff09d4d49944829fa708723436520787cfb60725ced80cf", - "zh:67162c28ccea71cb8141ed15c0637e35621354ebe14878e0b75a8f160fc5505d", - "zh:6ac1e07f5347b6395aca690ed22101bb25e957d25f986f760ff673a7adfd5ef6", - "zh:70282a723c7b52fcabde2baad41c864ed3a8d69f0c4d27a6b6933cac434cffc6", + "h1:2kDVLD36BOVgBzI9p0WIQ+xjFfMmjaItA0l8SyZWEPo=", + "h1:2sGJDAwFEgO8+3y+2suYO+yrjNOzSsihad0hbM3+jPg=", + "h1:A1WPQFcdD+7FrFBFrKcx4CiSr75xSmsO93C0e5NBAeQ=", + "h1:BuXs/1ohmF4fWyOErY6vNbm7DaEIfbLSepSiZ2ol9I8=", + "h1:QPh+X19oyo808sqdeJaVqahZcQgcG1jCi3DA5zpjz6U=", + "h1:RI7c7dhSJoIkfou5b8ITRpM5MqsQD3FULj1h/rI4rJk=", + "h1:gdI5JTCPjewdGq1bhGAs+V5qCcmJ73N2gtMfuFybJp4=", + "h1:h4lnJpCIYZ7dsN9IO2mmwNdWNiQYEPoAEUjLF2sZ5kc=", + "h1:jTaExrX/eR7vGT5wayGqH8ZtXS2zyk0WmD3zbAKFIQU=", + "h1:l5NKJUOQJ1mHl1eekeXaxUZ+g+8Yv4aGcIN9vuK6GL4=", + "h1:sNbvm66/2vc8B/khyioOO8eNaU8nb89x693AN7fQheU=", + "h1:tXS4g1yE420AU4mvZ7RrYI+yYTutkRID3l+W0gBH4BM=", + "h1:vA+kES7uqmKA9K0U45IXR94jaTQZCHZLCHqMUeGxKMI=", + "h1:zV131k79+ob9p4jrLDgztDNvZvt8fvrrzpn0nPikBw8=", + "zh:006d111d6eafe6eeb5df2f91bd0ca320f979bd71f8cd8c475f10b2bd94acba55", + "zh:031fbb5cac23a841dc18e270cbfcd3ce9f4ba504edbd3c78931f7ed9827220a8", + "zh:07a72fe8b55afee99529bf4169ab6abfac5eabcd10968c29101925bcd358b09f", + "zh:0d14727d011c2d9df4c3058f527d2409223449ab48b46cbc86922eb553ef77c1", + "zh:155ce1333672d26cd18a5866b0761489d91682beffee58e45c3a1b68e8491d3d", + "zh:35a2a1939a965335b29ebdbfd759d93a97c0f589d9cd218f537dee6f600e3fb9", + "zh:52912fe421e7d911431f77788db2ea13836efd65a2e82385adb52c6a84d4ee90", + "zh:57374318d9194ea1db08884b0541a9055823d5970ad48f9a57547ac231163007", + "zh:5fb942b9e2553c058fe09fe12fb39dd175cd6715bb41c059c1a70df2bfc64dc1", + "zh:63cabd2bda201b09b35a3279d1f813ab71394b9b90fc5cf8962a5eba207803bc", "zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f", - "zh:924cd23abc326c6b3914e2cd9c94c7832c2552e1e9ae258fb9fd9aedaa5f7ce7", - "zh:a4b75e4c239879296259e7d54f1befbc7fdc16da2d62d1294e9f73add4cae61e", - "zh:a6ceb08feb63b00c7141783b31e45a154c76fd8cdebbdf371074805f0053572d", - "zh:afae1843f9ba85f2f6d94108c65cf43a457e83531a632d44d863e935160cb2ba", - "zh:bd6628ce60c778960a5755f7010b7e2cc5c6ff0341a21c175341b28058ec843d", - "zh:cd30866a1ff99d72b5fa1699db582fa4f25562e6ab21dcc6870324f3056108e0", - "zh:df5924cca691a8220aaaebb5cb55c3d6c32ff0a881f198695eff28155eb12b54", - "zh:e78d0696c941aba58df1cb36b8a0d25cd5f3963f01d9338fdbda74db58afdd49", + "zh:978ee67d3d53970a5c474ab40b00adee97f4153b16804a2b6b7ee205ae69d18a", + "zh:bbafdbef631b5c80570087817b42b16b1a76d556d692853a71c47fb48663cf00", + "zh:be91b3f2a697cbbb41f65aad2600972d0ede1e962a7d8a00bb3177cb77d86666", + "zh:efe168ad4aaa6156ce5a31d4e50e9d54d38ee5a5888412f9e690c0de5d619683", ] } diff --git a/deployment/modules/cloudflare/docs-release/config.tf b/deployment/modules/cloudflare/docs-release/config.tf index 18d8ff1eb4665..74ea6d5816f8e 100644 --- a/deployment/modules/cloudflare/docs-release/config.tf +++ b/deployment/modules/cloudflare/docs-release/config.tf @@ -5,7 +5,7 @@ terraform { required_providers { cloudflare = { source = "cloudflare/cloudflare" - version = "4.41.0" + version = "4.43.0" } } } diff --git a/deployment/modules/cloudflare/docs/.terraform.lock.hcl b/deployment/modules/cloudflare/docs/.terraform.lock.hcl index afa00e60677c1..6419c16dad324 100644 --- a/deployment/modules/cloudflare/docs/.terraform.lock.hcl +++ b/deployment/modules/cloudflare/docs/.terraform.lock.hcl @@ -2,37 +2,37 @@ # Manual edits may be lost in future updates. provider "registry.opentofu.org/cloudflare/cloudflare" { - version = "4.41.0" - constraints = "4.41.0" + version = "4.43.0" + constraints = "4.43.0" hashes = [ - "h1:0mc+YrjQrcctGrGYDmzlcqcgSv9MYB74rvMaZylIKC8=", - "h1:0zUx4vk4jOORQqn6xHBF7dO6N6bielFHdJ0mgF4Obn8=", - "h1:AsIZW3uLFNOZO7kL/K7/Y/S0IYxUV9Hz85NNk/3TTsA=", - "h1:FSgYM4+LHMbX/a4Y1kx7FPPWmXqS3/MQYzvjMJHHHWM=", - "h1:Tx6Nh3BWP1x9L3KK/Eyi+ET0T26g3+jf1jyiuqpNIis=", - "h1:VRI9wu8P43xxfpeTndRwsisLnqncfnmEYMOEH5zH4pQ=", - "h1:YxQqmiES/Yanq/VfGqBEqg+VIO7FGhO88aKoWFHyGIg=", - "h1:ZWHiaesjgDLKWlfdNj0oKyj/DWdxcfsO6NINu39zfpY=", - "h1:a2aCgDDBz3ccrr8YstIMl7VFnKo1xZAp+rOv59PPJ7U=", - "h1:aRyv8tB6wBAF9lKsLEdiHyCqnK5LfZq0FqMXCcUB4UU=", - "h1:lXpuO7zv2uD2GzPE1ARxznreRAh+QHTc2lAJ7iOoFgY=", - "h1:sA1xq0QNQ4fH8SHXouYNq50xirVD18SamKQwPsBQrrY=", - "h1:v7sHvKq7oqMYPn47ULHFyIQsKD9o+6Xg/uHbxQUixEw=", - "h1:wo/x4atWyXuWGlfR6h5nH0YwBAmBwTRY27HtWP8ycLo=", - "zh:339d26e06dc6fb299ea8aad9476a60fd65bb1d40631ae8eeb81cddf2dd2bebc8", - "zh:3dec2ad96ac2c283fd34ce65781b55c4edbb4d5c5cb53da8e31537176c0ed562", - "zh:5f63a5f8080319a2fff09d4d49944829fa708723436520787cfb60725ced80cf", - "zh:67162c28ccea71cb8141ed15c0637e35621354ebe14878e0b75a8f160fc5505d", - "zh:6ac1e07f5347b6395aca690ed22101bb25e957d25f986f760ff673a7adfd5ef6", - "zh:70282a723c7b52fcabde2baad41c864ed3a8d69f0c4d27a6b6933cac434cffc6", + "h1:2kDVLD36BOVgBzI9p0WIQ+xjFfMmjaItA0l8SyZWEPo=", + "h1:2sGJDAwFEgO8+3y+2suYO+yrjNOzSsihad0hbM3+jPg=", + "h1:A1WPQFcdD+7FrFBFrKcx4CiSr75xSmsO93C0e5NBAeQ=", + "h1:BuXs/1ohmF4fWyOErY6vNbm7DaEIfbLSepSiZ2ol9I8=", + "h1:QPh+X19oyo808sqdeJaVqahZcQgcG1jCi3DA5zpjz6U=", + "h1:RI7c7dhSJoIkfou5b8ITRpM5MqsQD3FULj1h/rI4rJk=", + "h1:gdI5JTCPjewdGq1bhGAs+V5qCcmJ73N2gtMfuFybJp4=", + "h1:h4lnJpCIYZ7dsN9IO2mmwNdWNiQYEPoAEUjLF2sZ5kc=", + "h1:jTaExrX/eR7vGT5wayGqH8ZtXS2zyk0WmD3zbAKFIQU=", + "h1:l5NKJUOQJ1mHl1eekeXaxUZ+g+8Yv4aGcIN9vuK6GL4=", + "h1:sNbvm66/2vc8B/khyioOO8eNaU8nb89x693AN7fQheU=", + "h1:tXS4g1yE420AU4mvZ7RrYI+yYTutkRID3l+W0gBH4BM=", + "h1:vA+kES7uqmKA9K0U45IXR94jaTQZCHZLCHqMUeGxKMI=", + "h1:zV131k79+ob9p4jrLDgztDNvZvt8fvrrzpn0nPikBw8=", + "zh:006d111d6eafe6eeb5df2f91bd0ca320f979bd71f8cd8c475f10b2bd94acba55", + "zh:031fbb5cac23a841dc18e270cbfcd3ce9f4ba504edbd3c78931f7ed9827220a8", + "zh:07a72fe8b55afee99529bf4169ab6abfac5eabcd10968c29101925bcd358b09f", + "zh:0d14727d011c2d9df4c3058f527d2409223449ab48b46cbc86922eb553ef77c1", + "zh:155ce1333672d26cd18a5866b0761489d91682beffee58e45c3a1b68e8491d3d", + "zh:35a2a1939a965335b29ebdbfd759d93a97c0f589d9cd218f537dee6f600e3fb9", + "zh:52912fe421e7d911431f77788db2ea13836efd65a2e82385adb52c6a84d4ee90", + "zh:57374318d9194ea1db08884b0541a9055823d5970ad48f9a57547ac231163007", + "zh:5fb942b9e2553c058fe09fe12fb39dd175cd6715bb41c059c1a70df2bfc64dc1", + "zh:63cabd2bda201b09b35a3279d1f813ab71394b9b90fc5cf8962a5eba207803bc", "zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f", - "zh:924cd23abc326c6b3914e2cd9c94c7832c2552e1e9ae258fb9fd9aedaa5f7ce7", - "zh:a4b75e4c239879296259e7d54f1befbc7fdc16da2d62d1294e9f73add4cae61e", - "zh:a6ceb08feb63b00c7141783b31e45a154c76fd8cdebbdf371074805f0053572d", - "zh:afae1843f9ba85f2f6d94108c65cf43a457e83531a632d44d863e935160cb2ba", - "zh:bd6628ce60c778960a5755f7010b7e2cc5c6ff0341a21c175341b28058ec843d", - "zh:cd30866a1ff99d72b5fa1699db582fa4f25562e6ab21dcc6870324f3056108e0", - "zh:df5924cca691a8220aaaebb5cb55c3d6c32ff0a881f198695eff28155eb12b54", - "zh:e78d0696c941aba58df1cb36b8a0d25cd5f3963f01d9338fdbda74db58afdd49", + "zh:978ee67d3d53970a5c474ab40b00adee97f4153b16804a2b6b7ee205ae69d18a", + "zh:bbafdbef631b5c80570087817b42b16b1a76d556d692853a71c47fb48663cf00", + "zh:be91b3f2a697cbbb41f65aad2600972d0ede1e962a7d8a00bb3177cb77d86666", + "zh:efe168ad4aaa6156ce5a31d4e50e9d54d38ee5a5888412f9e690c0de5d619683", ] } diff --git a/deployment/modules/cloudflare/docs/config.tf b/deployment/modules/cloudflare/docs/config.tf index 18d8ff1eb4665..74ea6d5816f8e 100644 --- a/deployment/modules/cloudflare/docs/config.tf +++ b/deployment/modules/cloudflare/docs/config.tf @@ -5,7 +5,7 @@ terraform { required_providers { cloudflare = { source = "cloudflare/cloudflare" - version = "4.41.0" + version = "4.43.0" } } }