From 3a68190b99a35656e00584b88ae68ec58e450ccf Mon Sep 17 00:00:00 2001 From: Matthew Momjian <50788000+mmomjian@users.noreply.github.com> Date: Fri, 10 May 2024 11:02:48 -0400 Subject: [PATCH 001/163] docs: warn against use on NTFS / WSL (#9371) * DB filesystem * updates to requirements * wording * OS update * Update environment-variables.md * Update FAQ.mdx * Update requirements.md --- docs/docs/FAQ.mdx | 5 +++++ docs/docs/install/environment-variables.md | 11 +++++++++-- docs/docs/install/requirements.md | 17 ++++++++++------- 3 files changed, 24 insertions(+), 9 deletions(-) diff --git a/docs/docs/FAQ.mdx b/docs/docs/FAQ.mdx index 6739670f46..aaf093d49f 100644 --- a/docs/docs/FAQ.mdx +++ b/docs/docs/FAQ.mdx @@ -399,6 +399,11 @@ If it mentions SIGILL (note the lack of a K) or error code 132, it most likely m If your version of Immich is below 1.92.0 and the crash occurs after logs about tracing or exporting a model, consider either upgrading or disabling the Tag Objects job. +### Why am I getting database errors? + +If you get database errors such as `FATAL: data directory "/var/lib/postgresql/data" has wrong ownership` upon database startup, this is likely due to an issue with your filesystem. +NTFS and ex/FAT/32 filesystems are not supported. See [here](/docs/install/environment-variables#supported-filesystems) for more details. + ### Why does Immich log migration errors on startup? Sometimes Immich logs errors such as "duplicate key value violates unique constraint" or "column (...) of relation (...) already exists". Because of Immich's container structure, this error can be seen when both immich and immich-microservices start at the same time and attempt to migrate or create the database structure. Since the database migration is run sequentially and inside of transactions, this error message does not cause harm to your installation of Immich and can safely be ignored. If needed, you can manually restart Immich by running `docker restart immich immich-microservices`. diff --git a/docs/docs/install/environment-variables.md b/docs/docs/install/environment-variables.md index 6826107893..5b498f1b27 100644 --- a/docs/docs/install/environment-variables.md +++ b/docs/docs/install/environment-variables.md @@ -24,11 +24,18 @@ If this should not work, try running `docker compose up -d --force-recreate`. | `DB_DATA_LOCATION` | Host Path for Postgres database | | database | :::tip - 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 | Services | diff --git a/docs/docs/install/requirements.md b/docs/docs/install/requirements.md index b611d04f2c..8944336ec7 100644 --- a/docs/docs/install/requirements.md +++ b/docs/docs/install/requirements.md @@ -15,12 +15,15 @@ Hardware and software requirements for Immich Immich requires the command `docker compose` - the similarly named `docker-compose` is [deprecated](https://docs.docker.com/compose/migrate/) and is no longer compatible with Immich. ::: -:::info Podman -You can also use Podman to run the application. However, additional configuration might be required. -::: - ## Hardware -- **OS**: Preferred unix-based operating system (Ubuntu, Debian, MacOS, etc). Windows works too, with [Docker Desktop on Windows](https://docs.docker.com/desktop/install/windows-install/) -- **RAM**: At least 4GB, preferred 6GB. -- **CPU**: At least 2 cores, preferred 4 cores. +- **OS**: Recommended Linux operating system (Ubuntu, Debian, etc). + - Windows is supported with [Docker Desktop on Windows](https://docs.docker.com/desktop/install/windows-install/) or [WSL 2](https://docs.docker.com/desktop/wsl/). + - macOS is supported with [Docker Desktop on Mac](https://docs.docker.com/desktop/install/mac-install/). +- **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. + - 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. From fed8d11fb858746a8665fb2aa5111180f419736f Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 10 May 2024 11:40:41 -0500 Subject: [PATCH 002/163] refactor(mobile): remove shared module (#9363) --- mobile/lib/main.dart | 2 +- mobile/lib/{shared => utils}/cache/custom_image_cache.dart | 0 mobile/lib/{shared => utils}/cache/widgets_binding.dart | 0 .../utils => utils/hooks}/app_settings_update_hook.dart | 0 mobile/lib/{shared/ui => utils}/hooks/blurhash_hook.dart | 0 .../asset_viewer => utils}/hooks/chewiew_controller_hook.dart | 0 mobile/lib/{shared/ui => utils}/hooks/timer_hook.dart | 0 .../lib/widgets/asset_viewer/custom_video_player_controls.dart | 2 +- mobile/lib/widgets/asset_viewer/video_player.dart | 2 +- mobile/lib/widgets/common/immich_thumbnail.dart | 2 +- mobile/lib/widgets/memories/memory_card.dart | 2 +- mobile/lib/widgets/settings/advanced_settings.dart | 2 +- .../settings/asset_list_settings/asset_list_group_settings.dart | 2 +- .../asset_list_settings/asset_list_layout_settings.dart | 2 +- .../settings/asset_list_settings/asset_list_settings.dart | 2 +- .../lib/widgets/settings/backup_settings/backup_settings.dart | 2 +- mobile/lib/widgets/settings/image_viewer_quality_setting.dart | 2 +- mobile/lib/widgets/settings/notification_setting.dart | 2 +- .../widgets/settings/preference_settings/haptic_setting.dart | 2 +- .../lib/widgets/settings/preference_settings/theme_setting.dart | 2 +- 20 files changed, 14 insertions(+), 14 deletions(-) rename mobile/lib/{shared => utils}/cache/custom_image_cache.dart (100%) rename mobile/lib/{shared => utils}/cache/widgets_binding.dart (100%) rename mobile/lib/{widgets/settings/utils => utils/hooks}/app_settings_update_hook.dart (100%) rename mobile/lib/{shared/ui => utils}/hooks/blurhash_hook.dart (100%) rename mobile/lib/{widgets/asset_viewer => utils}/hooks/chewiew_controller_hook.dart (100%) rename mobile/lib/{shared/ui => utils}/hooks/timer_hook.dart (100%) diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index b2c10fdace..2a320fbddb 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -16,7 +16,7 @@ import 'package:immich_mobile/entities/backup_album.entity.dart'; import 'package:immich_mobile/entities/duplicated_asset.entity.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/tab_navigation_observer.dart'; -import 'package:immich_mobile/shared/cache/widgets_binding.dart'; +import 'package:immich_mobile/utils/cache/widgets_binding.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'; diff --git a/mobile/lib/shared/cache/custom_image_cache.dart b/mobile/lib/utils/cache/custom_image_cache.dart similarity index 100% rename from mobile/lib/shared/cache/custom_image_cache.dart rename to mobile/lib/utils/cache/custom_image_cache.dart diff --git a/mobile/lib/shared/cache/widgets_binding.dart b/mobile/lib/utils/cache/widgets_binding.dart similarity index 100% rename from mobile/lib/shared/cache/widgets_binding.dart rename to mobile/lib/utils/cache/widgets_binding.dart diff --git a/mobile/lib/widgets/settings/utils/app_settings_update_hook.dart b/mobile/lib/utils/hooks/app_settings_update_hook.dart similarity index 100% rename from mobile/lib/widgets/settings/utils/app_settings_update_hook.dart rename to mobile/lib/utils/hooks/app_settings_update_hook.dart diff --git a/mobile/lib/shared/ui/hooks/blurhash_hook.dart b/mobile/lib/utils/hooks/blurhash_hook.dart similarity index 100% rename from mobile/lib/shared/ui/hooks/blurhash_hook.dart rename to mobile/lib/utils/hooks/blurhash_hook.dart diff --git a/mobile/lib/widgets/asset_viewer/hooks/chewiew_controller_hook.dart b/mobile/lib/utils/hooks/chewiew_controller_hook.dart similarity index 100% rename from mobile/lib/widgets/asset_viewer/hooks/chewiew_controller_hook.dart rename to mobile/lib/utils/hooks/chewiew_controller_hook.dart diff --git a/mobile/lib/shared/ui/hooks/timer_hook.dart b/mobile/lib/utils/hooks/timer_hook.dart similarity index 100% rename from mobile/lib/shared/ui/hooks/timer_hook.dart rename to mobile/lib/utils/hooks/timer_hook.dart diff --git a/mobile/lib/widgets/asset_viewer/custom_video_player_controls.dart b/mobile/lib/widgets/asset_viewer/custom_video_player_controls.dart index 2e1e7f8e64..ebef229dd6 100644 --- a/mobile/lib/widgets/asset_viewer/custom_video_player_controls.dart +++ b/mobile/lib/widgets/asset_viewer/custom_video_player_controls.dart @@ -6,7 +6,7 @@ import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provi import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart'; import 'package:immich_mobile/widgets/asset_viewer/center_play_button.dart'; import 'package:immich_mobile/widgets/common/delayed_loading_indicator.dart'; -import 'package:immich_mobile/shared/ui/hooks/timer_hook.dart'; +import 'package:immich_mobile/utils/hooks/timer_hook.dart'; class CustomVideoPlayerControls extends HookConsumerWidget { final Duration hideTimerDuration; diff --git a/mobile/lib/widgets/asset_viewer/video_player.dart b/mobile/lib/widgets/asset_viewer/video_player.dart index 56cb22ea74..076eafdca3 100644 --- a/mobile/lib/widgets/asset_viewer/video_player.dart +++ b/mobile/lib/widgets/asset_viewer/video_player.dart @@ -1,7 +1,7 @@ import 'package:chewie/chewie.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/widgets/asset_viewer/hooks/chewiew_controller_hook.dart'; +import 'package:immich_mobile/utils/hooks/chewiew_controller_hook.dart'; import 'package:immich_mobile/widgets/asset_viewer/custom_video_player_controls.dart'; import 'package:video_player/video_player.dart'; diff --git a/mobile/lib/widgets/common/immich_thumbnail.dart b/mobile/lib/widgets/common/immich_thumbnail.dart index 0f15c0a11f..2ebead0083 100644 --- a/mobile/lib/widgets/common/immich_thumbnail.dart +++ b/mobile/lib/widgets/common/immich_thumbnail.dart @@ -5,7 +5,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:immich_mobile/providers/image/immich_local_thumbnail_provider.dart'; import 'package:immich_mobile/providers/image/immich_remote_thumbnail_provider.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:immich_mobile/shared/ui/hooks/blurhash_hook.dart'; +import 'package:immich_mobile/utils/hooks/blurhash_hook.dart'; import 'package:immich_mobile/widgets/common/immich_image.dart'; import 'package:immich_mobile/widgets/common/thumbhash_placeholder.dart'; import 'package:octo_image/octo_image.dart'; diff --git a/mobile/lib/widgets/memories/memory_card.dart b/mobile/lib/widgets/memories/memory_card.dart index df64b2fddb..fb7cc882a0 100644 --- a/mobile/lib/widgets/memories/memory_card.dart +++ b/mobile/lib/widgets/memories/memory_card.dart @@ -5,7 +5,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/pages/common/video_viewer.page.dart'; -import 'package:immich_mobile/shared/ui/hooks/blurhash_hook.dart'; +import 'package:immich_mobile/utils/hooks/blurhash_hook.dart'; import 'package:immich_mobile/widgets/common/immich_image.dart'; class MemoryCard extends StatelessWidget { diff --git a/mobile/lib/widgets/settings/advanced_settings.dart b/mobile/lib/widgets/settings/advanced_settings.dart index e79c50bc81..b0727feb0c 100644 --- a/mobile/lib/widgets/settings/advanced_settings.dart +++ b/mobile/lib/widgets/settings/advanced_settings.dart @@ -6,7 +6,7 @@ import 'package:immich_mobile/widgets/settings/local_storage_settings.dart'; import 'package:immich_mobile/widgets/settings/settings_slider_list_tile.dart'; import 'package:immich_mobile/widgets/settings/settings_sub_page_scaffold.dart'; import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart'; -import 'package:immich_mobile/widgets/settings/utils/app_settings_update_hook.dart'; +import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/providers/user.provider.dart'; diff --git a/mobile/lib/widgets/settings/asset_list_settings/asset_list_group_settings.dart b/mobile/lib/widgets/settings/asset_list_settings/asset_list_group_settings.dart index 8e63e61e1c..5b09ae8b72 100644 --- a/mobile/lib/widgets/settings/asset_list_settings/asset_list_group_settings.dart +++ b/mobile/lib/widgets/settings/asset_list_settings/asset_list_group_settings.dart @@ -6,7 +6,7 @@ import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/widgets/settings/settings_radio_list_tile.dart'; import 'package:immich_mobile/widgets/settings/settings_sub_title.dart'; -import 'package:immich_mobile/widgets/settings/utils/app_settings_update_hook.dart'; +import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart'; class GroupSettings extends HookConsumerWidget { const GroupSettings({ diff --git a/mobile/lib/widgets/settings/asset_list_settings/asset_list_layout_settings.dart b/mobile/lib/widgets/settings/asset_list_settings/asset_list_layout_settings.dart index 1224b14d15..4584b7e688 100644 --- a/mobile/lib/widgets/settings/asset_list_settings/asset_list_layout_settings.dart +++ b/mobile/lib/widgets/settings/asset_list_settings/asset_list_layout_settings.dart @@ -6,7 +6,7 @@ import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/widgets/settings/settings_slider_list_tile.dart'; import 'package:immich_mobile/widgets/settings/settings_sub_title.dart'; import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart'; -import 'package:immich_mobile/widgets/settings/utils/app_settings_update_hook.dart'; +import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart'; class LayoutSettings extends HookConsumerWidget { const LayoutSettings({ diff --git a/mobile/lib/widgets/settings/asset_list_settings/asset_list_settings.dart b/mobile/lib/widgets/settings/asset_list_settings/asset_list_settings.dart index 9545533d95..cd12ea3eb2 100644 --- a/mobile/lib/widgets/settings/asset_list_settings/asset_list_settings.dart +++ b/mobile/lib/widgets/settings/asset_list_settings/asset_list_settings.dart @@ -6,7 +6,7 @@ import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/widgets/settings/asset_list_settings/asset_list_group_settings.dart'; import 'package:immich_mobile/widgets/settings/settings_sub_page_scaffold.dart'; import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart'; -import 'package:immich_mobile/widgets/settings/utils/app_settings_update_hook.dart'; +import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart'; import 'asset_list_layout_settings.dart'; class AssetListSettings extends HookConsumerWidget { diff --git a/mobile/lib/widgets/settings/backup_settings/backup_settings.dart b/mobile/lib/widgets/settings/backup_settings/backup_settings.dart index 19e031b082..25bcf2d06e 100644 --- a/mobile/lib/widgets/settings/backup_settings/backup_settings.dart +++ b/mobile/lib/widgets/settings/backup_settings/backup_settings.dart @@ -9,7 +9,7 @@ import 'package:immich_mobile/widgets/settings/backup_settings/foreground_settin import 'package:immich_mobile/widgets/settings/settings_button_list_tile.dart'; import 'package:immich_mobile/widgets/settings/settings_sub_page_scaffold.dart'; import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart'; -import 'package:immich_mobile/widgets/settings/utils/app_settings_update_hook.dart'; +import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart'; import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart'; class BackupSettings extends HookConsumerWidget { diff --git a/mobile/lib/widgets/settings/image_viewer_quality_setting.dart b/mobile/lib/widgets/settings/image_viewer_quality_setting.dart index af39249e4b..8c45db7863 100644 --- a/mobile/lib/widgets/settings/image_viewer_quality_setting.dart +++ b/mobile/lib/widgets/settings/image_viewer_quality_setting.dart @@ -5,7 +5,7 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/widgets/settings/settings_sub_page_scaffold.dart'; import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart'; -import 'package:immich_mobile/widgets/settings/utils/app_settings_update_hook.dart'; +import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart'; class ImageViewerQualitySetting extends HookWidget { const ImageViewerQualitySetting({ diff --git a/mobile/lib/widgets/settings/notification_setting.dart b/mobile/lib/widgets/settings/notification_setting.dart index c07a80c97a..b7276107e0 100644 --- a/mobile/lib/widgets/settings/notification_setting.dart +++ b/mobile/lib/widgets/settings/notification_setting.dart @@ -8,7 +8,7 @@ import 'package:immich_mobile/widgets/settings/settings_button_list_tile.dart'; import 'package:immich_mobile/widgets/settings/settings_slider_list_tile.dart'; import 'package:immich_mobile/widgets/settings/settings_sub_page_scaffold.dart'; import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart'; -import 'package:immich_mobile/widgets/settings/utils/app_settings_update_hook.dart'; +import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart'; import 'package:permission_handler/permission_handler.dart'; class NotificationSetting extends HookConsumerWidget { diff --git a/mobile/lib/widgets/settings/preference_settings/haptic_setting.dart b/mobile/lib/widgets/settings/preference_settings/haptic_setting.dart index 7935002c05..90a123bfbd 100644 --- a/mobile/lib/widgets/settings/preference_settings/haptic_setting.dart +++ b/mobile/lib/widgets/settings/preference_settings/haptic_setting.dart @@ -5,7 +5,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/widgets/settings/settings_sub_title.dart'; import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart'; -import 'package:immich_mobile/widgets/settings/utils/app_settings_update_hook.dart'; +import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart'; class HapticSetting extends HookConsumerWidget { const HapticSetting({ diff --git a/mobile/lib/widgets/settings/preference_settings/theme_setting.dart b/mobile/lib/widgets/settings/preference_settings/theme_setting.dart index 7bec8fdd45..5780054428 100644 --- a/mobile/lib/widgets/settings/preference_settings/theme_setting.dart +++ b/mobile/lib/widgets/settings/preference_settings/theme_setting.dart @@ -5,7 +5,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/widgets/settings/settings_sub_title.dart'; import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart'; -import 'package:immich_mobile/widgets/settings/utils/app_settings_update_hook.dart'; +import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart'; import 'package:immich_mobile/utils/immich_app_theme.dart'; class ThemeSetting extends HookConsumerWidget { From fa4cd74dfd051b7060ae66869eeb9b04df38109f Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 10 May 2024 12:18:10 -0500 Subject: [PATCH 003/163] fix(web): autofocus change name field (#9376) --- web/src/routes/(user)/people/+page.svelte | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/web/src/routes/(user)/people/+page.svelte b/web/src/routes/(user)/people/+page.svelte index 7976bb4f3a..54f703c8f3 100644 --- a/web/src/routes/(user)/people/+page.svelte +++ b/web/src/routes/(user)/people/+page.svelte @@ -62,7 +62,7 @@ let handleSearchPeople: (force?: boolean, name?: string) => Promise; let showPeople: PersonResponseDto[] = []; let countVisiblePeople: number; - + let changeNameInputEl: HTMLInputElement | null; let innerHeight: number; for (const person of people) { @@ -237,6 +237,8 @@ personName = detail.name; personMerge1 = detail; edittingPerson = detail; + + setTimeout(() => changeNameInputEl?.focus(), 100); }; const handleSetBirthDate = (detail: PersonResponseDto) => { @@ -448,7 +450,14 @@
- +
From 35ef82ab6b3d2420ec0d36ed0245d5e0de532308 Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Fri, 10 May 2024 19:38:35 +0200 Subject: [PATCH 004/163] docs: add discord link (#9379) add discord link to docs --- docs/docusaurus.config.js | 5 +++++ docs/src/pages/index.tsx | 7 +++++++ 2 files changed, 12 insertions(+) diff --git a/docs/docusaurus.config.js b/docs/docusaurus.config.js index 75513e3d3b..a9680fb9a1 100644 --- a/docs/docusaurus.config.js +++ b/docs/docusaurus.config.js @@ -119,6 +119,11 @@ const config = { label: 'GitHub', position: 'right', }, + { + href: 'https://discord.gg/D8JsnBEuKb', + label: 'Discord', + position: 'right', + }, ], }, footer: { diff --git a/docs/src/pages/index.tsx b/docs/src/pages/index.tsx index f864c07633..de634da70a 100644 --- a/docs/src/pages/index.tsx +++ b/docs/src/pages/index.tsx @@ -33,6 +33,13 @@ function HomepageHeader() { > Demo portal + + + Discord + screenshots
From f3fbb9b588443f7cecabdd6d5dc693d5130f0b96 Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Fri, 10 May 2024 14:15:25 -0400 Subject: [PATCH 005/163] perf: cache `getConfig` (#9377) * cache `getConfig` * critical fix Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> --------- Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> --- server/src/cores/system-config.core.ts | 110 +++++++++++-------- server/src/interfaces/database.interface.ts | 1 + server/src/services/metadata.service.spec.ts | 15 ++- 3 files changed, 73 insertions(+), 53 deletions(-) diff --git a/server/src/cores/system-config.core.ts b/server/src/cores/system-config.core.ts index 17d9ed2270..c94da0977b 100644 --- a/server/src/cores/system-config.core.ts +++ b/server/src/cores/system-config.core.ts @@ -1,5 +1,6 @@ import { BadRequestException, ForbiddenException, Injectable } from '@nestjs/common'; import { CronExpression } from '@nestjs/schedule'; +import AsyncLock from 'async-lock'; import { plainToInstance } from 'class-transformer'; import { validate } from 'class-validator'; import { load as loadYaml } from 'js-yaml'; @@ -21,6 +22,7 @@ import { TranscodePolicy, VideoCodec, } from 'src/entities/system-config.entity'; +import { DatabaseLock } from 'src/interfaces/database.interface'; import { QueueName } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; @@ -186,7 +188,9 @@ let instance: SystemConfigCore | null; @Injectable() export class SystemConfigCore { - private configCache: SystemConfigEntity[] | null = null; + private readonly asyncLock = new AsyncLock(); + private config: SystemConfig | null = null; + private lastUpdated: number | null = null; public config$ = new Subject(); @@ -268,32 +272,17 @@ export class SystemConfigCore { } public async getConfig(force = false): Promise { - const configFilePath = process.env.IMMICH_CONFIG_FILE; - const config = _.cloneDeep(defaults); - const overrides = configFilePath ? await this.loadFromFile(configFilePath, force) : await this.repository.load(); - - for (const { key, value } of overrides) { - // set via dot notation - _.set(config, key, value); + if (force || !this.config) { + const lastUpdated = this.lastUpdated; + await this.asyncLock.acquire(DatabaseLock[DatabaseLock.GetSystemConfig], async () => { + if (lastUpdated === this.lastUpdated) { + this.config = await this.buildConfig(); + this.lastUpdated = Date.now(); + } + }); } - const errors = await validate(plainToInstance(SystemConfigDto, config)); - if (errors.length > 0) { - this.logger.error('Validation error', errors); - if (configFilePath) { - throw new Error(`Invalid value(s) in file: ${errors}`); - } - } - - if (!config.ffmpeg.acceptedVideoCodecs.includes(config.ffmpeg.targetVideoCodec)) { - config.ffmpeg.acceptedVideoCodecs.unshift(config.ffmpeg.targetVideoCodec); - } - - if (!config.ffmpeg.acceptedAudioCodecs.includes(config.ffmpeg.targetAudioCodec)) { - config.ffmpeg.acceptedAudioCodecs.unshift(config.ffmpeg.targetAudioCodec); - } - - return config; + return this.config!; } public async updateConfig(newConfig: SystemConfig): Promise { @@ -345,33 +334,60 @@ export class SystemConfigCore { this.config$.next(newConfig); } - private async loadFromFile(filepath: string, force = false) { - if (force || !this.configCache) { - try { - const file = await this.repository.readFile(filepath); - const config = loadYaml(file.toString()) as any; - const overrides: SystemConfigEntity[] = []; + private async buildConfig() { + const config = _.cloneDeep(defaults); + const overrides = process.env.IMMICH_CONFIG_FILE + ? await this.loadFromFile(process.env.IMMICH_CONFIG_FILE) + : await this.repository.load(); - for (const key of Object.values(SystemConfigKey)) { - const value = _.get(config, key); - this.unsetDeep(config, key); - if (value !== undefined) { - overrides.push({ key, value }); - } - } + for (const { key, value } of overrides) { + // set via dot notation + _.set(config, key, value); + } - if (!_.isEmpty(config)) { - this.logger.warn(`Unknown keys found: ${JSON.stringify(config, null, 2)}`); - } - - this.configCache = overrides; - } catch (error: Error | any) { - this.logger.error(`Unable to load configuration file: ${filepath}`); - throw error; + const errors = await validate(plainToInstance(SystemConfigDto, config)); + if (errors.length > 0) { + if (process.env.IMMICH_CONFIG_FILE) { + throw new Error(`Invalid value(s) in file: ${errors}`); + } else { + this.logger.error('Validation error', errors); } } - return this.configCache; + if (!config.ffmpeg.acceptedVideoCodecs.includes(config.ffmpeg.targetVideoCodec)) { + config.ffmpeg.acceptedVideoCodecs.push(config.ffmpeg.targetVideoCodec); + } + + if (!config.ffmpeg.acceptedAudioCodecs.includes(config.ffmpeg.targetAudioCodec)) { + config.ffmpeg.acceptedAudioCodecs.push(config.ffmpeg.targetAudioCodec); + } + + return config; + } + + private async loadFromFile(filepath: string) { + try { + const file = await this.repository.readFile(filepath); + const config = loadYaml(file.toString()) as any; + const overrides: SystemConfigEntity[] = []; + + for (const key of Object.values(SystemConfigKey)) { + const value = _.get(config, key); + this.unsetDeep(config, key); + if (value !== undefined) { + overrides.push({ key, value }); + } + } + + if (!_.isEmpty(config)) { + this.logger.warn(`Unknown keys found: ${JSON.stringify(config, null, 2)}`); + } + + return overrides; + } catch (error: Error | any) { + this.logger.error(`Unable to load configuration file: ${filepath}`); + throw error; + } } private unsetDeep(object: object, key: string) { diff --git a/server/src/interfaces/database.interface.ts b/server/src/interfaces/database.interface.ts index 42342eccc3..e57c91e917 100644 --- a/server/src/interfaces/database.interface.ts +++ b/server/src/interfaces/database.interface.ts @@ -20,6 +20,7 @@ export enum DatabaseLock { StorageTemplateMigration = 420, CLIPDimSize = 512, LibraryWatch = 1337, + GetSystemConfig = 69, } export const extName: Record = { diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index 5adeb1c774..c0758eeeaf 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -99,19 +99,22 @@ describe(MetadataService.name, () => { }); describe('init', () => { - beforeEach(async () => { - configMock.load.mockResolvedValue([{ key: SystemConfigKey.REVERSE_GEOCODING_ENABLED, value: true }]); - + it('should pause and resume queue during init', async () => { await sut.init(); + + expect(jobMock.pause).toHaveBeenCalledTimes(1); + expect(metadataMock.init).toHaveBeenCalledTimes(1); + expect(jobMock.resume).toHaveBeenCalledTimes(1); }); it('should return if reverse geocoding is disabled', async () => { configMock.load.mockResolvedValue([{ key: SystemConfigKey.REVERSE_GEOCODING_ENABLED, value: false }]); await sut.init(); - expect(jobMock.pause).toHaveBeenCalledTimes(1); - expect(metadataMock.init).toHaveBeenCalledTimes(1); - expect(jobMock.resume).toHaveBeenCalledTimes(1); + + expect(jobMock.pause).not.toHaveBeenCalled(); + expect(metadataMock.init).not.toHaveBeenCalled(); + expect(jobMock.resume).not.toHaveBeenCalled(); }); }); From bb4843747bfcba22e39ecec69219c254eabb5843 Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Fri, 10 May 2024 15:03:47 -0400 Subject: [PATCH 006/163] perf: cache transcoding devices (#9381) cache transcoding devices --- server/src/services/media.service.spec.ts | 9 ++++- server/src/services/media.service.ts | 47 ++++++++++++++--------- 2 files changed, 35 insertions(+), 21 deletions(-) diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts index 637e5fa248..6c568f3625 100644 --- a/server/src/services/media.service.spec.ts +++ b/server/src/services/media.service.spec.ts @@ -2053,14 +2053,19 @@ describe(MediaService.name, () => { twoPass: false, }, ); + }); - storageMock.readdir.mockResolvedValue(['renderD129', 'renderD128']); + it('should prefer higher index gpu node', async () => { + storageMock.readdir.mockResolvedValue(['renderD129', 'renderD130', 'renderD128']); + mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); + configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.VAAPI }]); + 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: ['-init_hw_device vaapi=accel:/dev/dri/renderD129', '-filter_hw_device accel'], + inputOptions: ['-init_hw_device vaapi=accel:/dev/dri/renderD130', '-filter_hw_device accel'], outputOptions: [ `-c:v h264_vaapi`, '-c:a copy', diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index 00623cecbe..8a4ca4ce3e 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -50,7 +50,8 @@ import { usePagination } from 'src/utils/pagination'; export class MediaService { private configCore: SystemConfigCore; private storageCore: StorageCore; - private hasOpenCL?: boolean = undefined; + private openCL: boolean | null = null; + private devices: string[] | null = null; constructor( @Inject(IAssetRepository) private assetRepository: IAssetRepository, @@ -492,36 +493,21 @@ export class MediaService { private async getHWCodecConfig(config: SystemConfigFFmpegDto) { let handler: VideoCodecHWConfig; - let devices: string[]; switch (config.accel) { case TranscodeHWAccel.NVENC: { handler = new NVENCConfig(config); break; } case TranscodeHWAccel.QSV: { - devices = await this.storageRepository.readdir('/dev/dri'); - handler = new QSVConfig(config, devices); + handler = new QSVConfig(config, await this.getDevices()); break; } case TranscodeHWAccel.VAAPI: { - devices = await this.storageRepository.readdir('/dev/dri'); - handler = new VAAPIConfig(config, devices); + handler = new VAAPIConfig(config, await this.getDevices()); break; } case TranscodeHWAccel.RKMPP: { - if (this.hasOpenCL === undefined) { - try { - const maliIcdStat = await this.storageRepository.stat('/etc/OpenCL/vendors/mali.icd'); - const maliDeviceStat = await this.storageRepository.stat('/dev/mali0'); - this.hasOpenCL = maliIcdStat.isFile() && maliDeviceStat.isCharacterDevice(); - } catch { - this.logger.warn('OpenCL not available for transcoding, using CPU instead.'); - this.hasOpenCL = false; - } - } - - devices = await this.storageRepository.readdir('/dev/dri'); - handler = new RKMPPConfig(config, devices, this.hasOpenCL); + handler = new RKMPPConfig(config, await this.getDevices(), await this.hasOpenCL()); break; } default: { @@ -572,4 +558,27 @@ export class MediaService { return extractedSize >= targetSize; } + + private async getDevices() { + if (!this.devices) { + this.devices = await this.storageRepository.readdir('/dev/dri'); + } + + return this.devices; + } + + private async hasOpenCL() { + if (this.openCL === null) { + try { + const maliIcdStat = await this.storageRepository.stat('/etc/OpenCL/vendors/mali.icd'); + const maliDeviceStat = await this.storageRepository.stat('/dev/mali0'); + this.openCL = maliIcdStat.isFile() && maliDeviceStat.isCharacterDevice(); + } catch { + this.logger.warn('OpenCL not available for transcoding, using CPU instead.'); + this.openCL = false; + } + } + + return this.openCL; + } } From dd8d7732deb48a0c4c8bdf73c512622e078077af Mon Sep 17 00:00:00 2001 From: Tushar Harsora Date: Sat, 11 May 2024 01:58:21 +0530 Subject: [PATCH 007/163] fix(web): Fixed video unmutes when scrubbing (#9382) Fixed video unmutes when scrubbing Co-authored-by: Tushar Harsora --- .../lib/components/asset-viewer/video-native-viewer.svelte | 5 ++--- web/src/lib/stores/preferences.store.ts | 1 + 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/web/src/lib/components/asset-viewer/video-native-viewer.svelte b/web/src/lib/components/asset-viewer/video-native-viewer.svelte index 21d3ccec2f..a0c3aa19af 100644 --- a/web/src/lib/components/asset-viewer/video-native-viewer.svelte +++ b/web/src/lib/components/asset-viewer/video-native-viewer.svelte @@ -1,5 +1,5 @@ @@ -60,11 +68,21 @@ {/if} {#if $featureFlags.map} - + {/if} {#if $sidebarSettings.people} - + {/if} {#if $sidebarSettings.sharing} @@ -118,7 +136,12 @@ - + {#await getStats({ isArchived: true })} @@ -132,7 +155,12 @@ {#if $featureFlags.trash} - + {#await getStats({ isTrashed: true })} From 2ae44022c2ccf756a5f057f7be051c3da464335d Mon Sep 17 00:00:00 2001 From: Deedikjepijn Date: Mon, 13 May 2024 12:17:13 +0200 Subject: [PATCH 013/163] docs: update README_nl_NL.md with missing sections from english readme (#9399) * Update README_nl_NL.md Added missing Features for the dutch translation of the readme file, Added "Activities" to the dutch version, Added "star history", Added "Contributors". * Update README_nl_NL.md Translated one additional word on the dutch activities panel * Update readme_i18n/README_nl_NL.md Co-authored-by: bo0tzz * Update readme_i18n/README_nl_NL.md Co-authored-by: bo0tzz --------- Co-authored-by: Zack Pollard Co-authored-by: bo0tzz --- readme_i18n/README_nl_NL.md | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/readme_i18n/README_nl_NL.md b/readme_i18n/README_nl_NL.md index 1785660294..c900b7eb2e 100644 --- a/readme_i18n/README_nl_NL.md +++ b/readme_i18n/README_nl_NL.md @@ -42,7 +42,7 @@ ## Inhoud s - [Officiële documentatie](https://immich.app/docs) -- [Roadmap](https://github.com/orgs/immich-app/projects/1) +- [Toekomstplannen](https://github.com/orgs/immich-app/projects/1) - [Demo](#demo) - [Functies](#functies) - [Introductie](https://immich.app/docs/overview/introduction) @@ -59,7 +59,7 @@ De demo is te bekijken op https://demo.immich.app. Voor de mobiele app kunt u gebruik maken van `https://demo.immich.app/api` voor de `Server Endpoint URL` -```bash title="Demo Credential" +```bash title="Demo inloggegevens" De inloggegevens email: demo@immich.app wachtwoord: demo @@ -69,12 +69,18 @@ wachtwoord: demo Spec: Free-tier Oracle VM - Amsterdam - 2.4Ghz quad-core ARM64 CPU, 24GB RAM ``` +## Activiteit + +![Activiteit](https://repobeats.axiom.co/api/embed/9e86d9dc3ddd137161f2f6d2e758d7863b1789cb.svg "Repobeats analytics image" + + # Functies | Functies | Mobiel | Web | |-----------------------------------------------------|--------|-----| | Upload en bekijk video's en foto's | Ja | Ja | | Automatische back-up wanneer de app wordt geopend | Ja | NVT | +| Duplicatie van bestanden voorkomen | Ja | Ja | | Selectieve album(s) voor back-up | Ja | NVT | | Download foto's en video's naar een lokaal apparaat | Ja | Ja | | Ondersteuning voor meerdere gebruikers | Ja | Ja | @@ -89,6 +95,7 @@ Spec: Free-tier Oracle VM - Amsterdam - 2.4Ghz quad-core ARM64 CPU, 24GB RAM | OAuth-ondersteuning | Ja | Ja | | API-sleutels | NVT | Ja | | LivePhoto-back-up en weergave | iOS | Ja | +| Ondersteuning 360 Graden foto weergave | Nee | Ja | | Door de gebruiker gedefinieerde opslagstructuur | Ja | Ja | | Openbaar delen | Nee | Ja | | Archief en Favorieten | Ja | Ja | @@ -98,3 +105,19 @@ Spec: Free-tier Oracle VM - Amsterdam - 2.4Ghz quad-core ARM64 CPU, 24GB RAM | Herinneringen (x jaar geleden) | Ja | Ja | | Offline-ondersteuning | Ja | Nee | | Alleen-lezen galerij | Ja | Ja | + +## Contributie-leden + + + + + +## Ster geschiedenis + + + + + + Star History Chart + + From aa1dc68867126aaa56f59f880fc022bfdf4f0791 Mon Sep 17 00:00:00 2001 From: martin <74269598+martabal@users.noreply.github.com> Date: Mon, 13 May 2024 12:17:52 +0200 Subject: [PATCH 014/163] feat(web): allow hiding all unnamed faces and reset hidden faces (#9378) * feat: hide all unnamed * feat: remove dispatch event * pr feedback --- .../components/faces-page/show-hide.svelte | 52 +++++++++++++------ web/src/routes/(user)/people/+page.svelte | 29 +++++++---- 2 files changed, 55 insertions(+), 26 deletions(-) diff --git a/web/src/lib/components/faces-page/show-hide.svelte b/web/src/lib/components/faces-page/show-hide.svelte index aee90bdbe7..82159c8ee7 100644 --- a/web/src/lib/components/faces-page/show-hide.svelte +++ b/web/src/lib/components/faces-page/show-hide.svelte @@ -1,24 +1,46 @@ + +
- dispatch('close')} /> +

Show & hide people

({countTotalPeople.toLocaleString($locale)})

@@ -37,15 +59,15 @@
- dispatch('reset')} /> + dispatch('change')} + icon={toggleIcon} + on:click={() => onChange(getNextVisibility(toggleVisibility))} />
{#if !showLoadingSpinner} - + {:else} {/if} diff --git a/web/src/routes/(user)/people/+page.svelte b/web/src/routes/(user)/people/+page.svelte index 54f703c8f3..f4d318ac53 100644 --- a/web/src/routes/(user)/people/+page.svelte +++ b/web/src/routes/(user)/people/+page.svelte @@ -7,7 +7,7 @@ import MergeSuggestionModal from '$lib/components/faces-page/merge-suggestion-modal.svelte'; import PeopleCard from '$lib/components/faces-page/people-card.svelte'; import SetBirthDateModal from '$lib/components/faces-page/set-birth-date-modal.svelte'; - import ShowHide from '$lib/components/faces-page/show-hide.svelte'; + import ShowHide, { ToggleVisibilty } from '$lib/components/faces-page/show-hide.svelte'; import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte'; import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte'; import { @@ -48,7 +48,7 @@ let searchName = ''; let showLoadingSpinner = false; - let toggleVisibility = false; + let toggleVisibility: ToggleVisibilty = ToggleVisibilty.VIEW_ALL; let showChangeNameModal = false; let showSetBirthDateModal = false; @@ -104,7 +104,7 @@ // Reset variables used on the "Show & hide people" modal showLoadingSpinner = false; selectHidden = false; - toggleVisibility = false; + toggleVisibility = ToggleVisibilty.VIEW_ALL; }; const handleResetVisibility = () => { @@ -116,10 +116,17 @@ people = people; }; - const handleToggleVisibility = () => { - toggleVisibility = !toggleVisibility; + const handleToggleVisibility = (toggleVisibility: ToggleVisibilty) => { for (const person of people) { - person.isHidden = toggleVisibility; + if (toggleVisibility == ToggleVisibilty.HIDE_ALL) { + person.isHidden = true; + } + if (toggleVisibility == ToggleVisibilty.VIEW_ALL) { + person.isHidden = false; + } + if (toggleVisibility == ToggleVisibilty.HIDE_UNNANEMD && !person.name) { + person.isHidden = true; + } } // trigger reactivity @@ -172,7 +179,7 @@ // Reset variables used on the "Show & hide people" modal showLoadingSpinner = false; selectHidden = false; - toggleVisibility = false; + toggleVisibility = ToggleVisibilty.VIEW_ALL; }; const handleMergeSamePerson = async (response: [PersonResponseDto, PersonResponseDto]) => { @@ -483,10 +490,10 @@ {#if selectHidden} Date: Mon, 13 May 2024 06:23:20 -0400 Subject: [PATCH 015/163] docs: add info about postgres database checksums (#9391) * docs staging for next release * linting * newline * remove old info --- docs/docs/FAQ.mdx | 40 +++++++++++++++++-- .../administration/postgres-standalone.md | 2 +- 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/docs/docs/FAQ.mdx b/docs/docs/FAQ.mdx index aaf093d49f..5458a22620 100644 --- a/docs/docs/FAQ.mdx +++ b/docs/docs/FAQ.mdx @@ -399,13 +399,47 @@ If it mentions SIGILL (note the lack of a K) or error code 132, it most likely m If your version of Immich is below 1.92.0 and the crash occurs after logs about tracing or exporting a model, consider either upgrading or disabling the Tag Objects job. -### Why am I getting database errors? +## Database + +### Why am I getting database ownership errors? If you get database errors such as `FATAL: data directory "/var/lib/postgresql/data" has wrong ownership` upon database startup, this is likely due to an issue with your filesystem. NTFS and ex/FAT/32 filesystems are not supported. See [here](/docs/install/environment-variables#supported-filesystems) for more details. -### Why does Immich log migration errors on startup? +### How can I verify the integrity of my database? -Sometimes Immich logs errors such as "duplicate key value violates unique constraint" or "column (...) of relation (...) already exists". Because of Immich's container structure, this error can be seen when both immich and immich-microservices start at the same time and attempt to migrate or create the database structure. Since the database migration is run sequentially and inside of transactions, this error message does not cause harm to your installation of Immich and can safely be ignored. If needed, you can manually restart Immich by running `docker restart immich immich-microservices`. +If you installed Immich using v1.104.0 or later, you likely have database checksums enabled by default. You can check this by running the following command. +A result of `on` means that checksums are enabled. + +
+Check if checksums are enabled + +```bash +docker exec -it immich_postgres psql --dbname=immich --username= --command="show data_checksums" + data_checksums +---------------- + on +(1 row) +``` + +
+ +If checksums are enabled, you can check the status of the database with the following command. A normal result is all zeroes. + +
+Check for database corruption + +```bash +docker exec -it immich_postgres psql --dbname=immich --username= --command="SELECT datname, checksum_failures, checksum_last_failure FROM pg_stat_database WHERE datname IS NOT NULL" + datname | checksum_failures | checksum_last_failure +-----------+-------------------+----------------------- + postgres | 0 | + immich | 0 | + template1 | 0 | + template0 | 0 | +(4 rows) +``` + +
[huggingface]: https://huggingface.co/immich-app diff --git a/docs/docs/administration/postgres-standalone.md b/docs/docs/administration/postgres-standalone.md index e0a0cb875e..b5028c788e 100644 --- a/docs/docs/administration/postgres-standalone.md +++ b/docs/docs/administration/postgres-standalone.md @@ -5,7 +5,7 @@ While not officially recommended, it is possible to run Immich using a pre-exist By default, Immich expects superuser permission on the Postgres database and requires certain extensions to be installed. This guide outlines the steps required to prepare a pre-existing Postgres server to be used by Immich. :::tip -Running with a pre-existing Postgres server can unlock powerful administrative features, including logical replication, data page checksums, and streaming write-ahead log backups using programs like pgBackRest or Barman. +Running with a pre-existing Postgres server can unlock powerful administrative features, including logical replication and streaming write-ahead log backups using programs like pgBackRest or Barman. ::: ## Prerequisites From d121903b38d666da64bd1126c5564ed332c31811 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 13 May 2024 08:23:12 -0400 Subject: [PATCH 016/163] fix(deps): update dependency nestjs-otel to v6 (#9415) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- server/package-lock.json | 50 ++++++++++++++++++++-------------------- server/package.json | 2 +- 2 files changed, 26 insertions(+), 26 deletions(-) diff --git a/server/package-lock.json b/server/package-lock.json index 3beec64001..2807f331f0 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -48,7 +48,7 @@ "mnemonist": "^0.39.8", "nest-commander": "^3.11.1", "nestjs-cls": "^4.3.0", - "nestjs-otel": "^5.1.5", + "nestjs-otel": "^6.0.0", "nodemailer": "^6.9.13", "openid-client": "^5.4.3", "pg": "^8.11.3", @@ -2518,9 +2518,9 @@ "integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==" }, "node_modules/@opentelemetry/api": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.7.0.tgz", - "integrity": "sha512-AdY5wvN0P2vXBi3b29hxZgSFvdhdxPB9+f0B6s//P9Q8nibRWeA3cHm8UmLpio9ABigkVHJ5NMPk+Mz8VCCyrw==", + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.8.0.tgz", + "integrity": "sha512-I/s6F7yKUDdtMsoBWXJe8Qz40Tui5vsuKCWJEWVL+5q9sSWRzzx6v2KeNsOBEwd94j0eWkpWCH4yB6rZg9Mf0w==", "engines": { "node": ">=8.0.0" } @@ -2716,12 +2716,12 @@ } }, "node_modules/@opentelemetry/host-metrics": { - "version": "0.32.2", - "resolved": "https://registry.npmjs.org/@opentelemetry/host-metrics/-/host-metrics-0.32.2.tgz", - "integrity": "sha512-zrnls0CWMAYEUHQbdplY0M6v3L/cgEoiwpjAnHAaG7M3ICs7K4/Hms1UlVMDydEvNDkQilx63scpDcE1/M5V4A==", + "version": "0.35.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/host-metrics/-/host-metrics-0.35.1.tgz", + "integrity": "sha512-d49/Un/pzqUSsGLeO8PvrX2bLxVAORcaoL3nxjJCzGikXA6gjWXxGOfT8D4qePlgnocozppWszefMHoRFS2MsA==", "dependencies": { "@opentelemetry/sdk-metrics": "^1.8.0", - "systeminformation": "^5.0.0" + "systeminformation": "^5.21.20" }, "engines": { "node": ">=14" @@ -11448,12 +11448,12 @@ } }, "node_modules/nestjs-otel": { - "version": "5.1.5", - "resolved": "https://registry.npmjs.org/nestjs-otel/-/nestjs-otel-5.1.5.tgz", - "integrity": "sha512-4u87aSy/GpbwuYvb5OAm0Zk3CP/q9QATI2bb9DQNpLYC5uYsqNkLHL3AdLBMVueNIMqp8rZui67XW0WTM8rr6g==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/nestjs-otel/-/nestjs-otel-6.1.0.tgz", + "integrity": "sha512-X04WirM8OgaO7R1ThDaciKXD8YD/CdRV3MMqnf6+63nDo/qqQ14JU3lGqbL+sfdCZ2Rg7Cd0SiCOApPUbvlV+g==", "dependencies": { - "@opentelemetry/api": "^1.4.1", - "@opentelemetry/host-metrics": "^0.32.2", + "@opentelemetry/api": "^1.8.0", + "@opentelemetry/host-metrics": "^0.35.1", "response-time": "^2.3.2" }, "peerDependencies": { @@ -18007,9 +18007,9 @@ "integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==" }, "@opentelemetry/api": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.7.0.tgz", - "integrity": "sha512-AdY5wvN0P2vXBi3b29hxZgSFvdhdxPB9+f0B6s//P9Q8nibRWeA3cHm8UmLpio9ABigkVHJ5NMPk+Mz8VCCyrw==" + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.8.0.tgz", + "integrity": "sha512-I/s6F7yKUDdtMsoBWXJe8Qz40Tui5vsuKCWJEWVL+5q9sSWRzzx6v2KeNsOBEwd94j0eWkpWCH4yB6rZg9Mf0w==" }, "@opentelemetry/auto-instrumentations-node": { "version": "0.46.0", @@ -18147,12 +18147,12 @@ } }, "@opentelemetry/host-metrics": { - "version": "0.32.2", - "resolved": "https://registry.npmjs.org/@opentelemetry/host-metrics/-/host-metrics-0.32.2.tgz", - "integrity": "sha512-zrnls0CWMAYEUHQbdplY0M6v3L/cgEoiwpjAnHAaG7M3ICs7K4/Hms1UlVMDydEvNDkQilx63scpDcE1/M5V4A==", + "version": "0.35.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/host-metrics/-/host-metrics-0.35.1.tgz", + "integrity": "sha512-d49/Un/pzqUSsGLeO8PvrX2bLxVAORcaoL3nxjJCzGikXA6gjWXxGOfT8D4qePlgnocozppWszefMHoRFS2MsA==", "requires": { "@opentelemetry/sdk-metrics": "^1.8.0", - "systeminformation": "^5.0.0" + "systeminformation": "^5.21.20" } }, "@opentelemetry/instrumentation": { @@ -24316,12 +24316,12 @@ "requires": {} }, "nestjs-otel": { - "version": "5.1.5", - "resolved": "https://registry.npmjs.org/nestjs-otel/-/nestjs-otel-5.1.5.tgz", - "integrity": "sha512-4u87aSy/GpbwuYvb5OAm0Zk3CP/q9QATI2bb9DQNpLYC5uYsqNkLHL3AdLBMVueNIMqp8rZui67XW0WTM8rr6g==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/nestjs-otel/-/nestjs-otel-6.1.0.tgz", + "integrity": "sha512-X04WirM8OgaO7R1ThDaciKXD8YD/CdRV3MMqnf6+63nDo/qqQ14JU3lGqbL+sfdCZ2Rg7Cd0SiCOApPUbvlV+g==", "requires": { - "@opentelemetry/api": "^1.4.1", - "@opentelemetry/host-metrics": "^0.32.2", + "@opentelemetry/api": "^1.8.0", + "@opentelemetry/host-metrics": "^0.35.1", "response-time": "^2.3.2" } }, diff --git a/server/package.json b/server/package.json index 328b58fdd7..02f0f39e61 100644 --- a/server/package.json +++ b/server/package.json @@ -72,7 +72,7 @@ "mnemonist": "^0.39.8", "nest-commander": "^3.11.1", "nestjs-cls": "^4.3.0", - "nestjs-otel": "^5.1.5", + "nestjs-otel": "^6.0.0", "nodemailer": "^6.9.13", "openid-client": "^5.4.3", "pg": "^8.11.3", From 48927f5fb90d06dccb066f103c67853f84b3606e Mon Sep 17 00:00:00 2001 From: Andreas Gerstmayr Date: Mon, 13 May 2024 15:28:57 +0200 Subject: [PATCH 017/163] feat(server, web): include pictures of shared albums on map (#7439) * feat(server, web): include pictures of shared albums on map * run prettier * re-create api clients * implement suggestions from code review * shared from partner -> shared from partners * rename to 'include shared partner assets' * chore: fix tsc error in server and prettier in web * fix: include assets shared via owner albums --------- Co-authored-by: Zack Pollard Co-authored-by: Jason Rasmussen --- mobile/openapi/doc/AssetApi.md | 6 ++-- mobile/openapi/lib/api/asset_api.dart | 13 ++++++-- mobile/openapi/test/asset_api_test.dart | 2 +- open-api/immich-openapi-specs.json | 8 +++++ open-api/typescript-sdk/src/fetch-client.ts | 6 ++-- server/src/dtos/search.dto.ts | 3 ++ server/src/interfaces/asset.interface.ts | 2 +- server/src/repositories/asset.repository.ts | 32 ++++++++++++------- server/src/services/asset.service.spec.ts | 5 +++ server/src/services/asset.service.ts | 16 +++++++++- .../map-page/map-settings-modal.svelte | 7 +++- web/src/lib/stores/preferences.store.ts | 2 ++ .../[[assetId=id]]/+page.svelte | 3 +- 13 files changed, 81 insertions(+), 24 deletions(-) diff --git a/mobile/openapi/doc/AssetApi.md b/mobile/openapi/doc/AssetApi.md index d710ef926a..a1491c79a2 100644 --- a/mobile/openapi/doc/AssetApi.md +++ b/mobile/openapi/doc/AssetApi.md @@ -500,7 +500,7 @@ Name | Type | Description | Notes [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) # **getMapMarkers** -> List getMapMarkers(fileCreatedAfter, fileCreatedBefore, isArchived, isFavorite, withPartners) +> List getMapMarkers(fileCreatedAfter, fileCreatedBefore, isArchived, isFavorite, withPartners, withSharedAlbums) @@ -528,9 +528,10 @@ final fileCreatedBefore = 2013-10-20T19:20:30+01:00; // DateTime | final isArchived = true; // bool | final isFavorite = true; // bool | final withPartners = true; // bool | +final withSharedAlbums = true; // bool | try { - final result = api_instance.getMapMarkers(fileCreatedAfter, fileCreatedBefore, isArchived, isFavorite, withPartners); + final result = api_instance.getMapMarkers(fileCreatedAfter, fileCreatedBefore, isArchived, isFavorite, withPartners, withSharedAlbums); print(result); } catch (e) { print('Exception when calling AssetApi->getMapMarkers: $e\n'); @@ -546,6 +547,7 @@ Name | Type | Description | Notes **isArchived** | **bool**| | [optional] **isFavorite** | **bool**| | [optional] **withPartners** | **bool**| | [optional] + **withSharedAlbums** | **bool**| | [optional] ### Return type diff --git a/mobile/openapi/lib/api/asset_api.dart b/mobile/openapi/lib/api/asset_api.dart index 6515046869..0363ee73b0 100644 --- a/mobile/openapi/lib/api/asset_api.dart +++ b/mobile/openapi/lib/api/asset_api.dart @@ -522,7 +522,9 @@ class AssetApi { /// * [bool] isFavorite: /// /// * [bool] withPartners: - Future getMapMarkersWithHttpInfo({ DateTime? fileCreatedAfter, DateTime? fileCreatedBefore, bool? isArchived, bool? isFavorite, bool? withPartners, }) async { + /// + /// * [bool] withSharedAlbums: + Future getMapMarkersWithHttpInfo({ DateTime? fileCreatedAfter, DateTime? fileCreatedBefore, bool? isArchived, bool? isFavorite, bool? withPartners, bool? withSharedAlbums, }) async { // ignore: prefer_const_declarations final path = r'/asset/map-marker'; @@ -548,6 +550,9 @@ class AssetApi { if (withPartners != null) { queryParams.addAll(_queryParams('', 'withPartners', withPartners)); } + if (withSharedAlbums != null) { + queryParams.addAll(_queryParams('', 'withSharedAlbums', withSharedAlbums)); + } const contentTypes = []; @@ -574,8 +579,10 @@ class AssetApi { /// * [bool] isFavorite: /// /// * [bool] withPartners: - Future?> getMapMarkers({ DateTime? fileCreatedAfter, DateTime? fileCreatedBefore, bool? isArchived, bool? isFavorite, bool? withPartners, }) async { - final response = await getMapMarkersWithHttpInfo( fileCreatedAfter: fileCreatedAfter, fileCreatedBefore: fileCreatedBefore, isArchived: isArchived, isFavorite: isFavorite, withPartners: withPartners, ); + /// + /// * [bool] withSharedAlbums: + Future?> getMapMarkers({ DateTime? fileCreatedAfter, DateTime? fileCreatedBefore, bool? isArchived, bool? isFavorite, bool? withPartners, bool? withSharedAlbums, }) async { + final response = await getMapMarkersWithHttpInfo( fileCreatedAfter: fileCreatedAfter, fileCreatedBefore: fileCreatedBefore, isArchived: isArchived, isFavorite: isFavorite, withPartners: withPartners, withSharedAlbums: withSharedAlbums, ); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } diff --git a/mobile/openapi/test/asset_api_test.dart b/mobile/openapi/test/asset_api_test.dart index 0a278daa32..aa6fa6c278 100644 --- a/mobile/openapi/test/asset_api_test.dart +++ b/mobile/openapi/test/asset_api_test.dart @@ -65,7 +65,7 @@ void main() { // TODO }); - //Future> getMapMarkers({ DateTime fileCreatedAfter, DateTime fileCreatedBefore, bool isArchived, bool isFavorite, bool withPartners }) async + //Future> getMapMarkers({ DateTime fileCreatedAfter, DateTime fileCreatedBefore, bool isArchived, bool isFavorite, bool withPartners, bool withSharedAlbums }) async test('test getMapMarkers', () async { // TODO }); diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index eea90fb1c9..8cfa31c3c6 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -1386,6 +1386,14 @@ "schema": { "type": "boolean" } + }, + { + "name": "withSharedAlbums", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } } ], "responses": { diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 23b3b00bed..2db110fa15 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -1442,12 +1442,13 @@ export function runAssetJobs({ assetJobsDto }: { body: assetJobsDto }))); } -export function getMapMarkers({ fileCreatedAfter, fileCreatedBefore, isArchived, isFavorite, withPartners }: { +export function getMapMarkers({ fileCreatedAfter, fileCreatedBefore, isArchived, isFavorite, withPartners, withSharedAlbums }: { fileCreatedAfter?: string; fileCreatedBefore?: string; isArchived?: boolean; isFavorite?: boolean; withPartners?: boolean; + withSharedAlbums?: boolean; }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; @@ -1457,7 +1458,8 @@ export function getMapMarkers({ fileCreatedAfter, fileCreatedBefore, isArchived, fileCreatedBefore, isArchived, isFavorite, - withPartners + withPartners, + withSharedAlbums }))}`, { ...opts })); diff --git a/server/src/dtos/search.dto.ts b/server/src/dtos/search.dto.ts index 31d4195e7a..4d05b9f3aa 100644 --- a/server/src/dtos/search.dto.ts +++ b/server/src/dtos/search.dto.ts @@ -317,6 +317,9 @@ export class MapMarkerDto { @ValidateBoolean({ optional: true }) withPartners?: boolean; + + @ValidateBoolean({ optional: true }) + withSharedAlbums?: boolean; } export class MemoryLaneDto { diff --git a/server/src/interfaces/asset.interface.ts b/server/src/interfaces/asset.interface.ts index 2c8f077cfb..79d90dde67 100644 --- a/server/src/interfaces/asset.interface.ts +++ b/server/src/interfaces/asset.interface.ts @@ -183,7 +183,7 @@ export interface IAssetRepository { softDeleteAll(ids: string[]): Promise; restoreAll(ids: string[]): Promise; findLivePhotoMatch(options: LivePhotoSearchOptions): Promise; - getMapMarkers(ownerIds: string[], options?: MapMarkerSearchOptions): Promise; + getMapMarkers(ownerIds: string[], albumIds: string[], options?: MapMarkerSearchOptions): Promise; getStatistics(ownerId: string, options: AssetStatsOptions): Promise; getTimeBuckets(options: TimeBucketOptions): Promise; getTimeBucket(timeBucket: string, options: TimeBucketOptions): Promise; diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index 7c359aa895..f9ed1c468c 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -490,9 +490,24 @@ export class AssetRepository implements IAssetRepository { }); } - async getMapMarkers(ownerIds: string[], options: MapMarkerSearchOptions = {}): Promise { + async getMapMarkers( + ownerIds: string[], + albumIds: string[], + options: MapMarkerSearchOptions = {}, + ): Promise { const { isArchived, isFavorite, fileCreatedAfter, fileCreatedBefore } = options; + const where = { + isVisible: true, + isArchived, + exifInfo: { + latitude: Not(IsNull()), + longitude: Not(IsNull()), + }, + isFavorite, + fileCreatedAt: OptionalBetween(fileCreatedAfter, fileCreatedBefore), + }; + const assets = await this.repository.find({ select: { id: true, @@ -504,17 +519,10 @@ export class AssetRepository implements IAssetRepository { longitude: true, }, }, - where: { - ownerId: In([...ownerIds]), - isVisible: true, - isArchived, - exifInfo: { - latitude: Not(IsNull()), - longitude: Not(IsNull()), - }, - isFavorite, - fileCreatedAt: OptionalBetween(fileCreatedAfter, fileCreatedBefore), - }, + where: [ + { ...where, ownerId: In([...ownerIds]) }, + { ...where, albums: { id: In([...albumIds]) } }, + ], relations: { exifInfo: true, }, diff --git a/server/src/services/asset.service.spec.ts b/server/src/services/asset.service.spec.ts index 5a61f70da8..2673e2436d 100755 --- a/server/src/services/asset.service.spec.ts +++ b/server/src/services/asset.service.spec.ts @@ -2,6 +2,7 @@ import { BadRequestException, UnauthorizedException } from '@nestjs/common'; import { mapAsset } from 'src/dtos/asset-response.dto'; import { AssetJobName, AssetStatsResponseDto, UploadFieldName } from 'src/dtos/asset.dto'; import { AssetEntity, AssetType } from 'src/entities/asset.entity'; +import { IAlbumRepository } from 'src/interfaces/album.interface'; import { IAssetStackRepository } from 'src/interfaces/asset-stack.interface'; import { AssetStats, IAssetRepository } from 'src/interfaces/asset.interface'; import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface'; @@ -18,6 +19,7 @@ import { faceStub } from 'test/fixtures/face.stub'; import { partnerStub } from 'test/fixtures/partner.stub'; import { userStub } from 'test/fixtures/user.stub'; import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock'; +import { newAlbumRepositoryMock } from 'test/repositories/album.repository.mock'; import { newAssetStackRepositoryMock } from 'test/repositories/asset-stack.repository.mock'; import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock'; import { newEventRepositoryMock } from 'test/repositories/event.repository.mock'; @@ -160,6 +162,7 @@ describe(AssetService.name, () => { let configMock: Mocked; let partnerMock: Mocked; let assetStackMock: Mocked; + let albumMock: Mocked; let loggerMock: Mocked; it('should work', () => { @@ -182,6 +185,7 @@ describe(AssetService.name, () => { configMock = newSystemConfigRepositoryMock(); partnerMock = newPartnerRepositoryMock(); assetStackMock = newAssetStackRepositoryMock(); + albumMock = newAlbumRepositoryMock(); loggerMock = newLoggerRepositoryMock(); sut = new AssetService( @@ -194,6 +198,7 @@ describe(AssetService.name, () => { eventMock, partnerMock, assetStackMock, + albumMock, loggerMock, ); diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index 5ffa940e7b..b1eff50a93 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -29,6 +29,7 @@ import { UpdateStackParentDto } from 'src/dtos/stack.dto'; import { AssetEntity } from 'src/entities/asset.entity'; import { LibraryType } from 'src/entities/library.entity'; import { IAccessRepository } from 'src/interfaces/access.interface'; +import { IAlbumRepository } from 'src/interfaces/album.interface'; import { IAssetStackRepository } from 'src/interfaces/asset-stack.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface'; import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface'; @@ -78,6 +79,7 @@ export class AssetService { @Inject(IEventRepository) private eventRepository: IEventRepository, @Inject(IPartnerRepository) private partnerRepository: IPartnerRepository, @Inject(IAssetStackRepository) private assetStackRepository: IAssetStackRepository, + @Inject(IAlbumRepository) private albumRepository: IAlbumRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository, ) { this.logger.setContext(AssetService.name); @@ -167,6 +169,7 @@ export class AssetService { async getMapMarkers(auth: AuthDto, options: MapMarkerDto): Promise { const userIds: string[] = [auth.user.id]; + // TODO convert to SQL join if (options.withPartners) { const partners = await this.partnerRepository.getAll(auth.user.id); const partnersIds = partners @@ -174,7 +177,18 @@ export class AssetService { .map((partner) => partner.sharedById); userIds.push(...partnersIds); } - return this.assetRepository.getMapMarkers(userIds, options); + + // TODO convert to SQL join + const albumIds: string[] = []; + if (options.withSharedAlbums) { + const [ownedAlbums, sharedAlbums] = await Promise.all([ + this.albumRepository.getOwned(auth.user.id), + this.albumRepository.getShared(auth.user.id), + ]); + albumIds.push(...ownedAlbums.map((album) => album.id), ...sharedAlbums.map((album) => album.id)); + } + + return this.assetRepository.getMapMarkers(userIds, albumIds, options); } async getMemoryLane(auth: AuthDto, dto: MemoryLaneDto): Promise { 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 460363722e..aded865a06 100644 --- a/web/src/lib/components/map-page/map-settings-modal.svelte +++ b/web/src/lib/components/map-page/map-settings-modal.svelte @@ -30,7 +30,12 @@ - + + {#if customDateRange}
diff --git a/web/src/lib/stores/preferences.store.ts b/web/src/lib/stores/preferences.store.ts index 591802f488..3e4bf09b28 100644 --- a/web/src/lib/stores/preferences.store.ts +++ b/web/src/lib/stores/preferences.store.ts @@ -47,6 +47,7 @@ export interface MapSettings { includeArchived: boolean; onlyFavorites: boolean; withPartners: boolean; + withSharedAlbums: boolean; relativeDate: string; dateAfter: string; dateBefore: string; @@ -57,6 +58,7 @@ export const mapSettings = persisted('map-settings', { includeArchived: false, onlyFavorites: false, withPartners: false, + withSharedAlbums: false, relativeDate: '', dateAfter: '', dateBefore: '', 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 593250a7c9..1b5923663b 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 @@ -47,7 +47,7 @@ } abortController = new AbortController(); - const { includeArchived, onlyFavorites, withPartners } = $mapSettings; + const { includeArchived, onlyFavorites, withPartners, withSharedAlbums } = $mapSettings; const { fileCreatedAfter, fileCreatedBefore } = getFileCreatedDates(); return await getMapMarkers( @@ -57,6 +57,7 @@ fileCreatedAfter: fileCreatedAfter || undefined, fileCreatedBefore, withPartners: withPartners || undefined, + withSharedAlbums: withSharedAlbums || undefined, }, { signal: abortController.signal, From 45316f985b259b353e41507355be0a6dee8359a1 Mon Sep 17 00:00:00 2001 From: Matthew Momjian <50788000+mmomjian@users.noreply.github.com> Date: Mon, 13 May 2024 09:46:38 -0400 Subject: [PATCH 018/163] Update portainer install docs (#9421) * Update portainer.md * Update portainer.md * chore: cleanup --------- Co-authored-by: Jason Rasmussen --- docs/docs/install/portainer.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/docs/install/portainer.md b/docs/docs/install/portainer.md index 2277ef9b9b..35fa7d67e4 100644 --- a/docs/docs/install/portainer.md +++ b/docs/docs/install/portainer.md @@ -38,8 +38,9 @@ style={{border: '1px solid #ddd'}} alt="Dot Env Example" /> -- Populate custom database information if necessary. -- Populate `UPLOAD_LOCATION` with your preferred location for storing backup assets. +- Change the default `DB_PASSWORD`, and add custom database connection information if necessary. +- Change `DB_DATA_LOCATION` to a folder where the database will be saved to disk. +- Change `UPLOAD_LOCATION` to a folder where media (uploaded and generated) will be stored. 11. Click on "**Deploy the stack**". From 06402aa9fbd8dab396ec9fe23410d2045b581067 Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 13 May 2024 10:36:35 -0500 Subject: [PATCH 019/163] chore(server): email notification button png (#9423) * chore(server): email notification button png * valid user name --------- Co-authored-by: Daniel Dietzler --- docs/static/img/ios-app-store-badge.png | Bin 0 -> 1797 bytes server/src/emails/welcome.email.tsx | 5 ++--- 2 files changed, 2 insertions(+), 3 deletions(-) create mode 100644 docs/static/img/ios-app-store-badge.png diff --git a/docs/static/img/ios-app-store-badge.png b/docs/static/img/ios-app-store-badge.png new file mode 100644 index 0000000000000000000000000000000000000000..fd3d3a335831bdf7063397998ec73b03ac285f75 GIT binary patch literal 1797 zcmV+g2m1JlP)tiN(yFN)DAsmb z@rhue8U!mcrGkU4nU<#}7&N(|K@m`^pioi3S_%bUgIc5_iYTDd>JTOH>jzFQH;{Wx zsI((F>&v}+?X&Lr-?R7HYpuOQ2qAytBzGP-q*|ym7iW}yi8V+5t z{Ngyh``4MjUMR!+c9+#+z(!t%;u?s^8l^OTH)8)9w4T5Z?+bc6d z?24-z1#S?G6uTnCN_saM;NB{VLu`3wqXF)yd|4=8->T67w-VbBVlTIBz_f3h)YJto z&_|1UTDo4llpghG^|W(}YZ?HYF7{%*fwL`jz;}{p&$7c@OT8$&Ue;FVkwV+cll7R7 z4+?TT(*WR>VlUSdxUH?&@3f@A&k2HYN(2{s-fHLRVwT`8mul`I!4flV@`DIC-5eXu zm9Dw_{LMlqNHfa|rpAF=nq{N=WXMr$k@fmTn&ozyD92wt=)gJJ5^929a**P{vz&E=`Fx_}<9?M)VBnk49Bu_p{TI^awjB)=AE_N82hlL9yKv{cu#PCmYXzm*bqvlEZ; zgSAWPFOC85y(xizm?A>jWsm&-;*mNj_7M(v-8ItwSHLf%1Wx{bZvqRC`hiZf1J9Xm zw)Zhh5ag*e?AQXongX{fMImUNJ=WOkD@XZ2Q!zXG+}^tm=q&gVetm3#*QI>JIZ4c& ziDK7bWPGuj=2Gq5uGD0m1VLBRNU(X9gSBU#RGS$@Vg`dW>VnFl)!f;0sajx)Yd+$qL#jR%y#=#<0E|qd06Z{)vB@E zR}(Bu!b{GrW%Yg^W3N312quE}z*Ip{h9|L4Fx)=-ZSxs!Z3yt#l)#@FTxV_YO&@3` zSnfbH3i^WkwQ-^%Y>0rFt+m$XU`>LJHeLd6DpBBssP)j09^injdI-ANjKLA`i*jYk z^oh6i6r5(G{m!mG@ELp#@-0W)b6$M@{TvhG!0*Wr1ZlS540+fV<;^W@CmQNC8S7|k zRf7){90ojtc&2qa5Fr zhlk>Ai=y{bTUyJv7@z7{3;1cA6ZOf#Un1cCg5VF>VT&!c*iJmJsu%EiDRK8Yys+EC z?!)@@jewu1<&rLzheh$WB{)BcPxvE7*8*OK+(_F!nhuA6XJDSfxO}f+$-|>5fF~c( zy8z|7MN){O2zXbtZa4Q9y2-=4Q6!aa2MM0~EjyhO|8@_?M8H!7K~ch>y#mFxfJa4t z(81KFTOHwVO5iLzli;qeqjL2KQKodTKC;hF%_66@P?io#ur}_r%5>CG8CE1%DZwgV z6SOeGCqC0%kdMWh3UXC@#{fYe|8&5>TEHEAY^Onj)|SL)b_4R^{yvC8ji)?lt^H9X zon1#Mtt#@+HS!cLj)rti81uffBjBaph}zzDM#5buTOCcAYE(rIs)K)_+CagmNW*@G z@nxxhbpK`c;zL!KQAdFExdnhs*SRu2G`hOVu=rw;D5ooQS*@0V3Vl;wRA}u+MaD*T z$~?@|#u!D$Bn;r1{Pz-fsW8X51V3@QLQ`Z#;mrhd{3^ca2F8(K9Q;8tQALiw^|x7Cta$KsH!FllxB*l)GOo(!>)+(xt2 zlB=S)k!_cjk7z_2;#uasQ<)J$STLk99C^qz{os*@5JD&$TC%&Kp4(tw&chA`N_LkG nO*qGMoF7~Ib9rISv8De2!9W0bC^sbC00000NkvXXu0mjfosM|r literal 0 HcmV?d00001 diff --git a/server/src/emails/welcome.email.tsx b/server/src/emails/welcome.email.tsx index ea6ca52d66..1e4bdd1496 100644 --- a/server/src/emails/welcome.email.tsx +++ b/server/src/emails/welcome.email.tsx @@ -105,8 +105,7 @@ export const WelcomeEmail = ({ baseUrl, displayName, username, password }: Welco Immich @@ -133,7 +132,7 @@ export const WelcomeEmail = ({ baseUrl, displayName, username, password }: Welco WelcomeEmail.PreviewProps = { baseUrl: 'https://demo.immich.app/auth/login', displayName: 'Alan Turing', - username: 'alanturing', + username: 'alanturing@immich.app', password: 'mysuperpassword', } as WelcomeEmailProps; From 9c5a2b97bf0079dc12c0d7bb5963090eb9e248bd Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Mon, 13 May 2024 12:29:39 -0400 Subject: [PATCH 020/163] fix(server): put system config (#9425) --- e2e/src/api/specs/system-config.e2e-spec.ts | 20 ++++++++++++++++++++ server/src/cores/system-config.core.ts | 2 +- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/e2e/src/api/specs/system-config.e2e-spec.ts b/e2e/src/api/specs/system-config.e2e-spec.ts index 04cfec91e0..6be2683898 100644 --- a/e2e/src/api/specs/system-config.e2e-spec.ts +++ b/e2e/src/api/specs/system-config.e2e-spec.ts @@ -86,6 +86,26 @@ describe('/system-config', () => { expect(body).toEqual(errorDto.unauthorized); }); + it('should always return the new config', async () => { + const config = await getSystemConfig(admin.accessToken); + + const response1 = await request(app) + .put('/system-config') + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ ...config, newVersionCheck: { enabled: false } }); + + expect(response1.status).toBe(200); + expect(response1.body).toEqual({ ...config, newVersionCheck: { enabled: false } }); + + const response2 = await request(app) + .put('/system-config') + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ ...config, newVersionCheck: { enabled: true } }); + + expect(response2.status).toBe(200); + expect(response2.body).toEqual({ ...config, newVersionCheck: { enabled: true } }); + }); + it('should reject an invalid config entry', async () => { const { status, body } = await request(app) .put('/system-config') diff --git a/server/src/cores/system-config.core.ts b/server/src/cores/system-config.core.ts index c94da0977b..050f2891c4 100644 --- a/server/src/cores/system-config.core.ts +++ b/server/src/cores/system-config.core.ts @@ -321,7 +321,7 @@ export class SystemConfigCore { await this.repository.deleteKeys(deletes.map((item) => item.key)); } - const config = await this.getConfig(); + const config = await this.getConfig(true); this.config$.next(config); From 540e568e9d29abbf4306c4ff761614bb5cc93291 Mon Sep 17 00:00:00 2001 From: FedericoCalzoni <56981117+FedericoCalzoni@users.noreply.github.com> Date: Mon, 13 May 2024 18:49:30 +0200 Subject: [PATCH 021/163] docs: update external-library.md (#9420) * Update external-library.md I believe that displaying the code for both sections, even if it seems a bit repetitive, can help prevent fast readers from overlooking it * Apply suggestions from code review Co-authored-by: Matthew Momjian <50788000+mmomjian@users.noreply.github.com> --------- Co-authored-by: Matthew Momjian <50788000+mmomjian@users.noreply.github.com> --- docs/docs/guides/external-library.md | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/docs/docs/guides/external-library.md b/docs/docs/guides/external-library.md index b1d4b67b2e..042f3900cc 100644 --- a/docs/docs/guides/external-library.md +++ b/docs/docs/guides/external-library.md @@ -6,15 +6,19 @@ in a directory on the same machine. # Mount the directory into the containers. -Edit `docker-compose.yml` to add two new mount points under `volumes:` +Edit `docker-compose.yml` to add two new mount points in the sections `immich-server:` and `immich-microservices:` under `volumes:` -``` - immich-server: +```diff +immich-server: volumes: - - ${EXTERNAL_PATH}:/usr/src/app/external ++ - ${EXTERNAL_PATH}:/usr/src/app/external + +immich-microservices: + volumes: ++ - ${EXTERNAL_PATH}:/usr/src/app/external ``` -Be sure to add exactly the same line to both `immich-server:` and `immich-microservices:`. +Be sure to add exactly the same path to both services. Edit `.env` to define `EXTERNAL_PATH`, substituting in the correct path for your computer: From 1045957add936ab833a29742f0983192770ce3f0 Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 13 May 2024 13:27:21 -0500 Subject: [PATCH 022/163] Localizely: Translations update (#9432) chore(mobile): translation update --- mobile/assets/i18n/ar-JO.json | 1 + mobile/assets/i18n/cs-CZ.json | 1 + mobile/assets/i18n/da-DK.json | 1 + mobile/assets/i18n/de-DE.json | 1 + mobile/assets/i18n/el-GR.json | 1 + mobile/assets/i18n/en-US.json | 2 +- mobile/assets/i18n/es-ES.json | 65 ++-- mobile/assets/i18n/es-MX.json | 1 + mobile/assets/i18n/es-PE.json | 1 + mobile/assets/i18n/es-US.json | 1 + mobile/assets/i18n/fi-FI.json | 1 + mobile/assets/i18n/fr-CA.json | 1 + mobile/assets/i18n/fr-FR.json | 1 + mobile/assets/i18n/he-IL.json | 1 + mobile/assets/i18n/hi-IN.json | 1 + mobile/assets/i18n/hu-HU.json | 561 ++++++++++++++++---------------- mobile/assets/i18n/it-IT.json | 1 + mobile/assets/i18n/ja-JP.json | 3 +- mobile/assets/i18n/ko-KR.json | 3 +- mobile/assets/i18n/lt-LT.json | 1 + mobile/assets/i18n/lv-LV.json | 1 + mobile/assets/i18n/mn.json | 1 + mobile/assets/i18n/nb-NO.json | 1 + mobile/assets/i18n/nl-NL.json | 1 + mobile/assets/i18n/pl-PL.json | 1 + mobile/assets/i18n/pt-PT.json | 1 + mobile/assets/i18n/ro-RO.json | 1 + mobile/assets/i18n/ru-RU.json | 1 + mobile/assets/i18n/sk-SK.json | 1 + mobile/assets/i18n/sl-SI.json | 1 + mobile/assets/i18n/sr-Cyrl.json | 1 + mobile/assets/i18n/sr-Latn.json | 1 + mobile/assets/i18n/sv-FI.json | 1 + mobile/assets/i18n/sv-SE.json | 1 + mobile/assets/i18n/th-TH.json | 1 + mobile/assets/i18n/uk-UA.json | 1 + mobile/assets/i18n/vi-VN.json | 1 + mobile/assets/i18n/zh-CN.json | 1 + mobile/assets/i18n/zh-Hans.json | 1 + mobile/assets/i18n/zh-TW.json | 1 + 40 files changed, 354 insertions(+), 315 deletions(-) diff --git a/mobile/assets/i18n/ar-JO.json b/mobile/assets/i18n/ar-JO.json index 03d95e3231..fe19784f58 100644 --- a/mobile/assets/i18n/ar-JO.json +++ b/mobile/assets/i18n/ar-JO.json @@ -410,6 +410,7 @@ "share_add": "يضيف", "share_add_photos": "إضافة الصور", "share_add_title": "إضافة عنوان", + "share_assets_selected": "{} selected", "share_create_album": "إنشاء ألبوم", "shared_album_activities_input_disable": "التعليق معطل", "shared_album_activities_input_hint": "قل شيئا", diff --git a/mobile/assets/i18n/cs-CZ.json b/mobile/assets/i18n/cs-CZ.json index 52abe306d5..dea62eae6e 100644 --- a/mobile/assets/i18n/cs-CZ.json +++ b/mobile/assets/i18n/cs-CZ.json @@ -410,6 +410,7 @@ "share_add": "Přidat", "share_add_photos": "Přidat fotografie", "share_add_title": "Přidat název", + "share_assets_selected": "{} vybráno", "share_create_album": "Vytvořit album", "shared_album_activities_input_disable": "Komentář je vypnutý", "shared_album_activities_input_hint": "Řekněte něco", diff --git a/mobile/assets/i18n/da-DK.json b/mobile/assets/i18n/da-DK.json index b6a19cf4f5..20ad69f3e0 100644 --- a/mobile/assets/i18n/da-DK.json +++ b/mobile/assets/i18n/da-DK.json @@ -410,6 +410,7 @@ "share_add": "Tilføj", "share_add_photos": "Tilføj billeder", "share_add_title": "Tilføj en titel", + "share_assets_selected": "{} valgt", "share_create_album": "Opret album", "shared_album_activities_input_disable": "Kommentarer er deaktiveret", "shared_album_activities_input_hint": "Skriv noget", diff --git a/mobile/assets/i18n/de-DE.json b/mobile/assets/i18n/de-DE.json index af17d3c59a..5aeedf37fc 100644 --- a/mobile/assets/i18n/de-DE.json +++ b/mobile/assets/i18n/de-DE.json @@ -410,6 +410,7 @@ "share_add": "Hinzufügen", "share_add_photos": "Fotos hinzufügen", "share_add_title": "Titel hinzufügen", + "share_assets_selected": "{} ausgewählt", "share_create_album": "Album erstellen", "shared_album_activities_input_disable": "Kommentare sind deaktiviert.", "shared_album_activities_input_hint": "Sag etwas", diff --git a/mobile/assets/i18n/el-GR.json b/mobile/assets/i18n/el-GR.json index 53ea170140..6623a6dae3 100644 --- a/mobile/assets/i18n/el-GR.json +++ b/mobile/assets/i18n/el-GR.json @@ -410,6 +410,7 @@ "share_add": "Add", "share_add_photos": "Add photos", "share_add_title": "Add a title", + "share_assets_selected": "{} selected", "share_create_album": "Create album", "shared_album_activities_input_disable": "Το σχόλιο είναι απενεργοποιημένο", "shared_album_activities_input_hint": "Say something", diff --git a/mobile/assets/i18n/en-US.json b/mobile/assets/i18n/en-US.json index 84a8d94ee0..c968c603f7 100644 --- a/mobile/assets/i18n/en-US.json +++ b/mobile/assets/i18n/en-US.json @@ -410,8 +410,8 @@ "share_add": "Add", "share_add_photos": "Add photos", "share_add_title": "Add a title", - "share_create_album": "Create album", "share_assets_selected": "{} selected", + "share_create_album": "Create album", "shared_album_activities_input_disable": "Comment is disabled", "shared_album_activities_input_hint": "Say something", "shared_album_activity_remove_content": "Do you want to delete this activity?", diff --git a/mobile/assets/i18n/es-ES.json b/mobile/assets/i18n/es-ES.json index 005ebaa72d..5f64bbb4e8 100644 --- a/mobile/assets/i18n/es-ES.json +++ b/mobile/assets/i18n/es-ES.json @@ -7,7 +7,7 @@ "add_to_album_bottom_sheet_added": "Agregado a {album}", "add_to_album_bottom_sheet_already_exists": "Ya se encuentra en {album}", "advanced_settings_log_level_title": "Nivel de registro: {}", - "advanced_settings_prefer_remote_subtitle": "Algunos dispositivos tardan mucho en cargar las miniaturas de recursos encontrados el dispositivo. Activa esta opción para cargar imágenes remotas en su lugar.", + "advanced_settings_prefer_remote_subtitle": "Algunos dispositivos tardan mucho en cargar las miniaturas de los elementos encontrados en el dispositivo. Activa esta opción para cargar imágenes remotas en su lugar.", "advanced_settings_prefer_remote_title": "Preferir imágenes remotas", "advanced_settings_self_signed_ssl_subtitle": "Omitir verificación del certificado SSL del servidor. Requerido para certificados autofirmados", "advanced_settings_self_signed_ssl_title": "Permitir certificados autofirmados", @@ -26,7 +26,7 @@ "album_viewer_appbar_share_delete": "Eliminar álbum ", "album_viewer_appbar_share_err_delete": "No ha podido eliminar el álbum", "album_viewer_appbar_share_err_leave": "No se ha podido abandonar el álbum", - "album_viewer_appbar_share_err_remove": "Hay problemas para eliminar los archivos del álbum", + "album_viewer_appbar_share_err_remove": "Hay problemas para eliminar los elementos del álbum", "album_viewer_appbar_share_err_title": "Error al cambiar el título del álbum ", "album_viewer_appbar_share_leave": "Abandonar álbum ", "album_viewer_appbar_share_remove": "Eliminar del álbum ", @@ -37,14 +37,14 @@ "app_bar_signout_dialog_content": "¿Estás seguro que quieres cerrar sesión?", "app_bar_signout_dialog_ok": "Sí", "app_bar_signout_dialog_title": "Cerrar sesión", - "archive_page_no_archived_assets": "No se encontraron recursos archivados", + "archive_page_no_archived_assets": "No se encontraron elementos archivados", "archive_page_title": "Archivo ({})", "asset_action_delete_err_read_only": "No se pueden borrar el archivo(s) de solo lectura, omitiendo", "asset_action_share_err_offline": "No se pudo obtener el archivo(s) sin conexión, omitiendo", "asset_list_group_by_sub_title": "Agrupar por", "asset_list_layout_settings_dynamic_layout_title": "Diseño dinámico", "asset_list_layout_settings_group_automatically": "Automatico", - "asset_list_layout_settings_group_by": "Agrupar recursos por", + "asset_list_layout_settings_group_by": "Agrupar elementos por", "asset_list_layout_settings_group_by_month": "Mes", "asset_list_layout_settings_group_by_month_day": "Mes + día", "asset_list_layout_sub_title": "Disposición", @@ -53,17 +53,17 @@ "asset_viewer_settings_title": "Visor de Archivos", "backup_album_selection_page_albums_device": "Álbumes en el dispositivo ({})", "backup_album_selection_page_albums_tap": "Toque para incluir, doble toque para excluir", - "backup_album_selection_page_assets_scatter": "Los archivos pueden dispersarse en varios álbumes. De este modo, los álbumes pueden ser incluidos o excluidos durante el proceso de copia de seguridad.", + "backup_album_selection_page_assets_scatter": "Los elementos pueden dispersarse en varios álbumes. De este modo, los álbumes pueden ser incluidos o excluidos durante el proceso de copia de seguridad.", "backup_album_selection_page_select_albums": "Seleccionar Álbumes", "backup_album_selection_page_selection_info": "Información sobre la Selección", - "backup_album_selection_page_total_assets": "Total de archivos únicos", + "backup_album_selection_page_total_assets": "Total de elementos únicos", "backup_all": "Todos", - "backup_background_service_backup_failed_message": "Error al copiar archivos. Reintentando...", + "backup_background_service_backup_failed_message": "Error al copiar elementos. Reintentando...", "backup_background_service_connection_failed_message": "Error al conectar con el servidor. Reintentando...", "backup_background_service_current_upload_notification": "Cargando {}", - "backup_background_service_default_notification": "Verificando si hay nuevos archivos", + "backup_background_service_default_notification": "Verificando si hay nuevos elementos", "backup_background_service_error_title": "Error de copia de seguridad", - "backup_background_service_in_progress_notification": "Creando copia de seguridad de tus archivos...", + "backup_background_service_in_progress_notification": "Creando copia de seguridad de tus elementos...", "backup_background_service_upload_failure_notification": "Error al cargar {}", "backup_controller_page_albums": "Álbumes de copia de seguridad", "backup_controller_page_background_app_refresh_disabled_content": "Activa la actualización en segundo plano de la aplicación en Configuración > General > Actualización en segundo plano para usar la copia de seguridad en segundo plano.", @@ -76,7 +76,7 @@ "backup_controller_page_background_charging": "Solo mientras se carga", "backup_controller_page_background_configure_error": "Error al configurar el servicio en segundo plano", "backup_controller_page_background_delay": "Retraso en la copia de seguridad de nuevos elementos: {}", - "backup_controller_page_background_description": "Activa el servicio en segundo plano para copiar automáticamente cualquier nuevos archivos sin necesidad de abrir la aplicación.", + "backup_controller_page_background_description": "Activa el servicio en segundo plano para copiar automáticamente cualquier nuevos elementos sin necesidad de abrir la aplicación.", "backup_controller_page_background_is_off": "La copia de seguridad en segundo plano automática está desactivada", "backup_controller_page_background_is_on": "La copia de seguridad en segundo plano automática está activada", "backup_controller_page_background_turn_off": "Desactivar el servicio en segundo plano", @@ -109,28 +109,28 @@ "backup_controller_page_turn_on": "Activar la copia de seguridad", "backup_controller_page_uploading_file_info": "Cargando información del archivo", "backup_err_only_album": "No se puede eliminar el único álbum", - "backup_info_card_assets": "archivos", + "backup_info_card_assets": "elementos", "backup_manual_cancelled": "Cancelado", "backup_manual_failed": "Fallido", "backup_manual_in_progress": "Subida en progreso. Espere", "backup_manual_success": "Éxito", "backup_manual_title": "Estado de la subida", "backup_options_page_title": "Opciones de Copia de Seguridad", - "cache_settings_album_thumbnails": "Miniaturas de la página de la biblioteca ({} archivos)", + "cache_settings_album_thumbnails": "Miniaturas de la página de la biblioteca ({} elementos)", "cache_settings_clear_cache_button": "Borrar caché", "cache_settings_clear_cache_button_title": "Borra la caché de la aplicación. Esto afectará significativamente el rendimiento de la aplicación hasta que se reconstruya la caché.", "cache_settings_duplicated_assets_clear_button": "LIMPIAR", "cache_settings_duplicated_assets_subtitle": "Fotos y vídeos en la lista negra de la app", - "cache_settings_duplicated_assets_title": "Archivos duplicados ({})", - "cache_settings_image_cache_size": "Tamaño de la caché de imágenes ({} archivos)", + "cache_settings_duplicated_assets_title": "Elementos duplicados ({})", + "cache_settings_image_cache_size": "Tamaño de la caché de imágenes ({} elementos)", "cache_settings_statistics_album": "Miniaturas de la biblioteca", - "cache_settings_statistics_assets": "{} archivos ({})", + "cache_settings_statistics_assets": "{} elementos ({})", "cache_settings_statistics_full": "Imágenes completas", "cache_settings_statistics_shared": "Miniaturas de álbumes compartidos", "cache_settings_statistics_thumbnail": "Miniaturas", "cache_settings_statistics_title": "Uso de caché", "cache_settings_subtitle": "Controla el comportamiento del almacenamiento en caché de la aplicación móvil Immich", - "cache_settings_thumbnail_size": "Tamaño de la caché de miniaturas ({} archivos)", + "cache_settings_thumbnail_size": "Tamaño de la caché de miniaturas ({} elementos)", "cache_settings_tile_subtitle": "Controla el comportamiento del almacenamiento local", "cache_settings_tile_title": "Almacenamiento local", "cache_settings_title": "Configuración de la caché", @@ -165,7 +165,7 @@ "create_album_page_untitled": "Sin título", "create_shared_album_page_create": "Crear", "create_shared_album_page_share": "Compartir", - "create_shared_album_page_share_add_assets": "AGREGAR ARCHIVOS", + "create_shared_album_page_share_add_assets": "AGREGAR ELEMENTOS", "create_shared_album_page_share_select_photos": "Seleccionar Fotos", "curated_location_page_title": "Lugares", "curated_object_page_title": "Objetos", @@ -199,26 +199,26 @@ "experimental_settings_new_asset_list_title": "Habilitar cuadrícula fotográfica experimental", "experimental_settings_subtitle": "Úsalo bajo tu responsabilidad", "experimental_settings_title": "Experimental", - "favorites_page_no_favorites": "No se encontraron recursos marcados como favoritos", + "favorites_page_no_favorites": "No se encontraron elementos marcados como favoritos", "favorites_page_title": "Favoritos", "haptic_feedback_switch": "Activar respuesta háptica", "haptic_feedback_title": "Respuesta Háptica", "home_page_add_to_album_conflicts": "{added} elementos agregados al álbum {album}.{failed} elementos ya existen en el álbum.", - "home_page_add_to_album_err_local": "Aún no se pueden agregar recursos locales a álbumes, omitiendo", + "home_page_add_to_album_err_local": "Aún no se pueden agregar elementos locales a álbumes, omitiendo", "home_page_add_to_album_success": "{added} elementos agregados al álbum {album}. ", - "home_page_album_err_partner": "Aún no se pueden agregar activos a un album de un compañero, omitiendo", - "home_page_archive_err_local": "Los recursos locales no pueden ser archivados, omitiendo", - "home_page_archive_err_partner": "No se pueden archivar activos de un compañero, omitiendo", + "home_page_album_err_partner": "Aún no se pueden agregar elementos a un álbum de un compañero, omitiendo", + "home_page_archive_err_local": "Los elementos locales no pueden ser archivados, omitiendo", + "home_page_archive_err_partner": "No se pueden archivar elementos de un compañero, omitiendo", "home_page_building_timeline": "Construyendo la línea de tiempo", - "home_page_delete_err_partner": "No se pueden eliminar activos de un compañero, omitiendo", - "home_page_delete_remote_err_local": "Archivos locales en eliminación de selección remota, omitiendo", - "home_page_favorite_err_local": "Aún no se pueden archivar recursos locales, omitiendo", - "home_page_favorite_err_partner": "Aún no se pueden marcar recursos de compañeros como favoritos, omitiendo", + "home_page_delete_err_partner": "No se pueden eliminar elementos de un compañero, omitiendo", + "home_page_delete_remote_err_local": "Elementos locales en la selección de eliminación remota, omitiendo", + "home_page_favorite_err_local": "Aún no se pueden archivar elementos locales, omitiendo", + "home_page_favorite_err_partner": "Aún no se pueden marcar elementos de compañeros como favoritos, omitiendo", "home_page_first_time_notice": "Si esta es la primera vez que usas la app, por favor, asegúrate de elegir un álbum de respaldo para que la línea de tiempo pueda cargar fotos y videos en los álbumes.", - "home_page_share_err_local": "No se pueden compartir activos locales a través de un enlace, omitiendo", + "home_page_share_err_local": "No se pueden compartir elementos locales a través de un enlace, omitiendo", "home_page_upload_err_limit": "Solo se pueden subir 30 elementos simultáneamente, omitiendo", "image_viewer_page_state_provider_download_error": "Error de descarga", - "image_viewer_page_state_provider_download_started": "Download Started", + "image_viewer_page_state_provider_download_started": "Descarga Iniciada", "image_viewer_page_state_provider_download_success": "Descarga exitosa", "image_viewer_page_state_provider_share_error": "Error al compartir", "library_page_albums": "Álbumes", @@ -227,7 +227,7 @@ "library_page_favorites": "Favoritos", "library_page_new_album": "Nuevo álbum", "library_page_sharing": "Compartiendo", - "library_page_sort_asset_count": "Número de archivos", + "library_page_sort_asset_count": "Número de elementos", "library_page_sort_created": "Creado más recientemente", "library_page_sort_last_modified": "Última modificación", "library_page_sort_most_oldest_photo": "Foto más antigua", @@ -299,7 +299,7 @@ "motion_photos_page_title": "Foto en Movimiento", "multiselect_grid_edit_date_time_err_read_only": "No se puede cambiar la fecha del archivo(s) de solo lectura, omitiendo", "multiselect_grid_edit_gps_err_read_only": "No se puede cambiar la localización de archivos de solo lectura. Saltando.", - "no_assets_to_show": "No assets to show", + "no_assets_to_show": "No hay elementos a mostrar", "notification_permission_dialog_cancel": "Cancelar", "notification_permission_dialog_content": "Para activar las notificaciones, ve a Configuración y selecciona permitir.", "notification_permission_dialog_settings": "Ajustes", @@ -403,13 +403,14 @@ "setting_notifications_single_progress_title": "Mostrar progreso detallado de copia de seguridad en segundo plano", "setting_notifications_subtitle": "Ajusta tus preferencias de notificación", "setting_notifications_title": "Notificaciones", - "setting_notifications_total_progress_subtitle": "Progreso general de subida (archivos completados/total)", + "setting_notifications_total_progress_subtitle": "Progreso general de subida (elementos completados/total)", "setting_notifications_total_progress_title": "Mostrar progreso total de copia de seguridad en segundo plano", "setting_pages_app_bar_settings": "Ajustes", "settings_require_restart": "Por favor, reinicia Immich para aplicar este ajuste", "share_add": "Agregar", "share_add_photos": "Agregar fotos", "share_add_title": "Agregar un título", + "share_assets_selected": "{} selected", "share_create_album": "Crear álbum", "shared_album_activities_input_disable": "Los comentarios están deshabilitados", "shared_album_activities_input_hint": "Comenta algo", @@ -462,7 +463,7 @@ "shared_link_expires_never": "Caduca ∞", "shared_link_expires_second": "Caduca en {} segundo", "shared_link_expires_seconds": "Caduca en {} segundos", - "shared_link_individual_shared": "Individual shared", + "shared_link_individual_shared": "Compartido individualmente", "shared_link_info_chip_download": "Descargar", "shared_link_info_chip_metadata": "EXIF", "shared_link_info_chip_upload": "Subir", diff --git a/mobile/assets/i18n/es-MX.json b/mobile/assets/i18n/es-MX.json index 13255a6f4c..2f8b14fcfe 100644 --- a/mobile/assets/i18n/es-MX.json +++ b/mobile/assets/i18n/es-MX.json @@ -410,6 +410,7 @@ "share_add": "Agregar", "share_add_photos": "Agregar fotos", "share_add_title": "Agregar un título", + "share_assets_selected": "{} selected", "share_create_album": "Crear álbum", "shared_album_activities_input_disable": "Los comentarios están deshabilitados", "shared_album_activities_input_hint": "Say something", diff --git a/mobile/assets/i18n/es-PE.json b/mobile/assets/i18n/es-PE.json index 2904a3b412..cc0439af98 100644 --- a/mobile/assets/i18n/es-PE.json +++ b/mobile/assets/i18n/es-PE.json @@ -410,6 +410,7 @@ "share_add": "Agregar", "share_add_photos": "Agregar fotos", "share_add_title": "Agregar un título", + "share_assets_selected": "{} selected", "share_create_album": "Crear álbum", "shared_album_activities_input_disable": "Los comentarios están deshabilitados", "shared_album_activities_input_hint": "Comenta algo", diff --git a/mobile/assets/i18n/es-US.json b/mobile/assets/i18n/es-US.json index c6235208a8..6a3270c5ef 100644 --- a/mobile/assets/i18n/es-US.json +++ b/mobile/assets/i18n/es-US.json @@ -410,6 +410,7 @@ "share_add": "Agregar", "share_add_photos": "Agregar fotos", "share_add_title": "Agregar un título", + "share_assets_selected": "{} selected", "share_create_album": "Crear álbum", "shared_album_activities_input_disable": "Los comentarios están deshabilitados", "shared_album_activities_input_hint": "Di algo", diff --git a/mobile/assets/i18n/fi-FI.json b/mobile/assets/i18n/fi-FI.json index 00e7963fe5..49807fcd1d 100644 --- a/mobile/assets/i18n/fi-FI.json +++ b/mobile/assets/i18n/fi-FI.json @@ -410,6 +410,7 @@ "share_add": "Lisää", "share_add_photos": "Lisää kuvia", "share_add_title": "Lisää nimi", + "share_assets_selected": "{} selected", "share_create_album": "Luo albumi", "shared_album_activities_input_disable": "Kommentointi on kytketty pois päältä", "shared_album_activities_input_hint": "Sano jotain", diff --git a/mobile/assets/i18n/fr-CA.json b/mobile/assets/i18n/fr-CA.json index 5e5a15c984..b2b51bdb55 100644 --- a/mobile/assets/i18n/fr-CA.json +++ b/mobile/assets/i18n/fr-CA.json @@ -410,6 +410,7 @@ "share_add": "Ajouter", "share_add_photos": "Ajouter des photos", "share_add_title": "Ajouter un titre", + "share_assets_selected": "{} selected", "share_create_album": "Créer un album", "shared_album_activities_input_disable": "Les commentaires sont désactivés", "shared_album_activities_input_hint": "Dire quelque chose", diff --git a/mobile/assets/i18n/fr-FR.json b/mobile/assets/i18n/fr-FR.json index f4c00c9662..061a0df3c2 100644 --- a/mobile/assets/i18n/fr-FR.json +++ b/mobile/assets/i18n/fr-FR.json @@ -410,6 +410,7 @@ "share_add": "Ajouter", "share_add_photos": "Ajouter des photos", "share_add_title": "Ajouter un titre", + "share_assets_selected": "{} selected", "share_create_album": "Créer un album", "shared_album_activities_input_disable": "Les commentaires sont désactivés", "shared_album_activities_input_hint": "Dire quelque chose", diff --git a/mobile/assets/i18n/he-IL.json b/mobile/assets/i18n/he-IL.json index ce48e1ff74..9c914d5d39 100644 --- a/mobile/assets/i18n/he-IL.json +++ b/mobile/assets/i18n/he-IL.json @@ -410,6 +410,7 @@ "share_add": "הוסף", "share_add_photos": "הוסף תמונות", "share_add_title": "הוסף כותרת", + "share_assets_selected": "{} selected", "share_create_album": "צור אלבום", "shared_album_activities_input_disable": "התגובה מושבתת", "shared_album_activities_input_hint": "הגב/י משהו", diff --git a/mobile/assets/i18n/hi-IN.json b/mobile/assets/i18n/hi-IN.json index a1e01d2f74..3efddab4c2 100644 --- a/mobile/assets/i18n/hi-IN.json +++ b/mobile/assets/i18n/hi-IN.json @@ -410,6 +410,7 @@ "share_add": "Add", "share_add_photos": "Add photos", "share_add_title": "Add a title", + "share_assets_selected": "{} selected", "share_create_album": "Create album", "shared_album_activities_input_disable": "कॉमेंट डिजेबल्ड है", "shared_album_activities_input_hint": "कुछ कहें", diff --git a/mobile/assets/i18n/hu-HU.json b/mobile/assets/i18n/hu-HU.json index bf7c5cbff7..034a2ece15 100644 --- a/mobile/assets/i18n/hu-HU.json +++ b/mobile/assets/i18n/hu-HU.json @@ -1,28 +1,28 @@ { - "action_common_back": "Back", + "action_common_back": "Vissza", "action_common_cancel": "Mégsem", - "action_common_clear": "Clear", - "action_common_confirm": "Confirm", + "action_common_clear": "Kitöröl", + "action_common_confirm": "Jóváhagy", "action_common_update": "Frissít", - "add_to_album_bottom_sheet_added": "Hozzáadva a(z) {album} nevű albumhoz", - "add_to_album_bottom_sheet_already_exists": "Már eleme a(z) {album} nevű albumnak", + "add_to_album_bottom_sheet_added": "Hozzáadva a(z) \"{album}\" albumhoz", + "add_to_album_bottom_sheet_already_exists": "Már benne van a(z) \"{album}\" albumban", "advanced_settings_log_level_title": "Naplózás szintje: {}", - "advanced_settings_prefer_remote_subtitle": "Néhány eszköz fájdalmasan lassan tölti be az eszközön lévő elemeket. Ezzel a beállítással inkább a távoli képeket töltjük be helyette.", - "advanced_settings_prefer_remote_title": "Távoli képek preferálása", - "advanced_settings_self_signed_ssl_subtitle": "SSL tanúsítvány ellenőrzésének kihagyása a szerver végponthoz. Ehhez saját aláírt tanúsítványok szükségesek.", - "advanced_settings_self_signed_ssl_title": "Saját aláírt SSL tanúsítványok engedélyezése", + "advanced_settings_prefer_remote_subtitle": "Néhány eszköz fájdalmasan lassan tölti be az eszközön lévő bélyegképeket. Ezzel a beállítással inkább a távoli képeket töltjük be helyette.", + "advanced_settings_prefer_remote_title": "Távoli képek előnyben részesítése", + "advanced_settings_self_signed_ssl_subtitle": "Nem ellenőrzi a szerver SSL tanúsítványát. Önaláírt tanúsítvány esetén szükséges beállítás.", + "advanced_settings_self_signed_ssl_title": "Önaláírt SSL tanúsítványok engedélyezése", "advanced_settings_tile_subtitle": "Haladó felhasználói beállítások", "advanced_settings_tile_title": "Haladó", "advanced_settings_troubleshooting_subtitle": "További funkciók engedélyezése hibaelhárítás céljából", "advanced_settings_troubleshooting_title": "Hibaelhárítás", - "album_info_card_backup_album_excluded": "KIZÁRVA", + "album_info_card_backup_album_excluded": "KIHAGYVA", "album_info_card_backup_album_included": "BELEÉRTVE", "album_thumbnail_card_item": "1 elem", "album_thumbnail_card_items": "{} elem", "album_thumbnail_card_shared": "· Megosztott", "album_thumbnail_owned": "Tulajdonos", "album_thumbnail_shared_by": "Megosztotta: {}", - "album_viewer_appbar_delete_confirm": "Are you sure you want to delete this album from your account?", + "album_viewer_appbar_delete_confirm": "Biztos, hogy törölni szeretnéd ezt az albumot?", "album_viewer_appbar_share_delete": "Album törlése", "album_viewer_appbar_share_err_delete": "Nem sikerült törölni az albumot", "album_viewer_appbar_share_err_leave": "Nem sikerült kilépni az albumból", @@ -39,18 +39,18 @@ "app_bar_signout_dialog_title": "Kijelentkezés", "archive_page_no_archived_assets": "Nem található archivált elem", "archive_page_title": "Archívum ({})", - "asset_action_delete_err_read_only": "Nem sikerült törölni a csak-olvasható elem(ek)et, így ezeket átugorjuk", - "asset_action_share_err_offline": "Nem sikerült betölteni az offline elem(ek)et, így ezeket kihagyjuk", - "asset_list_group_by_sub_title": "Group by", + "asset_action_delete_err_read_only": "Csak-olvasható elem(ek)et nem lehet törölni, így ezeket átugorjuk", + "asset_action_share_err_offline": "Nem sikerült betölteni a kapcsolat nélküli elem(ek)et, így ezeket kihagyjuk", + "asset_list_group_by_sub_title": "Csoportosítás", "asset_list_layout_settings_dynamic_layout_title": "Dinamikus elrendezés", "asset_list_layout_settings_group_automatically": "Automatikus", "asset_list_layout_settings_group_by": "Elemek csoportosítása", "asset_list_layout_settings_group_by_month": "hónapok szerint", "asset_list_layout_settings_group_by_month_day": "hónap és nap szerint", - "asset_list_layout_sub_title": "Layout", + "asset_list_layout_sub_title": "Elrendezés", "asset_list_settings_subtitle": "Fotórács elrendezése", "asset_list_settings_title": "Fotórács", - "asset_viewer_settings_title": "Asset Viewer", + "asset_viewer_settings_title": "Elem Megjelenítő", "backup_album_selection_page_albums_device": "Ezen az eszközön lévő albumok ({})", "backup_album_selection_page_albums_tap": "Koppincs a hozzáadáshoz, duplán koppincs az eltávolításhoz", "backup_album_selection_page_assets_scatter": "Egy elem több albumban is lehet. Ezért a mentéshez albumokat lehet hozzáadni vagy azokat a mentésből kihagyni.", @@ -58,13 +58,13 @@ "backup_album_selection_page_selection_info": "Összegzés", "backup_album_selection_page_total_assets": "Összes egyedi elem", "backup_all": "Összes", - "backup_background_service_backup_failed_message": "HIba a mentés közben. Újrapróbálkozás...", - "backup_background_service_connection_failed_message": "HIba a szerverhez való csatlakozás közben. Újrapróbálkozás...", + "backup_background_service_backup_failed_message": "Hiba a mentés közben. Újrapróbálkozás...", + "backup_background_service_connection_failed_message": "Hiba a szerverhez való csatlakozás közben. Újrapróbálkozás...", "backup_background_service_current_upload_notification": "Feltöltés {}", "backup_background_service_default_notification": "Új elemek keresése...", "backup_background_service_error_title": "Hiba mentés közben", - "backup_background_service_in_progress_notification": "Elemek mentés alatt..", - "backup_background_service_upload_failure_notification": "Hiba feltöltés közben {}", + "backup_background_service_in_progress_notification": "Elemek mentése folyamatban…", + "backup_background_service_upload_failure_notification": "Hiba a feltöltés közben {}", "backup_controller_page_albums": "Albumok Mentése", "backup_controller_page_background_app_refresh_disabled_content": "Engedélyezd a háttérben történő frissítést a Beállítások > Általános > Háttérben Frissítés menüpontban.", "backup_controller_page_background_app_refresh_disabled_title": "Háttérben frissítés kikapcsolva", @@ -78,9 +78,9 @@ "backup_controller_page_background_delay": "Új elemek mentésének késleltetése: {}", "backup_controller_page_background_description": "Kapcsold be a háttérfolyamatot, hogy automatikusan mentsen elemeket az applikáció megnyitása nélkül", "backup_controller_page_background_is_off": "Automatikus mentés a háttérben ki van kapcsolva", - "backup_controller_page_background_is_on": "Automatikus mentés a háttérben bekapcsolva", - "backup_controller_page_background_turn_off": "Háttérfolyamat kikapcsolása", - "backup_controller_page_background_turn_on": "Háttérfolyamat bekapcsolása", + "backup_controller_page_background_is_on": "Automatikus mentés a háttérben be van kapcsolva", + "backup_controller_page_background_turn_off": "Háttérszolgáltatás kikapcsolása", + "backup_controller_page_background_turn_on": "Háttérszolgáltatás bekapcsolása", "backup_controller_page_background_wifi": "Csak WiFi-n", "backup_controller_page_backup": "Mentés", "backup_controller_page_backup_selected": "Kiválasztva:", @@ -92,15 +92,15 @@ "backup_controller_page_failed": "Sikertelen ({})", "backup_controller_page_filename": "Fájlnév: {}[{}]", "backup_controller_page_id": "Azonosító: {}", - "backup_controller_page_info": "Mentésinformációk", + "backup_controller_page_info": "Mentési Információk", "backup_controller_page_none_selected": "Egy sincs kiválasztva", "backup_controller_page_remainder": "Hátralévő", "backup_controller_page_remainder_sub": "Hátralévő fotók és videók a kijelöltek közül", "backup_controller_page_select": "Kiválaszt", "backup_controller_page_server_storage": "Szerver Tárhely", - "backup_controller_page_start_backup": "Mentés Elindítása", - "backup_controller_page_status_off": "Automatikus mentés az előtérben kikapcsolva", - "backup_controller_page_status_on": "Automatikus mentés az előtérben bekapcsolva", + "backup_controller_page_start_backup": "Mentés Indítása", + "backup_controller_page_status_off": "Automatikus mentés az előtérben ki van kapcsolva", + "backup_controller_page_status_on": "Automatikus mentés az előtérben be van kapcsolva", "backup_controller_page_storage_format": "{} / {} felhasználva", "backup_controller_page_to_backup": "Mentésre kijelölt albumok", "backup_controller_page_total": "Összesen", @@ -115,15 +115,15 @@ "backup_manual_in_progress": "Feltöltés már folyamatban. Próbáld meg később", "backup_manual_success": "Sikeres", "backup_manual_title": "Feltöltés állapota", - "backup_options_page_title": "Backup options", - "cache_settings_album_thumbnails": "Library page thumbnails ({} assets)", + "backup_options_page_title": "Biztonági mentés beállításai", + "cache_settings_album_thumbnails": "Képtár oldalankénti bélyegképei ({} elem)", "cache_settings_clear_cache_button": "Gyorsítótár kiürítése", - "cache_settings_clear_cache_button_title": "Clears the app's cache. This will significantly impact the app's performance until the cache has rebuilt.", + "cache_settings_clear_cache_button_title": "Kiüríti az alkalmazás gyorsítótárát. Ez jelentősen kihat az alkalmazás teljesítményére, amíg a gyorsítótár újra nem épül.", "cache_settings_duplicated_assets_clear_button": "KIÜRÍT", "cache_settings_duplicated_assets_subtitle": "Fotók és videók, amiket az alkalmazás fekete listára tett", "cache_settings_duplicated_assets_title": "Duplikált Elemek ({})", "cache_settings_image_cache_size": "Kép gyorsítótár mérete ({} elem)", - "cache_settings_statistics_album": "Mappa bélyegképei", + "cache_settings_statistics_album": "Képtár bélyegképei", "cache_settings_statistics_assets": "{} elem ({})", "cache_settings_statistics_full": "Teljes méretű képek", "cache_settings_statistics_shared": "Megosztott album bélyegképei", @@ -135,20 +135,20 @@ "cache_settings_tile_title": "Helyi Tárhely", "cache_settings_title": "Gyorsítótár Beállítások", "change_password_form_confirm_password": "Jelszó Megerősítése", - "change_password_form_description": "Kedves {name}!\n\nMost jelentkezel be először a rendszerbe vagy más okból szükséges a jelszavad meváltoztatása. Kérjük, add meg új jelszavad.", + "change_password_form_description": "Szia {name}!\n\nMost jelentkezel be először a rendszerbe vagy más okból szükséges a jelszavad meváltoztatása. Kérjük, add meg új jelszavad.", "change_password_form_new_password": "Új Jelszó", - "change_password_form_password_mismatch": "A két beírt jelszó nem egyezik", + "change_password_form_password_mismatch": "A beírt jelszavak nem egyeznek", "change_password_form_reenter_new_password": "Jelszó (még egyszer)", "common_add_to_album": "Albumhoz ad", "common_change_password": "Jelszócsere", "common_create_new_album": "Új album létrehozása", "common_server_error": "Kérjük, ellenőrizd a hálózati kapcsolatot, gondoskodj róla, hogy a szerver elérhető legyen, valamint az alkalmazás és a szerver kompatibilis verziójú legyen.", "common_shared": "Megosztva", - "control_bottom_app_bar_add_to_album": "Hozzáadás az albumhoz", + "control_bottom_app_bar_add_to_album": "Albumhoz ad", "control_bottom_app_bar_album_info": "{} elem", - "control_bottom_app_bar_album_info_shared": "{} elemek· Megosztva", + "control_bottom_app_bar_album_info_shared": "{} elemek · Megosztva", "control_bottom_app_bar_archive": "Archivál", - "control_bottom_app_bar_create_new_album": "Album létrehozása", + "control_bottom_app_bar_create_new_album": "Új album létrehozása", "control_bottom_app_bar_delete": "Törlés", "control_bottom_app_bar_delete_from_immich": "Törlés az Immich-ből", "control_bottom_app_bar_delete_from_local": "Törlés az eszközről", @@ -157,7 +157,7 @@ "control_bottom_app_bar_favorite": "Kedvenc", "control_bottom_app_bar_share": "Megosztás", "control_bottom_app_bar_share_to": "Megosztás Ide", - "control_bottom_app_bar_stack": "Stack", + "control_bottom_app_bar_stack": "Fotók csoportosítása", "control_bottom_app_bar_trash_from_immich": "Lomtárba Helyez", "control_bottom_app_bar_unarchive": "Nem Archivált", "control_bottom_app_bar_unfavorite": "Nem Kedvenc", @@ -169,93 +169,93 @@ "create_shared_album_page_share_select_photos": "Fotók választása", "curated_location_page_title": "Helyek", "curated_object_page_title": "Dolgok", - "daily_title_text_date": "E, MMM dd", - "daily_title_text_date_year": "E, MMM dd, yyyy", - "date_format": "E, LLL d, y • h:mm a", + "daily_title_text_date": "MMM dd (E)", + "daily_title_text_date_year": "yyyy MMM dd (E)", + "date_format": "y LLL d (E) • HH:mm", "delete_dialog_alert": "Ezek az elemek véglegesen törölve lesznek Immich-ről és az eszközödről is", - "delete_dialog_alert_local": "These items will be permanently removed from your device but still be available on the Immich server", - "delete_dialog_alert_local_non_backed_up": "Some of the items aren't backed up to Immich and will be permanently removed from your device", - "delete_dialog_alert_remote": "These items will be permanently deleted from the Immich server", - "delete_dialog_cancel": "Mégse", + "delete_dialog_alert_local": "Ezek az elemek véglegesen törölve lesznek az eszközödről, de továbbra is elérhetőek maradnak az Immich szerveren", + "delete_dialog_alert_local_non_backed_up": "Néhány elem nem lett elmentve az Immich szerverre és most véglegesen törölve lesznek az eszközödről is", + "delete_dialog_alert_remote": "Ezek az elemek véglegesen törlésre kerülnek az Immich szerverről", + "delete_dialog_cancel": "Mégsem", "delete_dialog_ok": "Törlés", - "delete_dialog_ok_force": "Delete Anyway", - "delete_dialog_title": "Törlés véglegesen", - "delete_local_dialog_ok_backed_up_only": "Delete Backed Up Only", - "delete_local_dialog_ok_force": "Delete Anyway", - "delete_shared_link_dialog_content": "Are you sure you want to delete this shared link?", - "delete_shared_link_dialog_title": "Delete Shared Link", + "delete_dialog_ok_force": "Törlés Mindenképp", + "delete_dialog_title": "Végleges Törlés", + "delete_local_dialog_ok_backed_up_only": "Csak a Biztonsági Mentés Törlése", + "delete_local_dialog_ok_force": "Törlés Mindenképp", + "delete_shared_link_dialog_content": "Biztos, hogy törlöd ezt a megosztott linket?", + "delete_shared_link_dialog_title": "Megosztott Link Törlése", "description_input_hint_text": "Leírás hozzáadása...", "description_input_submit_error": "Nem sikerült frissíteni a leírást. További információért kérjük, nézd meg az eseménynaplót", - "edit_date_time_dialog_date_time": "Date and Time", - "edit_date_time_dialog_timezone": "Timezone", - "edit_location_dialog_title": "Location", - "exif_bottom_sheet_description": "Leírás hozzáadása...", + "edit_date_time_dialog_date_time": "Dátum és Idő", + "edit_date_time_dialog_timezone": "Időzóna", + "edit_location_dialog_title": "Hely", + "exif_bottom_sheet_description": "Leírás Hozzáadása...", "exif_bottom_sheet_details": "RÉSZLETEK", - "exif_bottom_sheet_location": "HELYSZÍN", - "exif_bottom_sheet_location_add": "Add a location", - "exif_bottom_sheet_people": "PEOPLE", - "exif_bottom_sheet_person_add_person": "Add name", + "exif_bottom_sheet_location": "HELY", + "exif_bottom_sheet_location_add": "Hely hozzáadása", + "exif_bottom_sheet_people": "EMBEREK", + "exif_bottom_sheet_person_add_person": "Név hozzáadása", "experimental_settings_new_asset_list_subtitle": "Fejlesztés alatt", - "experimental_settings_new_asset_list_title": "Enable experimental photo grid", - "experimental_settings_subtitle": "Csak saját felelősségre használd", + "experimental_settings_new_asset_list_title": "Kisérleti képrács engedélyezése", + "experimental_settings_subtitle": "Csak saját felelősségre használd!", "experimental_settings_title": "Kísérleti", - "favorites_page_no_favorites": "Nem található kedvencnek jelölt média", + "favorites_page_no_favorites": "Nem található kedvencnek jelölt elem", "favorites_page_title": "Kedvencek", - "haptic_feedback_switch": "Enable haptic feedback", - "haptic_feedback_title": "Haptic Feedback", - "home_page_add_to_album_conflicts": "Added {added} assets to album {album}. {failed} assets are already in the album.", - "home_page_add_to_album_err_local": "Helyi médiát még nem lehet albumba tenni. Kihagyjuk.", - "home_page_add_to_album_success": "Added {added} assets to album {album}.", - "home_page_album_err_partner": "Can not add partner assets to an album yet, skipping", - "home_page_archive_err_local": "Helyi média archiválása még nem támogatott, úgyhogy kihagyjuk", - "home_page_archive_err_partner": "Can not archive partner assets, skipping", - "home_page_building_timeline": "Building the timeline", - "home_page_delete_err_partner": "Can not delete partner assets, skipping", - "home_page_delete_remote_err_local": "Local assets in delete remote selection, skipping", - "home_page_favorite_err_local": "Helyi médiát még nem lehet a kedvencek közé tenni. Kihagyjuk.", - "home_page_favorite_err_partner": "Can not favorite partner assets yet, skipping", - "home_page_first_time_notice": "If this is your first time using the app, please make sure to choose a backup album(s) so that the timeline can populate photos and videos in the album(s).", - "home_page_share_err_local": "Can not share local assets via link, skipping", - "home_page_upload_err_limit": "Csak 30 elemet tudsz egyszerre feltölteni, átugrás", + "haptic_feedback_switch": "Rezgéses visszajelzés engedélyezése", + "haptic_feedback_title": "Rezgéses Visszajelzés", + "home_page_add_to_album_conflicts": "{added} elem hozzáadva a(z) \"{album}\" albumhoz. {failed} elem már eleve az albumban volt.", + "home_page_add_to_album_err_local": "Helyi elemeket még nem lehet albumba tenni. Kihagyjuk.", + "home_page_add_to_album_success": "{added} elem hozzáadva a(z) \"{album}\" albumhoz.", + "home_page_album_err_partner": "Még nem lehet a partner elemeit albumokhoz adni, úghogy kihagyjuk.", + "home_page_archive_err_local": "Helyi elemek archiválása még nem támogatott, úgyhogy kihagyjuk", + "home_page_archive_err_partner": "Partner elemeit nem lehet archiválni, úgyhogy kihagyjuk", + "home_page_building_timeline": "Idővonal összeállítása", + "home_page_delete_err_partner": "Partner elemeit nem lehet törölni, úgyhogy kihagyjuk", + "home_page_delete_remote_err_local": "Helyi elemek vannak távoli törlésre kiválasztva, úgyhogy ezeket kihagyjuk", + "home_page_favorite_err_local": "Helyi elemeket még nem lehet a kedvencek közé tenni, úgyhogy ezeket kihagyjuk", + "home_page_favorite_err_partner": "Partner elemeit még nem lehet a kedvencek közé tenni, úgyhogy ezeket kihagyjuk", + "home_page_first_time_notice": "Ha most használod először az alkalmazást, akkor ahhoz, hogy megjelenjenek a fotók és a videók az idővonaladon, állítsd be, hogy melyik albumaidról készüljön biztonsági mentés.", + "home_page_share_err_local": "Helyi elemekről nem lehet megosztási linket készíteni, úgyhogy kihagyjuk", + "home_page_upload_err_limit": "Csak 30 elemet tudsz egyszerre feltölteni, úgyhogy kihagyjuk", "image_viewer_page_state_provider_download_error": "Letöltési Hiba", - "image_viewer_page_state_provider_download_started": "Download Started", + "image_viewer_page_state_provider_download_started": "Letöltés Megkezdődött", "image_viewer_page_state_provider_download_success": "Letöltés Sikeres", - "image_viewer_page_state_provider_share_error": "Share Error", + "image_viewer_page_state_provider_share_error": "Megosztási Hiba", "library_page_albums": "Albumok", "library_page_archive": "Archívum", "library_page_device_albums": "Albumok az Eszközön", "library_page_favorites": "Kedvencek", "library_page_new_album": "Új album", - "library_page_sharing": "Megosztás\n", - "library_page_sort_asset_count": "Number of assets", - "library_page_sort_created": "Legutoljára létrehozott", - "library_page_sort_last_modified": "Last modified", - "library_page_sort_most_oldest_photo": "Oldest photo", - "library_page_sort_most_recent_photo": "Most recent photo", + "library_page_sharing": "Megosztás", + "library_page_sort_asset_count": "Eszközök száma", + "library_page_sort_created": "Létrehozás ideje", + "library_page_sort_last_modified": "Utolsó módosítás ideje", + "library_page_sort_most_oldest_photo": "Legrégebbi fotó", + "library_page_sort_most_recent_photo": "Legújabb fotó", "library_page_sort_title": "Album címe", - "location_picker_choose_on_map": "Choose on map", - "location_picker_latitude": "Latitude", - "location_picker_latitude_error": "Enter a valid latitude", - "location_picker_latitude_hint": "Enter your latitude here", - "location_picker_longitude": "Longitude", - "location_picker_longitude_error": "Enter a valid longitude", - "location_picker_longitude_hint": "Enter your longitude here", + "location_picker_choose_on_map": "Válassz a térképen", + "location_picker_latitude": "Szélességi kör", + "location_picker_latitude_error": "Érvényes szélességi kört írj be", + "location_picker_latitude_hint": "Ide írd a szélességi kört", + "location_picker_longitude": "Hosszúsági kör", + "location_picker_longitude_error": "Érvényes hosszúsági kört írj be", + "location_picker_longitude_hint": "Ide írd a hosszúsági kört", "login_disabled": "A bejelentkezés letiltva", "login_form_api_exception": "API hiba. Kérljük, ellenőrid a szerver címét, majd próbáld újra.", - "login_form_back_button_text": "Back", + "login_form_back_button_text": "Vissza", "login_form_button_text": "Bejelentkezés", "login_form_email_hint": "email@cimed.hu", - "login_form_endpoint_hint": "http://szerver-címe:port/api", + "login_form_endpoint_hint": "http(s)://szerver-címe:port/api", "login_form_endpoint_url": "Szerver címe", - "login_form_err_http": "Kérem, adjon meg egy http:// vagy https:// címet", + "login_form_err_http": "Kérjük, adj meg egy http:// vagy https:// címet", "login_form_err_invalid_email": "Érvénytelen email cím", "login_form_err_invalid_url": "Érvénytelen cím", "login_form_err_leading_whitespace": "Az első karakter szóköz", "login_form_err_trailing_whitespace": "Az utolsó karakter szóköz", - "login_form_failed_get_oauth_server_config": "Error logging using OAuth, check server URL", - "login_form_failed_get_oauth_server_disable": "OAuth feature is not available on this server", + "login_form_failed_get_oauth_server_config": "Nem sikerült az OAuth bejelentkezés. Ellenőrizd a szerver címét.", + "login_form_failed_get_oauth_server_disable": "OAuth bejelentkezés nem elérhető ezen a szerveren", "login_form_failed_login": "Hiba bejelentkezés közben, ellenőrizd a címet, email-t és a jelszót", - "login_form_handshake_exception": "There was an Handshake Exception with the server. Enable self-signed certificate support in the settings if you are using a self-signed certificate.", + "login_form_handshake_exception": "SSL Kézfogási Hiba törént. Engedélyezd az önaláírt tanúsítvényokat a beállításokban, hogy ha önaláírt tanúsítványt használsz.", "login_form_label_email": "Email", "login_form_label_password": "Jelszó", "login_form_next_button": "Következő", @@ -263,61 +263,61 @@ "login_form_save_login": "Maradjon bejelentkezve", "login_form_server_empty": "Add meg a szerver címét.", "login_form_server_error": "Nem sikerült kapcsolódni a szerverhez.", - "login_password_changed_error": "There was an error updating your password", - "login_password_changed_success": "Password updated successfully", - "map_assets_in_bound": "{} photo", - "map_assets_in_bounds": "{} photos", - "map_cannot_get_user_location": "Cannot get user's location", - "map_location_dialog_cancel": "Cancel", - "map_location_dialog_yes": "Yes", - "map_location_picker_page_use_location": "Use this location", - "map_location_service_disabled_content": "Location service needs to be enabled to display assets from your current location. Do you want to enable it now?", - "map_location_service_disabled_title": "Location Service disabled", - "map_no_assets_in_bounds": "No photos in this area", - "map_no_location_permission_content": "Location permission is needed to display assets from your current location. Do you want to allow it now?", - "map_no_location_permission_title": "Location Permission denied", - "map_settings_dark_mode": "Dark mode", - "map_settings_date_range_option_all": "All", - "map_settings_date_range_option_day": "Past 24 hours", - "map_settings_date_range_option_days": "Past {} days", - "map_settings_date_range_option_year": "Past year", - "map_settings_date_range_option_years": "Past {} years", - "map_settings_dialog_cancel": "Cancel", - "map_settings_dialog_save": "Save", - "map_settings_dialog_title": "Map Settings", - "map_settings_include_show_archived": "Include Archived", - "map_settings_include_show_partners": "Include Partners", - "map_settings_only_relative_range": "Date range", - "map_settings_only_show_favorites": "Show Favorite Only", - "map_settings_theme_settings": "Map Theme", - "map_zoom_to_see_photos": "Zoom out to see photos", - "memories_all_caught_up": "All caught up", - "memories_check_back_tomorrow": "Check back tomorrow for more memories", - "memories_start_over": "Start Over", - "memories_swipe_to_close": "Swipe up to close", - "monthly_title_text_date_format": "MMMM y", + "login_password_changed_error": "Nem sikerült módosítani a jelszót", + "login_password_changed_success": "Jelszó sikeresen módosítva", + "map_assets_in_bound": "{} fotó", + "map_assets_in_bounds": "{} fotó", + "map_cannot_get_user_location": "A helymeghatározás nem sikerült", + "map_location_dialog_cancel": "Mégsem", + "map_location_dialog_yes": "Igen", + "map_location_picker_page_use_location": "Kiválasztott hely használata", + "map_location_service_disabled_content": "A helymeghatározás szolgáltatást engedélyezni kell a jelenlegi helyednél lévő elemek megjelenítéséhez. Szeretnéd most engedélyezni?", + "map_location_service_disabled_title": "Helymeghatározás Szolgáltatás letiltva", + "map_no_assets_in_bounds": "Nincsenek fotók a környéken", + "map_no_location_permission_content": "A helymeghatározást engedélyezni kell a jelenlegi helyednél lévő elemek megjelenítéséhez. Szeretnéd most engedélyezni?", + "map_no_location_permission_title": "Helymeghatározás letiltva", + "map_settings_dark_mode": "Sötét mód", + "map_settings_date_range_option_all": "Összes", + "map_settings_date_range_option_day": "Elmúlt 24 óra", + "map_settings_date_range_option_days": "Elmúlt {} nap", + "map_settings_date_range_option_year": "Elmúlt év", + "map_settings_date_range_option_years": "Elmúlt {} év", + "map_settings_dialog_cancel": "Mégsem", + "map_settings_dialog_save": "Mentés", + "map_settings_dialog_title": "Térkép Beállítások", + "map_settings_include_show_archived": "Archívokkal Együtt", + "map_settings_include_show_partners": "Partnerével Együtt", + "map_settings_only_relative_range": "Dátum intervallum", + "map_settings_only_show_favorites": "Csak Kedvencek Mutatása", + "map_settings_theme_settings": "Térkép Témája", + "map_zoom_to_see_photos": "Kicsinyíts, hogy láss fényképeket", + "memories_all_caught_up": "Naprakész vagy", + "memories_check_back_tomorrow": "Nézz vissza holnap újabb emlékekért", + "memories_start_over": "Újrakezdés", + "memories_swipe_to_close": "Bezáráshoz söpörd ki felfelé", + "monthly_title_text_date_format": "y MMMM", "motion_photos_page_title": "Mozgó Fotók", - "multiselect_grid_edit_date_time_err_read_only": "Cannot edit date of read only asset(s), skipping", - "multiselect_grid_edit_gps_err_read_only": "Cannot edit location of read only asset(s), skipping", - "no_assets_to_show": "No assets to show", + "multiselect_grid_edit_date_time_err_read_only": "Csak-olvasható elem(ek) dátuma nem módosítható, ezért kihagyjuk", + "multiselect_grid_edit_gps_err_read_only": "Csak-olvasható elem(ek) helyszíne nem módosítható, ezért kihagyjuk", + "no_assets_to_show": "Nincs megjeleníthető elem", "notification_permission_dialog_cancel": "Mégsem", "notification_permission_dialog_content": "Az értesítések bekapcsolásához a Beállítások menüben válaszd ki az Engedélyezés-t.", "notification_permission_dialog_settings": "Beállítások", "notification_permission_list_tile_content": "Értesítések engedélyezése", "notification_permission_list_tile_enable_button": "Értesítések Bekapcsolása", "notification_permission_list_tile_title": "Engedély az Értesítésekhez", - "partner_list_user_photos": "{user}'s photos", - "partner_list_view_all": "View all", - "partner_page_add_partner": "Add partner", - "partner_page_empty_message": "Your photos are not yet shared with any partner.", - "partner_page_no_more_users": "No more users to add", - "partner_page_partner_add_failed": "Failed to add partner", - "partner_page_select_partner": "Select partner", - "partner_page_shared_to_title": "Shared to", - "partner_page_stop_sharing_content": "{} will no longer be able to access your photos.", - "partner_page_stop_sharing_title": "Stop sharing your photos?", + "partner_list_user_photos": "{user} fényképei", + "partner_list_view_all": "Összes mutatása", + "partner_page_add_partner": "Partner hozzáadása", + "partner_page_empty_message": "Még senkivel nem osztottad meg a fényképeidet.", + "partner_page_no_more_users": "Nincs hozzáadható felhasználó", + "partner_page_partner_add_failed": "Nem sikerült hozzáadni a felhasználót", + "partner_page_select_partner": "Partner kiválasztása", + "partner_page_shared_to_title": "Megosztva: ", + "partner_page_stop_sharing_content": "{} nem fog többé hozzáférni a fotóidhoz.", + "partner_page_stop_sharing_title": "Fotók megosztásának megszűntetése?", "partner_page_title": "Partner", - "permission_onboarding_back": "Back", + "permission_onboarding_back": "Vissza", "permission_onboarding_continue_anyway": "Folytatás mindenképp", "permission_onboarding_get_started": "Kezdjük el", "permission_onboarding_go_to_settings": "Beállítások megnyitása", @@ -327,47 +327,47 @@ "permission_onboarding_permission_granted": "Hozzáférés engedélyezve! Minden készen áll.", "permission_onboarding_permission_limited": "Korlátozott hozzáférés. Ha szeretnéd, hogy az Immich a teljes galéria gyűjteményedet mentse és kezelje, akkor a Beállításokban engedélyezd a fotó és videó jogosultságokat.", "permission_onboarding_request": "Engedélyezni kell, hogy az Immich hozzáférjen a képekhez és videókhoz", - "preferences_settings_title": "Preferences", + "preferences_settings_title": "Beállítások", "profile_drawer_app_logs": "Naplók", - "profile_drawer_client_out_of_date_major": "Mobile App is out of date. Please update to the latest major version.", - "profile_drawer_client_out_of_date_minor": "Mobile App is out of date. Please update to the latest minor version.", + "profile_drawer_client_out_of_date_major": "A mobilalkalmazás elavult. Kérjük, frissítsd a legfrisebb főverzióra.", + "profile_drawer_client_out_of_date_minor": "A mobilalkalmazás elavult. Kérjük, frissítsd a legfrisebb alverzióra.", "profile_drawer_client_server_up_to_date": "Kliens és a szerver is naprakész", - "profile_drawer_documentation": "Documentation", + "profile_drawer_documentation": "Dokumentáció", "profile_drawer_github": "GitHub", - "profile_drawer_server_out_of_date_major": "Server is out of date. Please update to the latest major version.", - "profile_drawer_server_out_of_date_minor": "Server is out of date. Please update to the latest minor version.", + "profile_drawer_server_out_of_date_major": "A szerver elavult. Kérjük, frissítsd a legfrisebb főverzióra.", + "profile_drawer_server_out_of_date_minor": "A szerver elavult. Kérjük, frissítsd a legfrisebb alverzióra.", "profile_drawer_settings": "Beállítások", "profile_drawer_sign_out": "Kijelentkezés", - "profile_drawer_trash": "Trash", + "profile_drawer_trash": "Lomtár", "recently_added_page_title": "Nemrég Hozzáadott", - "scaffold_body_error_occurred": "Error occurred", - "search_bar_hint": "Keress a fotóid között", - "search_filter_apply": "Apply filter", - "search_filter_camera_make": "Make", - "search_filter_camera_model": "Model", - "search_filter_display_option_archive": "Archive", - "search_filter_display_option_favorite": "Favorite", - "search_filter_display_option_not_in_album": "Not in album", - "search_filter_location_city": "City", - "search_filter_location_country": "Country", - "search_filter_location_state": "State", - "search_filter_media_type_all": "All", - "search_filter_media_type_image": "Image", - "search_filter_media_type_video": "Video", + "scaffold_body_error_occurred": "Hiba történt", + "search_bar_hint": "Fotók keresése", + "search_filter_apply": "Szűrő alkalmazása", + "search_filter_camera_make": "Gyártó", + "search_filter_camera_model": "Modell", + "search_filter_display_option_archive": "Archivált", + "search_filter_display_option_favorite": "Kedvenc", + "search_filter_display_option_not_in_album": "Nincs albumban", + "search_filter_location_city": "Város", + "search_filter_location_country": "Ország", + "search_filter_location_state": "Állam", + "search_filter_media_type_all": "Összes", + "search_filter_media_type_image": "Kép", + "search_filter_media_type_video": "Videó", "search_page_categories": "Kategóriák", "search_page_favorites": "Kedvencek", "search_page_motion_photos": "Mozgó Fotók", - "search_page_no_objects": "No Objects Info Available", - "search_page_no_places": "Helyinformáció nem érhető el", + "search_page_no_objects": "Nincs Információ a Tárgyakról", + "search_page_no_places": "Nincs Információ a Helyszínekről", "search_page_people": "Emberek", - "search_page_person_add_name_dialog_cancel": "Cancel", - "search_page_person_add_name_dialog_hint": "Name", - "search_page_person_add_name_dialog_save": "Save", - "search_page_person_add_name_dialog_title": "Add a name", - "search_page_person_add_name_subtitle": "Find them fast by name with search", - "search_page_person_add_name_title": "Add a name", - "search_page_person_edit_name": "Edit name", - "search_page_places": "Helyszínek", + "search_page_person_add_name_dialog_cancel": "Mégsem", + "search_page_person_add_name_dialog_hint": "Név", + "search_page_person_add_name_dialog_save": "Mentés", + "search_page_person_add_name_dialog_title": "Név hozzáadása", + "search_page_person_add_name_subtitle": "Név szerint gyorsan megtalálhatod a keresőben", + "search_page_person_add_name_title": "Név hozzáadása", + "search_page_person_edit_name": "Név módosítása", + "search_page_places": "Helyek", "search_page_recently_added": "Nemrég hozzáadott", "search_page_screenshots": "Képernyőképek", "search_page_selfies": "Szelfik", @@ -375,7 +375,7 @@ "search_page_videos": "Videók", "search_page_view_all_button": "Összes mutatása", "search_page_your_activity": "Tevékenységeid", - "search_page_your_map": "Your Map", + "search_page_your_map": "Térképed", "search_result_page_new_search_hint": "Új keresés", "search_suggestion_list_smart_search_hint_1": "Az intelligens keresés alapértelmezetten be van kapcsolva, metaadatokat így kereshetsz", "search_suggestion_list_smart_search_hint_2": "m:keresési-kifejezés", @@ -383,137 +383,138 @@ "select_user_for_sharing_page_err_album": "Nem sikerült létrehozni az albumot", "select_user_for_sharing_page_share_suggestions": "Javaslatok", "server_info_box_app_version": "Alkalmazás Verzió", - "server_info_box_latest_release": "Latest Version", - "server_info_box_server_url": "Server URL", + "server_info_box_latest_release": "Legfrissebb Verzió", + "server_info_box_server_url": "Szerver Címe", "server_info_box_server_version": "Szerver Verzió", - "setting_image_viewer_help": "The detail viewer loads the small thumbnail first, then loads the medium-size preview (if enabled), finally loads the original (if enabled).", - "setting_image_viewer_original_subtitle": "Enable to load the original full-resolution image (large!). Disable to reduce data usage (both network and on device cache).", - "setting_image_viewer_original_title": "Load original image", - "setting_image_viewer_preview_subtitle": "Enable to load a medium-resolution image. Disable to either directly load the original or only use the thumbnail.", - "setting_image_viewer_preview_title": "Load preview image", - "setting_languages_apply": "Apply", - "setting_languages_title": "Languages", - "setting_notifications_notify_failures_grace_period": "Notify background backup failures: {}", + "setting_image_viewer_help": "A képnézegető először a kis bélyegképet tölti be, aztán a közepes méretű előnézetet (ha elérhető), végül az eredetit (ha elérhető).", + "setting_image_viewer_original_subtitle": "Engedélyezi az eredeti teljes felbontású kép betöltését (nagy!). Kikapcsolva csökkenti az adathasználatot (a neten és az eszköz gyorsítótárán is).", + "setting_image_viewer_original_title": "Eredeti kép betöltése", + "setting_image_viewer_preview_subtitle": "Engedélyezi a közepes felbontású kép betöltését. Kikapcsolva vagy az eredeti kép töltődik be, vagy csak a bélyegkép.", + "setting_image_viewer_preview_title": "Előnézet betöltése", + "setting_languages_apply": "Alkalmaz", + "setting_languages_title": "Nyelvek", + "setting_notifications_notify_failures_grace_period": "Értesítés a háttérben történő mentés hibáiról: {}", "setting_notifications_notify_hours": "{} óra", "setting_notifications_notify_immediately": "azonnal", "setting_notifications_notify_minutes": "{} perc", "setting_notifications_notify_never": "soha", "setting_notifications_notify_seconds": "{} másodperc", - "setting_notifications_single_progress_subtitle": "Detailed upload progress information per asset", - "setting_notifications_single_progress_title": "Show background backup detail progress", - "setting_notifications_subtitle": "Adjust your notification preferences", + "setting_notifications_single_progress_subtitle": "Részletes feltöltési folyamat információ minden elemről", + "setting_notifications_single_progress_title": "Mutassa a háttérben történő mentés részletes folyamatát", + "setting_notifications_subtitle": "Értesítési beállítások módosítása", "setting_notifications_title": "Értesítések", - "setting_notifications_total_progress_subtitle": "Overall upload progress (done/total assets)", - "setting_notifications_total_progress_title": "Show background backup total progress", + "setting_notifications_total_progress_subtitle": "Átfogó feltöltési folyamat (kész/összes elem)", + "setting_notifications_total_progress_title": "Mutassa a háttérben történő mentés teljes folyamatát", "setting_pages_app_bar_settings": "Beállítások", - "settings_require_restart": "Kérlek indítsd újra az Immich-et hogy alkalmazd ezt a beállítást", + "settings_require_restart": "Ennek a beállításnak az érvénybe lépéséhez indítsd újra az Immich-et", "share_add": "Hozzáadás", "share_add_photos": "Fotók hozzáadása", "share_add_title": "Album neve", + "share_assets_selected": "{} kiválasztva", "share_create_album": "Album létrehozása", - "shared_album_activities_input_disable": "Comment is disabled", - "shared_album_activities_input_hint": "Say something", - "shared_album_activity_remove_content": "Do you want to delete this activity?", - "shared_album_activity_remove_title": "Delete Activity", - "shared_album_activity_setting_subtitle": "Let others respond", - "shared_album_activity_setting_title": "Comments & likes", - "shared_album_section_people_action_error": "Error leaving/removing from album", - "shared_album_section_people_action_leave": "Remove user from album", - "shared_album_section_people_action_remove_user": "Remove user from album", - "shared_album_section_people_owner_label": "Owner", - "shared_album_section_people_title": "PEOPLE", + "shared_album_activities_input_disable": "Hozzászólások kikapcsolva", + "shared_album_activities_input_hint": "Szólj hozzá", + "shared_album_activity_remove_content": "Törölni szeretnéd ezt a tevékenységet?", + "shared_album_activity_remove_title": "Tevékenység Törlése", + "shared_album_activity_setting_subtitle": "Engedd, hogy mások reagáljanak", + "shared_album_activity_setting_title": "Hozzászólások és lájkok", + "shared_album_section_people_action_error": "Hiba az albummal kapcsolatos kilépés/eltávolítás közben", + "shared_album_section_people_action_leave": "Felhasználó eltávolítása az albumból", + "shared_album_section_people_action_remove_user": "Felhasználó eltávolítása az albumból", + "shared_album_section_people_owner_label": "Tulajdonos", + "shared_album_section_people_title": "EMBEREK", "share_dialog_preparing": "Előkészítés...", - "shared_link_app_bar_title": "Shared Links", - "shared_link_clipboard_copied_massage": "Copied to clipboard", - "shared_link_clipboard_text": "Link: {}\nPassword: {}", - "shared_link_create_app_bar_title": "Create link to share", - "shared_link_create_error": "Error while creating shared link", - "shared_link_create_info": "Let anyone with the link see the selected photo(s)", - "shared_link_create_submit_button": "Create link", - "shared_link_edit_allow_download": "Allow public user to download", - "shared_link_edit_allow_upload": "Allow public user to upload", - "shared_link_edit_app_bar_title": "Edit link", - "shared_link_edit_change_expiry": "Change expiration time", - "shared_link_edit_description": "Description", - "shared_link_edit_description_hint": "Enter the share description", - "shared_link_edit_expire_after": "Expire after", - "shared_link_edit_expire_after_option_day": "1 day", - "shared_link_edit_expire_after_option_days": "{} days", - "shared_link_edit_expire_after_option_hour": "1 hour", - "shared_link_edit_expire_after_option_hours": "{} hours", - "shared_link_edit_expire_after_option_minute": "1 minute", - "shared_link_edit_expire_after_option_minutes": "{} minutes", - "shared_link_edit_expire_after_option_months": "{} months", - "shared_link_edit_expire_after_option_never": "Never", - "shared_link_edit_expire_after_option_year": "{} year", - "shared_link_edit_password": "Password", - "shared_link_edit_password_hint": "Enter the share password", - "shared_link_edit_show_meta": "Show metadata", - "shared_link_edit_submit_button": "Update link", - "shared_link_empty": "You don't have any shared links", - "shared_link_error_server_url_fetch": "Cannot fetch the server url", - "shared_link_expired": "Expired", - "shared_link_expires_day": "Expires in {} day", - "shared_link_expires_days": "Expires in {} days", - "shared_link_expires_hour": "Expires in {} hour", - "shared_link_expires_hours": "Expires in {} hours", - "shared_link_expires_minute": "Expires in {} minute", - "shared_link_expires_minutes": "Expires in {} minutes", - "shared_link_expires_never": "Expires ∞", - "shared_link_expires_second": "Expires in {} second", - "shared_link_expires_seconds": "Expires in {} seconds", - "shared_link_individual_shared": "Individual shared", - "shared_link_info_chip_download": "Download", + "shared_link_app_bar_title": "Megosztott Linkek", + "shared_link_clipboard_copied_massage": "Vágólapra másolva", + "shared_link_clipboard_text": "Link: {}\nJelszó: {}", + "shared_link_create_app_bar_title": "Megosztási link létrehozása", + "shared_link_create_error": "Hiba a megosztási link létrehozásakor", + "shared_link_create_info": "A linket használva bárki megnézheti a kiválasztott kép(ek)et", + "shared_link_create_submit_button": "Link létrehozása", + "shared_link_edit_allow_download": "Letöltés engedélyezése", + "shared_link_edit_allow_upload": "Feltöltés engedélyezése", + "shared_link_edit_app_bar_title": "Link módosítása", + "shared_link_edit_change_expiry": "Lejárati idő megváltoztatása", + "shared_link_edit_description": "Leírás", + "shared_link_edit_description_hint": "Add meg a megosztás leírását", + "shared_link_edit_expire_after": "Lejárati idő", + "shared_link_edit_expire_after_option_day": "1 nap", + "shared_link_edit_expire_after_option_days": "{} nap", + "shared_link_edit_expire_after_option_hour": "1 óra", + "shared_link_edit_expire_after_option_hours": "{} óra", + "shared_link_edit_expire_after_option_minute": "1 perc", + "shared_link_edit_expire_after_option_minutes": "{} perc", + "shared_link_edit_expire_after_option_months": "{} hónap", + "shared_link_edit_expire_after_option_never": "Soha", + "shared_link_edit_expire_after_option_year": "{} év", + "shared_link_edit_password": "Jelszó", + "shared_link_edit_password_hint": "Add meg a megosztási jelszót", + "shared_link_edit_show_meta": "Metaadatok mutatása", + "shared_link_edit_submit_button": "Link frissítése", + "shared_link_empty": "Nincsenek megosztási linkek", + "shared_link_error_server_url_fetch": "A szerver címét nem sikerült betölteni", + "shared_link_expired": "Lejárt", + "shared_link_expires_day": "{} nap múlva lejár", + "shared_link_expires_days": "{} nap múlva lejár", + "shared_link_expires_hour": "{} óra múlva lejár", + "shared_link_expires_hours": "{} óra múlva lejár", + "shared_link_expires_minute": "{} perc múlva lejár", + "shared_link_expires_minutes": "{} perc múlva lejár", + "shared_link_expires_never": "Nem jár le", + "shared_link_expires_second": "{} másodperc múlva lejár", + "shared_link_expires_seconds": "{} másodperc múlva lejár", + "shared_link_individual_shared": "Egyénileg megosztva", + "shared_link_info_chip_download": "Letöltés", "shared_link_info_chip_metadata": "EXIF", - "shared_link_info_chip_upload": "Upload", - "shared_link_manage_links": "Manage Shared links", - "shared_link_public_album": "Public album", - "share_done": "Done", + "shared_link_info_chip_upload": "Feltöltés", + "shared_link_manage_links": "Megosztási linkek kezelése", + "shared_link_public_album": "Nyilvános album", + "share_done": "Kész", "share_invite": "Meghívás az albumba", "sharing_page_album": "Megosztott albumok", - "sharing_page_description": "Megosztott albumok létrehozásával fényképeket és videókatoszthatsz meg a hálózatodban lévő emberekkel.", + "sharing_page_description": "Megosztott albumok létrehozásával fényképeket és videókat oszthatsz meg a hálózatodban lévő emberekkel.", "sharing_page_empty_list": "ÜRES LISTA", - "sharing_silver_appbar_create_shared_album": "Megosztott album létrehozása", - "sharing_silver_appbar_shared_links": "Shared links", + "sharing_silver_appbar_create_shared_album": "Új megosztott album", + "sharing_silver_appbar_shared_links": "Megosztási linkek", "sharing_silver_appbar_share_partner": "Megosztás partnerrel", - "tab_controller_nav_library": "Könyvtár", + "tab_controller_nav_library": "Képtár", "tab_controller_nav_photos": "Képek", "tab_controller_nav_search": "Keresés", "tab_controller_nav_sharing": "Megosztás", - "theme_setting_asset_list_storage_indicator_title": "Show storage indicator on asset tiles", - "theme_setting_asset_list_tiles_per_row_title": "Number of assets per row ({})", + "theme_setting_asset_list_storage_indicator_title": "Tárhely ikon mutatása az elemeken", + "theme_setting_asset_list_tiles_per_row_title": "Elemek száma soronként ({})", "theme_setting_dark_mode_switch": "Sötét mód", - "theme_setting_image_viewer_quality_subtitle": "Adjust the quality of the detail image viewer", - "theme_setting_image_viewer_quality_title": "Image viewer quality", + "theme_setting_image_viewer_quality_subtitle": "Részletes képmegjelenítő minőségének beállítása", + "theme_setting_image_viewer_quality_title": "Képmegjelenítő minősége", "theme_setting_system_theme_switch": "Automatikus (követi a rendszer témáját)", - "theme_setting_theme_subtitle": "Choose the app's theme setting", + "theme_setting_theme_subtitle": "Alkalmazás témájának választása", "theme_setting_theme_title": "Téma", - "theme_setting_three_stage_loading_subtitle": "Three-stage loading might increase the loading performance but causes significantly higher network load", - "theme_setting_three_stage_loading_title": "Enable three-stage loading", - "translated_text_options": "Options", - "trash_page_delete": "Delete", - "trash_page_delete_all": "Delete All", - "trash_page_empty_trash_btn": "Empty trash", - "trash_page_empty_trash_dialog_content": "Do you want to empty your trashed assets? These items will be permanently removed from Immich", + "theme_setting_three_stage_loading_subtitle": "A háromlépcsős betöltés javíthatja a betöltési teljesítményt, de jelentősen növeli a hálózati forgalmat", + "theme_setting_three_stage_loading_title": "Háromlépcsős betöltés engedélyezése", + "translated_text_options": "Beállítások", + "trash_page_delete": "Töröl", + "trash_page_delete_all": "Mindet Töröl", + "trash_page_empty_trash_btn": "Lomtár Ürítése", + "trash_page_empty_trash_dialog_content": "Ki szeretnéd üríteni a lomtárban lévő elemeket? Ezeket véglegesen eltávolítjuk az Immich-ből", "trash_page_empty_trash_dialog_ok": "Ok", - "trash_page_info": "Trashed items will be permanently deleted after {} days", - "trash_page_no_assets": "No trashed assets", - "trash_page_restore": "Restore", - "trash_page_restore_all": "Restore All", - "trash_page_select_assets_btn": "Select assets", - "trash_page_select_btn": "Select", - "trash_page_title": "Trash ({})", - "upload_dialog_cancel": "Mégse", - "upload_dialog_info": "Akarod menteni a kiválasztott eleme(ke)t a szerverre?", + "trash_page_info": "A Lomátrba helyezett elemek {} nap után véglegesen törlődnek", + "trash_page_no_assets": "Nincsen semmi a Lomtárban", + "trash_page_restore": "Visszaállít", + "trash_page_restore_all": "Mindet Visszaállítja", + "trash_page_select_assets_btn": "Elemek kiválasztása", + "trash_page_select_btn": "Kiválaszt", + "trash_page_title": "Lomtár ({})", + "upload_dialog_cancel": "Mégsem", + "upload_dialog_info": "Szeretnél mentést készíteni a kiválasztott elem(ek)ről a szerverre?", "upload_dialog_ok": "Feltöltés", - "upload_dialog_title": "Elem feltöltése", + "upload_dialog_title": "Elem Feltöltése", "version_announcement_overlay_ack": "Megértettem", "version_announcement_overlay_release_notes": "a változtatások listáját elolvasd", "version_announcement_overlay_text_1": "Szia, egy új verzió érhető el", "version_announcement_overlay_text_2": "kérlek szánj időt arra, hogy ", - "version_announcement_overlay_text_3": "és gyöződj meg róla, hogy a docker-compose és .env beállításai naprakészek és pontosak, különösen akkor, ha használsz watchtower-t vagy bármi olyan megoldást ami automatikusan frissíti a szervert.", - "version_announcement_overlay_title": "Új szerververzió érhető el \uD83C\uDF89", - "viewer_remove_from_stack": "Remove from Stack", - "viewer_stack_use_as_main_asset": "Use as Main Asset", - "viewer_unstack": "Un-Stack" + "version_announcement_overlay_text_3": "és gyöződj meg róla, hogy a docker-compose és .env beállításai naprakészek és pontosak, különösen akkor, ha watchtower-t vagy bármi olyan megoldást használsz, ami automatikusan frissíti a szervert.", + "version_announcement_overlay_title": "Új Szerververzió Érhető El \uD83C\uDF89", + "viewer_remove_from_stack": "Eltávolít a Csoportból", + "viewer_stack_use_as_main_asset": "Fő Elemnek Beállít", + "viewer_unstack": "Csoport Megszűntetése" } \ No newline at end of file diff --git a/mobile/assets/i18n/it-IT.json b/mobile/assets/i18n/it-IT.json index b30ce5dc95..dbd93587fb 100644 --- a/mobile/assets/i18n/it-IT.json +++ b/mobile/assets/i18n/it-IT.json @@ -410,6 +410,7 @@ "share_add": "Aggiungi", "share_add_photos": "Aggiungi foto", "share_add_title": "Aggiungi un titolo ", + "share_assets_selected": "{} selected", "share_create_album": "Crea album", "shared_album_activities_input_disable": "I commenti sono disabilitati", "shared_album_activities_input_hint": "Dici qualcosa", diff --git a/mobile/assets/i18n/ja-JP.json b/mobile/assets/i18n/ja-JP.json index 2cad0ff929..da482d75cb 100644 --- a/mobile/assets/i18n/ja-JP.json +++ b/mobile/assets/i18n/ja-JP.json @@ -218,7 +218,7 @@ "home_page_share_err_local": "ローカルのみの項目をリンクで共有はできません。スキップします", "home_page_upload_err_limit": "1回でアップロードできる写真の数は30枚です。スキップします", "image_viewer_page_state_provider_download_error": "ダウンロード失敗", - "image_viewer_page_state_provider_download_started": "Download Started", + "image_viewer_page_state_provider_download_started": "ダウンロードが始まります", "image_viewer_page_state_provider_download_success": "ダウンロード成功", "image_viewer_page_state_provider_share_error": "共有エラー", "library_page_albums": "アルバム", @@ -410,6 +410,7 @@ "share_add": "追加", "share_add_photos": "写真を追加", "share_add_title": "タイトルを追加", + "share_assets_selected": "{}選択されました", "share_create_album": "アルバムを作成", "shared_album_activities_input_disable": "コメントはオフになってます", "shared_album_activities_input_hint": "何か書き込みましょう", diff --git a/mobile/assets/i18n/ko-KR.json b/mobile/assets/i18n/ko-KR.json index d505533ee7..bea52a3476 100644 --- a/mobile/assets/i18n/ko-KR.json +++ b/mobile/assets/i18n/ko-KR.json @@ -218,7 +218,7 @@ "home_page_share_err_local": "링크를 통해 로컬 미디어를 공유할 수 없으므로 건너뜁니다", "home_page_upload_err_limit": "한번에 최대 30개의 미디어만 업로드할 수 있습니다", "image_viewer_page_state_provider_download_error": "다운로드 에러", - "image_viewer_page_state_provider_download_started": "Download Started", + "image_viewer_page_state_provider_download_started": "다운로드 시작", "image_viewer_page_state_provider_download_success": "다운로드 완료", "image_viewer_page_state_provider_share_error": "공유 오류", "library_page_albums": "앨범", @@ -410,6 +410,7 @@ "share_add": "추가", "share_add_photos": "사진 추가", "share_add_title": "새 앨범제목", + "share_assets_selected": "{} 선택됨", "share_create_album": "앨범 만들기", "shared_album_activities_input_disable": "댓글이 비활성화되었습니다.", "shared_album_activities_input_hint": "말하기", diff --git a/mobile/assets/i18n/lt-LT.json b/mobile/assets/i18n/lt-LT.json index 379deed82d..c968c603f7 100644 --- a/mobile/assets/i18n/lt-LT.json +++ b/mobile/assets/i18n/lt-LT.json @@ -410,6 +410,7 @@ "share_add": "Add", "share_add_photos": "Add photos", "share_add_title": "Add a title", + "share_assets_selected": "{} selected", "share_create_album": "Create album", "shared_album_activities_input_disable": "Comment is disabled", "shared_album_activities_input_hint": "Say something", diff --git a/mobile/assets/i18n/lv-LV.json b/mobile/assets/i18n/lv-LV.json index 61f5631478..2379306d9d 100644 --- a/mobile/assets/i18n/lv-LV.json +++ b/mobile/assets/i18n/lv-LV.json @@ -410,6 +410,7 @@ "share_add": "Pievienot", "share_add_photos": "Pievienot fotoattēlus", "share_add_title": "Pievienot virsrakstu", + "share_assets_selected": "{} selected", "share_create_album": "Izveidot albumu", "shared_album_activities_input_disable": "Comment is disabled", "shared_album_activities_input_hint": "Say something", diff --git a/mobile/assets/i18n/mn.json b/mobile/assets/i18n/mn.json index 37603e8d1b..66e6e0f723 100644 --- a/mobile/assets/i18n/mn.json +++ b/mobile/assets/i18n/mn.json @@ -410,6 +410,7 @@ "share_add": "Add", "share_add_photos": "Add photos", "share_add_title": "Add a title", + "share_assets_selected": "{} selected", "share_create_album": "Create album", "shared_album_activities_input_disable": "Comment is disabled", "shared_album_activities_input_hint": "Say something", diff --git a/mobile/assets/i18n/nb-NO.json b/mobile/assets/i18n/nb-NO.json index 994220a72f..32862e2ecf 100644 --- a/mobile/assets/i18n/nb-NO.json +++ b/mobile/assets/i18n/nb-NO.json @@ -410,6 +410,7 @@ "share_add": "Legg til", "share_add_photos": "Legg til bilder", "share_add_title": "Legg til tittel", + "share_assets_selected": "{} valgt", "share_create_album": "Opprett album", "shared_album_activities_input_disable": "Kommenterer er deaktivert", "shared_album_activities_input_hint": "Si noe", diff --git a/mobile/assets/i18n/nl-NL.json b/mobile/assets/i18n/nl-NL.json index 2b1dc7847e..283a060a70 100644 --- a/mobile/assets/i18n/nl-NL.json +++ b/mobile/assets/i18n/nl-NL.json @@ -410,6 +410,7 @@ "share_add": "Toevoegen", "share_add_photos": "Foto's toevoegen", "share_add_title": "Titel toevoegen", + "share_assets_selected": "{} geselecteerd", "share_create_album": "Album aanmaken", "shared_album_activities_input_disable": "Reactie is uitgeschakeld", "shared_album_activities_input_hint": "Zeg iets", diff --git a/mobile/assets/i18n/pl-PL.json b/mobile/assets/i18n/pl-PL.json index f4d17a4601..a517467400 100644 --- a/mobile/assets/i18n/pl-PL.json +++ b/mobile/assets/i18n/pl-PL.json @@ -410,6 +410,7 @@ "share_add": "Dodaj", "share_add_photos": "Dodaj zdjęcia", "share_add_title": "Dodaj tytuł", + "share_assets_selected": "{} selected", "share_create_album": "Utwórz album", "shared_album_activities_input_disable": "Komentarz jest wyłączony", "shared_album_activities_input_hint": "Powiedz coś", diff --git a/mobile/assets/i18n/pt-PT.json b/mobile/assets/i18n/pt-PT.json index 99942749af..646758c39c 100644 --- a/mobile/assets/i18n/pt-PT.json +++ b/mobile/assets/i18n/pt-PT.json @@ -410,6 +410,7 @@ "share_add": "Adicionar", "share_add_photos": "Adicionar fotos", "share_add_title": "Adicione um título", + "share_assets_selected": "{} selected", "share_create_album": "Criar álbum", "shared_album_activities_input_disable": "Comentários desativados", "shared_album_activities_input_hint": "Dizer alguma coisa", diff --git a/mobile/assets/i18n/ro-RO.json b/mobile/assets/i18n/ro-RO.json index 838f42c425..032e46fcaf 100644 --- a/mobile/assets/i18n/ro-RO.json +++ b/mobile/assets/i18n/ro-RO.json @@ -410,6 +410,7 @@ "share_add": "Adaugă", "share_add_photos": "Adaugă fotografii", "share_add_title": "Adaugă un titlu", + "share_assets_selected": "{} selected", "share_create_album": "Creează album", "shared_album_activities_input_disable": "Cometariile sunt dezactivate", "shared_album_activities_input_hint": "Spune ceva", diff --git a/mobile/assets/i18n/ru-RU.json b/mobile/assets/i18n/ru-RU.json index 80ceee10f1..2ccada2c76 100644 --- a/mobile/assets/i18n/ru-RU.json +++ b/mobile/assets/i18n/ru-RU.json @@ -410,6 +410,7 @@ "share_add": "Добавить", "share_add_photos": "Добавить фото", "share_add_title": "Добавить название", + "share_assets_selected": "{} выбрано", "share_create_album": "Создать альбом", "shared_album_activities_input_disable": "Комментирование отключено", "shared_album_activities_input_hint": "Скажите что-нибудь", diff --git a/mobile/assets/i18n/sk-SK.json b/mobile/assets/i18n/sk-SK.json index d3cdd32e83..ffdcc2dffe 100644 --- a/mobile/assets/i18n/sk-SK.json +++ b/mobile/assets/i18n/sk-SK.json @@ -410,6 +410,7 @@ "share_add": "Pridať", "share_add_photos": "Pridať fotografie", "share_add_title": "Pridať názov", + "share_assets_selected": "{} selected", "share_create_album": "Vytvoriť album", "shared_album_activities_input_disable": "Komentár je zakázaný", "shared_album_activities_input_hint": "Napíšte niečo", diff --git a/mobile/assets/i18n/sl-SI.json b/mobile/assets/i18n/sl-SI.json index 439a5da16f..254e385bf3 100644 --- a/mobile/assets/i18n/sl-SI.json +++ b/mobile/assets/i18n/sl-SI.json @@ -410,6 +410,7 @@ "share_add": "Dodaj", "share_add_photos": "Dodaj fotografije", "share_add_title": "Dodaj naslov", + "share_assets_selected": "{} selected", "share_create_album": "Ustvari album", "shared_album_activities_input_disable": "Komentiranje je onemogočeno", "shared_album_activities_input_hint": "Reci kaj", diff --git a/mobile/assets/i18n/sr-Cyrl.json b/mobile/assets/i18n/sr-Cyrl.json index 379deed82d..c968c603f7 100644 --- a/mobile/assets/i18n/sr-Cyrl.json +++ b/mobile/assets/i18n/sr-Cyrl.json @@ -410,6 +410,7 @@ "share_add": "Add", "share_add_photos": "Add photos", "share_add_title": "Add a title", + "share_assets_selected": "{} selected", "share_create_album": "Create album", "shared_album_activities_input_disable": "Comment is disabled", "shared_album_activities_input_hint": "Say something", diff --git a/mobile/assets/i18n/sr-Latn.json b/mobile/assets/i18n/sr-Latn.json index 0f7615550e..3ae3f63498 100644 --- a/mobile/assets/i18n/sr-Latn.json +++ b/mobile/assets/i18n/sr-Latn.json @@ -410,6 +410,7 @@ "share_add": "Dodaj", "share_add_photos": "Dodaj fotografije", "share_add_title": "Dodaj naslov", + "share_assets_selected": "{} selected", "share_create_album": "Napravi album", "shared_album_activities_input_disable": "Comment is disabled", "shared_album_activities_input_hint": "Say something", diff --git a/mobile/assets/i18n/sv-FI.json b/mobile/assets/i18n/sv-FI.json index 379deed82d..c968c603f7 100644 --- a/mobile/assets/i18n/sv-FI.json +++ b/mobile/assets/i18n/sv-FI.json @@ -410,6 +410,7 @@ "share_add": "Add", "share_add_photos": "Add photos", "share_add_title": "Add a title", + "share_assets_selected": "{} selected", "share_create_album": "Create album", "shared_album_activities_input_disable": "Comment is disabled", "shared_album_activities_input_hint": "Say something", diff --git a/mobile/assets/i18n/sv-SE.json b/mobile/assets/i18n/sv-SE.json index 5cd119f7e3..710045f5da 100644 --- a/mobile/assets/i18n/sv-SE.json +++ b/mobile/assets/i18n/sv-SE.json @@ -410,6 +410,7 @@ "share_add": "Lägg till", "share_add_photos": "Lägg till foton", "share_add_title": "Lägg till en titel", + "share_assets_selected": "{} selected", "share_create_album": "Skapa album", "shared_album_activities_input_disable": "Comment is disabled", "shared_album_activities_input_hint": "Say something", diff --git a/mobile/assets/i18n/th-TH.json b/mobile/assets/i18n/th-TH.json index 5166d90561..815182942f 100644 --- a/mobile/assets/i18n/th-TH.json +++ b/mobile/assets/i18n/th-TH.json @@ -410,6 +410,7 @@ "share_add": "เพิ่ม", "share_add_photos": "เพิ่มรูปภาพ", "share_add_title": "เพิ่มชื่อ", + "share_assets_selected": "{} selected", "share_create_album": "สร้างอัลบั้ม", "shared_album_activities_input_disable": "คอมเมนต์ถูกปิด", "shared_album_activities_input_hint": "พูดอะไรสักอย่าง", diff --git a/mobile/assets/i18n/uk-UA.json b/mobile/assets/i18n/uk-UA.json index 851092f7ec..11dafc0ce8 100644 --- a/mobile/assets/i18n/uk-UA.json +++ b/mobile/assets/i18n/uk-UA.json @@ -410,6 +410,7 @@ "share_add": "Додати", "share_add_photos": "Додати знімки", "share_add_title": "Додати назву", + "share_assets_selected": "{} обрано", "share_create_album": "Створити альбом", "shared_album_activities_input_disable": "Коментування вимкнено", "shared_album_activities_input_hint": "Скажіть що-небудь", diff --git a/mobile/assets/i18n/vi-VN.json b/mobile/assets/i18n/vi-VN.json index 975da42be0..fc569d2795 100644 --- a/mobile/assets/i18n/vi-VN.json +++ b/mobile/assets/i18n/vi-VN.json @@ -410,6 +410,7 @@ "share_add": "Thêm", "share_add_photos": "Thêm ảnh", "share_add_title": "Thêm tiêu đề", + "share_assets_selected": "{} selected", "share_create_album": "Tạo album", "shared_album_activities_input_disable": "Nhận xét hiện đã tắt", "shared_album_activities_input_hint": "Nói điều gì đó", diff --git a/mobile/assets/i18n/zh-CN.json b/mobile/assets/i18n/zh-CN.json index 157e7f500b..6d57bb26b1 100644 --- a/mobile/assets/i18n/zh-CN.json +++ b/mobile/assets/i18n/zh-CN.json @@ -410,6 +410,7 @@ "share_add": "添加", "share_add_photos": "添加项目", "share_add_title": "添加标题", + "share_assets_selected": "{}已选择", "share_create_album": "创建相册", "shared_album_activities_input_disable": "评论已禁用", "shared_album_activities_input_hint": "说些什么", diff --git a/mobile/assets/i18n/zh-Hans.json b/mobile/assets/i18n/zh-Hans.json index 671c953dd1..4e459141dc 100644 --- a/mobile/assets/i18n/zh-Hans.json +++ b/mobile/assets/i18n/zh-Hans.json @@ -410,6 +410,7 @@ "share_add": "添加", "share_add_photos": "添加项目", "share_add_title": "添加标题", + "share_assets_selected": "{}已选择", "share_create_album": "创建相册", "shared_album_activities_input_disable": "评论已禁用", "shared_album_activities_input_hint": "说些什么", diff --git a/mobile/assets/i18n/zh-TW.json b/mobile/assets/i18n/zh-TW.json index 379deed82d..c968c603f7 100644 --- a/mobile/assets/i18n/zh-TW.json +++ b/mobile/assets/i18n/zh-TW.json @@ -410,6 +410,7 @@ "share_add": "Add", "share_add_photos": "Add photos", "share_add_title": "Add a title", + "share_assets_selected": "{} selected", "share_create_album": "Create album", "shared_album_activities_input_disable": "Comment is disabled", "shared_album_activities_input_hint": "Say something", From a6f557c24c5e9afee57c0458e4cff8e079d04ab7 Mon Sep 17 00:00:00 2001 From: Alex The Bot Date: Mon, 13 May 2024 18:31:57 +0000 Subject: [PATCH 023/163] Version v1.104.0 --- cli/package-lock.json | 4 ++-- e2e/package-lock.json | 6 +++--- 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 +- 16 files changed, 24 insertions(+), 24 deletions(-) diff --git a/cli/package-lock.json b/cli/package-lock.json index ba4c08cbc6..fcddabde9b 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -47,14 +47,14 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.103.1", + "version": "1.104.0", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { "@oazapfts/runtime": "^1.0.2" }, "devDependencies": { - "@types/node": "^20.12.8", + "@types/node": "^20.11.0", "typescript": "^5.3.3" } }, diff --git a/e2e/package-lock.json b/e2e/package-lock.json index e01a570197..28bbe286d0 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich-e2e", - "version": "1.103.1", + "version": "1.104.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich-e2e", - "version": "1.103.1", + "version": "1.104.0", "license": "GNU Affero General Public License version 3", "devDependencies": { "@immich/cli": "file:../cli", @@ -81,7 +81,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.103.1", + "version": "1.104.0", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { diff --git a/e2e/package.json b/e2e/package.json index 9ae68f7dbb..de2b8cd4b6 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -1,6 +1,6 @@ { "name": "immich-e2e", - "version": "1.103.1", + "version": "1.104.0", "description": "", "main": "index.js", "type": "module", diff --git a/machine-learning/pyproject.toml b/machine-learning/pyproject.toml index 645d9b07c4..46d812b726 100644 --- a/machine-learning/pyproject.toml +++ b/machine-learning/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "machine-learning" -version = "1.103.1" +version = "1.104.0" description = "" authors = ["Hau Tran "] readme = "README.md" diff --git a/mobile/android/fastlane/Fastfile b/mobile/android/fastlane/Fastfile index 6b50ca60ca..5d8ecd5481 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" => 137, - "android.injected.version.name" => "1.103.1", + "android.injected.version.code" => 138, + "android.injected.version.name" => "1.104.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 9e5c3018bd..82ea740ffc 100644 --- a/mobile/ios/fastlane/Fastfile +++ b/mobile/ios/fastlane/Fastfile @@ -19,7 +19,7 @@ platform :ios do desc "iOS Beta" lane :beta do increment_version_number( - version_number: "1.103.1" + version_number: "1.104.0" ) increment_build_number( build_number: latest_testflight_build_number + 1, diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index c98745430c..3326aaf9ba 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.103.1 +- API version: 1.104.0 - Build package: org.openapitools.codegen.languages.DartClientCodegen ## Requirements diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 847256dbbb..fc854b3375 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.103.1+137 +version: 1.104.0+138 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 8cfa31c3c6..266c9ed489 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -6446,7 +6446,7 @@ "info": { "title": "Immich", "description": "Immich API", - "version": "1.103.1", + "version": "1.104.0", "contact": {} }, "tags": [], diff --git a/open-api/typescript-sdk/package-lock.json b/open-api/typescript-sdk/package-lock.json index ebc087ec0e..0c9c8e2e63 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.103.1", + "version": "1.104.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@immich/sdk", - "version": "1.103.1", + "version": "1.104.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 5f11a18da5..d573bd7332 100644 --- a/open-api/typescript-sdk/package.json +++ b/open-api/typescript-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@immich/sdk", - "version": "1.103.1", + "version": "1.104.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 2db110fa15..676dfe9439 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.103.1 + * 1.104.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 2807f331f0..0a7afe8e74 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich", - "version": "1.103.1", + "version": "1.104.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "immich", - "version": "1.103.1", + "version": "1.104.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 02f0f39e61..089dc824d1 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "immich", - "version": "1.103.1", + "version": "1.104.0", "description": "", "author": "", "private": true, diff --git a/web/package-lock.json b/web/package-lock.json index 3257e723b5..241e083336 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich-web", - "version": "1.103.1", + "version": "1.104.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich-web", - "version": "1.103.1", + "version": "1.104.0", "license": "GNU Affero General Public License version 3", "dependencies": { "@immich/sdk": "file:../open-api/typescript-sdk", @@ -65,7 +65,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.103.1", + "version": "1.104.0", "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 45660b4052..28cff7f43d 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "immich-web", - "version": "1.103.1", + "version": "1.104.0", "license": "GNU Affero General Public License version 3", "scripts": { "dev": "vite dev --host 0.0.0.0 --port 3000", From 5985f72643a6dd847f0e5154d2518b4dd193d972 Mon Sep 17 00:00:00 2001 From: Alex Tran Date: Mon, 13 May 2024 14:17:28 -0500 Subject: [PATCH 024/163] chore: post release tasks --- mobile/android/fastlane/report.xml | 6 +++--- mobile/ios/Runner.xcodeproj/project.pbxproj | 6 +++--- mobile/ios/Runner/Info.plist | 4 ++-- mobile/ios/fastlane/report.xml | 12 ++++++------ 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/mobile/android/fastlane/report.xml b/mobile/android/fastlane/report.xml index 358fb9618c..57f0a68b66 100644 --- a/mobile/android/fastlane/report.xml +++ b/mobile/android/fastlane/report.xml @@ -5,17 +5,17 @@ - + - + - + diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index f473cd0250..59dba8f69b 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -383,7 +383,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 155; + CURRENT_PROJECT_VERSION = 156; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -525,7 +525,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 155; + CURRENT_PROJECT_VERSION = 156; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -553,7 +553,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 155; + CURRENT_PROJECT_VERSION = 156; 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 a30399428a..e74de3b774 100644 --- a/mobile/ios/Runner/Info.plist +++ b/mobile/ios/Runner/Info.plist @@ -58,11 +58,11 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.103.0 + 1.104.0 CFBundleSignature ???? CFBundleVersion - 155 + 156 FLTEnableImpeller ITSAppUsesNonExemptEncryption diff --git a/mobile/ios/fastlane/report.xml b/mobile/ios/fastlane/report.xml index f23fb703e5..43e5e01c26 100644 --- a/mobile/ios/fastlane/report.xml +++ b/mobile/ios/fastlane/report.xml @@ -5,32 +5,32 @@ - + - + - + - + - + - + From b7ebf3152f55cdfaa44908d5a87c10247592dbfb Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Mon, 13 May 2024 16:03:36 -0400 Subject: [PATCH 025/163] fix(web): show w x h correctly when media is rotated (#9435) --- .../components/asset-viewer/detail-panel.svelte | 16 +++++++++++++--- web/src/lib/utils/asset-utils.ts | 8 ++++++-- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/web/src/lib/components/asset-viewer/detail-panel.svelte b/web/src/lib/components/asset-viewer/detail-panel.svelte index 5c466208dd..653d5903d2 100644 --- a/web/src/lib/components/asset-viewer/detail-panel.svelte +++ b/web/src/lib/components/asset-viewer/detail-panel.svelte @@ -8,7 +8,7 @@ import { user } from '$lib/stores/user.store'; import { websocketEvents } from '$lib/stores/websocket'; import { getAssetThumbnailUrl, getPeopleThumbnailUrl, isSharedLink, handlePromiseError } from '$lib/utils'; - import { delay } from '$lib/utils/asset-utils'; + import { delay, isFlipped } from '$lib/utils/asset-utils'; import { autoGrowHeight } from '$lib/utils/autogrow'; import { clickOutside } from '$lib/utils/click-outside'; import { @@ -17,6 +17,7 @@ updateAsset, type AlbumResponseDto, type AssetResponseDto, + type ExifResponseDto, } from '@immich/sdk'; import { mdiCalendar, @@ -47,6 +48,15 @@ export let albums: AlbumResponseDto[] = []; export let currentAlbum: AlbumResponseDto | null = null; + const getDimensions = (exifInfo: ExifResponseDto) => { + const { exifImageWidth: width, exifImageHeight: height } = exifInfo; + if (isFlipped(exifInfo.orientation)) { + return { width: height, height: width }; + } + + return { width, height }; + }; + let showAssetPath = false; let textArea: HTMLTextAreaElement; let description: string; @@ -410,8 +420,8 @@ {getMegapixel(asset.exifInfo.exifImageHeight, asset.exifInfo.exifImageWidth)} MP

{/if} - -

{asset.exifInfo.exifImageHeight} x {asset.exifInfo.exifImageWidth}

+ {@const { width, height } = getDimensions(asset.exifInfo)} +

{width} x {height}

{/if}

{asByteUnitString(asset.exifInfo.fileSizeInByte, $locale)}

diff --git a/web/src/lib/utils/asset-utils.ts b/web/src/lib/utils/asset-utils.ts index 5a69e6eea1..bda0bb6ffe 100644 --- a/web/src/lib/utils/asset-utils.ts +++ b/web/src/lib/utils/asset-utils.ts @@ -218,14 +218,18 @@ function isRotated270CW(orientation: number) { return orientation === 7 || orientation === 8 || orientation === -90; } +export function isFlipped(orientation?: string | null) { + const value = Number(orientation); + return value && (isRotated270CW(value) || isRotated90CW(value)); +} + /** * Returns aspect ratio for the asset */ export function getAssetRatio(asset: AssetResponseDto) { let height = asset.exifInfo?.exifImageHeight || 235; let width = asset.exifInfo?.exifImageWidth || 235; - const orientation = Number(asset.exifInfo?.orientation); - if (orientation && (isRotated90CW(orientation) || isRotated270CW(orientation))) { + if (isFlipped(asset.exifInfo?.orientation)) { [width, height] = [height, width]; } return { width, height }; From 1bebc7368c3ba9be705a9cddfafc23eb60a826d2 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Mon, 13 May 2024 16:38:11 -0400 Subject: [PATCH 026/163] fix(server): regenerate (extract) motion videos (#9438) --- server/src/services/metadata.service.spec.ts | 7 ++++++- server/src/services/metadata.service.ts | 17 ++++++++++------- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index c0758eeeaf..fefd40becb 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -459,10 +459,14 @@ describe(MetadataService.name, () => { storageMock.readFile.mockResolvedValue(video); await sut.handleMetadataExtraction({ id: assetStub.livePhotoStillAsset.id }); - expect(jobMock.queue).toHaveBeenNthCalledWith(2, { + expect(jobMock.queue).toHaveBeenNthCalledWith(1, { name: JobName.ASSET_DELETION, data: { id: assetStub.livePhotoStillAsset.livePhotoVideoId }, }); + expect(jobMock.queue).toHaveBeenNthCalledWith(2, { + name: JobName.METADATA_EXTRACTION, + data: { id: 'random-uuid' }, + }); }); it('should not create a new motion photo video asset if the hash of the extracted video matches an existing asset', async () => { @@ -477,6 +481,7 @@ describe(MetadataService.name, () => { assetMock.getByChecksum.mockResolvedValue(assetStub.livePhotoMotionAsset); const video = randomBytes(512); storageMock.readFile.mockResolvedValue(video); + storageMock.checkFileExists.mockResolvedValue(true); await sut.handleMetadataExtraction({ id: assetStub.livePhotoStillAsset.id }); expect(assetMock.create).toHaveBeenCalledTimes(0); diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index 4b013fb663..0e5395c5bd 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -423,10 +423,7 @@ export class MetadataService { this.logger.log(`Hid unlinked motion photo video asset (${motionAsset.id})`); } } else { - // We create a UUID in advance so that each extracted video can have a unique filename - // (allowing us to delete old ones if necessary) const motionAssetId = this.cryptoRepository.randomUUID(); - const motionPath = StorageCore.getAndroidMotionPath(asset, motionAssetId); const createdAt = asset.fileCreatedAt ?? asset.createdAt; motionAsset = await this.assetRepository.create({ id: motionAssetId, @@ -437,16 +434,13 @@ export class MetadataService { localDateTime: createdAt, checksum, ownerId: asset.ownerId, - originalPath: motionPath, + originalPath: StorageCore.getAndroidMotionPath(asset, motionAssetId), originalFileName: asset.originalFileName, isVisible: false, deviceAssetId: 'NONE', deviceId: 'NONE', }); - this.storageCore.ensureFolders(motionPath); - await this.storageRepository.writeFile(motionAsset.originalPath, video); - await this.jobRepository.queue({ name: JobName.METADATA_EXTRACTION, data: { id: motionAsset.id } }); if (!asset.isExternal) { await this.userRepository.updateUsage(asset.ownerId, video.byteLength); } @@ -465,6 +459,15 @@ export class MetadataService { } } + // write extracted motion video to disk, especially if the encoded-video folder has been deleted + const existsOnDisk = await this.storageRepository.checkFileExists(motionAsset.originalPath); + if (!existsOnDisk) { + this.storageCore.ensureFolders(motionAsset.originalPath); + await this.storageRepository.writeFile(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 } }); + } + this.logger.debug(`Finished motion photo video extraction (${asset.id})`); } catch (error: Error | any) { this.logger.error(`Failed to extract live photo ${asset.originalPath}: ${error}`, error?.stack); From 844f5a16a192676ca2d52a29f9c6032fb89e4bc0 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Mon, 13 May 2024 16:40:16 -0400 Subject: [PATCH 027/163] chore(server): remove unused column (#9431) * chore(server): remove unused column * fix: broken migrations --- server/src/entities/exif.entity.ts | 16 ------- ...1566-MatchMigrationsWithTypeORMEntities.ts | 19 -------- ...470248-AddExifImageNameAsSearchableText.ts | 28 ----------- ...1159594469-RemoveImageNameFromEXIFTable.ts | 30 ------------ .../src/migrations/1700362016675-Geodata.ts | 4 -- .../1708059341865-GeodataLocationSearch.ts | 46 +------------------ .../1715623169039-RemoveTextSearchColumn.ts | 21 +++++++++ server/src/services/metadata.service.ts | 7 ++- server/test/fixtures/shared-link.stub.ts | 1 - 9 files changed, 25 insertions(+), 147 deletions(-) create mode 100644 server/src/migrations/1715623169039-RemoveTextSearchColumn.ts diff --git a/server/src/entities/exif.entity.ts b/server/src/entities/exif.entity.ts index 6f7aafadf4..3461faa685 100644 --- a/server/src/entities/exif.entity.ts +++ b/server/src/entities/exif.entity.ts @@ -98,20 +98,4 @@ export class ExifEntity { /* Video info */ @Column({ type: 'float8', nullable: true }) fps?: number | null; - - @Index('exif_text_searchable', { synchronize: false }) - @Column({ - type: 'tsvector', - generatedType: 'STORED', - select: false, - asExpression: `TO_TSVECTOR('english', - COALESCE(make, '') || ' ' || - COALESCE(model, '') || ' ' || - COALESCE(orientation, '') || ' ' || - COALESCE("lensModel", '') || ' ' || - COALESCE("city", '') || ' ' || - COALESCE("state", '') || ' ' || - COALESCE("country", ''))`, - }) - exifTextSearchableColumn!: string; } diff --git a/server/src/migrations/1656889061566-MatchMigrationsWithTypeORMEntities.ts b/server/src/migrations/1656889061566-MatchMigrationsWithTypeORMEntities.ts index 6bc1e56250..00a66d78e9 100644 --- a/server/src/migrations/1656889061566-MatchMigrationsWithTypeORMEntities.ts +++ b/server/src/migrations/1656889061566-MatchMigrationsWithTypeORMEntities.ts @@ -11,21 +11,6 @@ export class MatchMigrationsWithTypeORMEntities1656889061566 implements Migratio COALESCE("state", '') || ' ' || COALESCE("country", ''))) STORED`); await queryRunner.query(`ALTER TABLE "exif" ALTER COLUMN "exifTextSearchableColumn" SET NOT NULL`); - await queryRunner.query( - `DELETE FROM "typeorm_metadata" WHERE "type" = $1 AND "name" = $2 AND "database" = $3 AND "schema" = $4 AND "table" = $5`, - ['GENERATED_COLUMN', 'exifTextSearchableColumn', 'postgres', 'public', 'exif'], - ); - await queryRunner.query( - `INSERT INTO "typeorm_metadata"("database", "schema", "table", "type", "name", "value") VALUES ($1, $2, $3, $4, $5, $6)`, - [ - 'postgres', - 'public', - 'exif', - 'GENERATED_COLUMN', - 'exifTextSearchableColumn', - "TO_TSVECTOR('english',\n COALESCE(make, '') || ' ' ||\n COALESCE(model, '') || ' ' ||\n COALESCE(orientation, '') || ' ' ||\n COALESCE(\"lensModel\", '') || ' ' ||\n COALESCE(\"city\", '') || ' ' ||\n COALESCE(\"state\", '') || ' ' ||\n COALESCE(\"country\", ''))", - ], - ); await queryRunner.query(`ALTER TABLE "users" ALTER COLUMN "firstName" SET NOT NULL`); await queryRunner.query(`ALTER TABLE "users" ALTER COLUMN "lastName" SET NOT NULL`); await queryRunner.query(`ALTER TABLE "users" ALTER COLUMN "isAdmin" SET NOT NULL`); @@ -51,10 +36,6 @@ export class MatchMigrationsWithTypeORMEntities1656889061566 implements Migratio await queryRunner.query(`ALTER TABLE "users" ALTER COLUMN "isAdmin" DROP NOT NULL`); await queryRunner.query(`ALTER TABLE "users" ALTER COLUMN "lastName" DROP NOT NULL`); await queryRunner.query(`ALTER TABLE "users" ALTER COLUMN "firstName" DROP NOT NULL`); - await queryRunner.query( - `DELETE FROM "typeorm_metadata" WHERE "type" = $1 AND "name" = $2 AND "database" = $3 AND "schema" = $4 AND "table" = $5`, - ['GENERATED_COLUMN', 'exifTextSearchableColumn', 'immich', 'public', 'exif'], - ); await queryRunner.query(`ALTER TABLE "exif" DROP COLUMN "exifTextSearchableColumn"`); await queryRunner.query(`ALTER TABLE "asset_album" DROP CONSTRAINT "FK_7ae4e03729895bf87e056d7b598"`); await queryRunner.query(`ALTER TABLE "asset_album" DROP CONSTRAINT "FK_256a30a03a4a0aff0394051397d"`); diff --git a/server/src/migrations/1658860470248-AddExifImageNameAsSearchableText.ts b/server/src/migrations/1658860470248-AddExifImageNameAsSearchableText.ts index 3dfc655fc6..3b175be3e5 100644 --- a/server/src/migrations/1658860470248-AddExifImageNameAsSearchableText.ts +++ b/server/src/migrations/1658860470248-AddExifImageNameAsSearchableText.ts @@ -5,10 +5,6 @@ export class AddExifImageNameAsSearchableText1658860470248 implements MigrationI public async up(queryRunner: QueryRunner): Promise { await queryRunner.query(`ALTER TABLE "exif" DROP COLUMN "exifTextSearchableColumn"`); - await queryRunner.query( - `DELETE FROM "typeorm_metadata" WHERE "type" = $1 AND "name" = $2 AND "database" = $3 AND "schema" = $4 AND "table" = $5`, - ['GENERATED_COLUMN', 'exifTextSearchableColumn', 'immich', 'public', 'exif'], - ); await queryRunner.query(`ALTER TABLE "exif" ADD "exifTextSearchableColumn" tsvector GENERATED ALWAYS AS (TO_TSVECTOR('english', COALESCE(make, '') || ' ' || COALESCE(model, '') || ' ' || @@ -18,33 +14,9 @@ export class AddExifImageNameAsSearchableText1658860470248 implements MigrationI COALESCE("city", '') || ' ' || COALESCE("state", '') || ' ' || COALESCE("country", ''))) STORED`); - await queryRunner.query( - `DELETE FROM "typeorm_metadata" WHERE "type" = $1 AND "name" = $2 AND "database" = $3 AND "schema" = $4 AND "table" = $5`, - ['GENERATED_COLUMN', 'exifTextSearchableColumn', 'immich', 'public', 'exif'], - ); - await queryRunner.query( - `INSERT INTO "typeorm_metadata"("database", "schema", "table", "type", "name", "value") VALUES ($1, $2, $3, $4, $5, $6)`, - [ - 'immich', - 'public', - 'exif', - 'GENERATED_COLUMN', - 'exifTextSearchableColumn', - "TO_TSVECTOR('english',\n COALESCE(make, '') || ' ' ||\n COALESCE(model, '') || ' ' ||\n COALESCE(orientation, '') || ' ' ||\n COALESCE(\"lensModel\", '') || ' ' ||\n COALESCE(\"imageName\", '') || ' ' ||\n COALESCE(\"city\", '') || ' ' ||\n COALESCE(\"state\", '') || ' ' ||\n COALESCE(\"country\", ''))", - ], - ); } public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query( - `DELETE FROM "typeorm_metadata" WHERE "type" = $1 AND "name" = $2 AND "database" = $3 AND "schema" = $4 AND "table" = $5`, - ['GENERATED_COLUMN', 'exifTextSearchableColumn', 'immich', 'public', 'exif'], - ); - await queryRunner.query(`ALTER TABLE "exif" DROP COLUMN "exifTextSearchableColumn"`); - await queryRunner.query( - `INSERT INTO "typeorm_metadata"("database", "schema", "table", "type", "name", "value") VALUES ($1, $2, $3, $4, $5, $6)`, - ['immich', 'public', 'exif', 'GENERATED_COLUMN', 'exifTextSearchableColumn', ''], - ); await queryRunner.query(`ALTER TABLE "exif" ADD "exifTextSearchableColumn" tsvector NOT NULL`); } } diff --git a/server/src/migrations/1681159594469-RemoveImageNameFromEXIFTable.ts b/server/src/migrations/1681159594469-RemoveImageNameFromEXIFTable.ts index 87bf2a62e6..e188ea3506 100644 --- a/server/src/migrations/1681159594469-RemoveImageNameFromEXIFTable.ts +++ b/server/src/migrations/1681159594469-RemoveImageNameFromEXIFTable.ts @@ -5,10 +5,6 @@ export class RemoveImageNameFromEXIFTable1681159594469 implements MigrationInter public async up(queryRunner: QueryRunner): Promise { await queryRunner.query(`ALTER TABLE "exif" DROP COLUMN IF EXISTS "exifTextSearchableColumn"`); - await queryRunner.query( - `DELETE FROM "typeorm_metadata" WHERE "type" = $1 AND "name" = $2 AND "database" = $3 AND "schema" = $4 AND "table" = $5`, - ['GENERATED_COLUMN', 'exifTextSearchableColumn', 'immich', 'public', 'exif'], - ); await queryRunner.query(`ALTER TABLE "exif" ADD "exifTextSearchableColumn" tsvector GENERATED ALWAYS AS (TO_TSVECTOR('english', COALESCE(make, '') || ' ' || COALESCE(model, '') || ' ' || @@ -17,37 +13,11 @@ export class RemoveImageNameFromEXIFTable1681159594469 implements MigrationInter COALESCE("city", '') || ' ' || COALESCE("state", '') || ' ' || COALESCE("country", ''))) STORED NOT NULL`); - await queryRunner.query( - `INSERT INTO "typeorm_metadata"("database", "schema", "table", "type", "name", "value") VALUES ($1, $2, $3, $4, $5, $6)`, - [ - 'immich', - 'public', - 'exif', - 'GENERATED_COLUMN', - 'exifTextSearchableColumn', - "TO_TSVECTOR('english',\n COALESCE(make, '') || ' ' ||\n COALESCE(model, '') || ' ' ||\n COALESCE(orientation, '') || ' ' ||\n COALESCE(\"lensModel\", '') || ' ' ||\n COALESCE(\"city\", '') || ' ' ||\n COALESCE(\"state\", '') || ' ' ||\n COALESCE(\"country\", ''))", - ], - ); await queryRunner.query(`ALTER TABLE "exif" DROP COLUMN "imageName"`); } public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query( - `DELETE FROM "typeorm_metadata" WHERE "type" = $1 AND "name" = $2 AND "database" = $3 AND "schema" = $4 AND "table" = $5`, - ['GENERATED_COLUMN', 'exifTextSearchableColumn', 'immich', 'public', 'exif'], - ); await queryRunner.query(`ALTER TABLE "exif" DROP COLUMN "exifTextSearchableColumn"`); - await queryRunner.query( - `INSERT INTO "typeorm_metadata"("database", "schema", "table", "type", "name", "value") VALUES ($1, $2, $3, $4, $5, $6)`, - [ - 'immich', - 'public', - 'exif', - 'GENERATED_COLUMN', - 'exifTextSearchableColumn', - "TO_TSVECTOR('english',\n COALESCE(make, '') || ' ' ||\n COALESCE(model, '') || ' ' ||\n COALESCE(orientation, '') || ' ' ||\n COALESCE(\"lensModel\", '') || ' ' ||\n COALESCE(\"imageName\", '') || ' ' ||\n COALESCE(\"city\", '') || ' ' ||\n COALESCE(\"state\", '') || ' ' ||\n COALESCE(\"country\", ''))", - ], - ); await queryRunner.query(`ALTER TABLE "exif" ADD "exifTextSearchableColumn" tsvector GENERATED ALWAYS AS (TO_TSVECTOR('english', COALESCE(make, '') || ' ' || COALESCE(model, '') || ' ' || diff --git a/server/src/migrations/1700362016675-Geodata.ts b/server/src/migrations/1700362016675-Geodata.ts index 1ef562ff7e..fa948e0896 100644 --- a/server/src/migrations/1700362016675-Geodata.ts +++ b/server/src/migrations/1700362016675-Geodata.ts @@ -8,8 +8,6 @@ export class Geodata1700362016675 implements MigrationInterface { await queryRunner.query(`CREATE EXTENSION IF NOT EXISTS earthdistance`) await queryRunner.query(`CREATE TABLE "geodata_admin2" ("key" character varying NOT NULL, "name" character varying NOT NULL, CONSTRAINT "PK_1e3886455dbb684d6f6b4756726" PRIMARY KEY ("key"))`); await queryRunner.query(`CREATE TABLE "geodata_admin1" ("key" character varying NOT NULL, "name" character varying NOT NULL, CONSTRAINT "PK_3fe3a89c5aac789d365871cb172" PRIMARY KEY ("key"))`); - await queryRunner.query(`INSERT INTO "typeorm_metadata"("database", "schema", "table", "type", "name", "value") VALUES ($1, $2, $3, $4, $5, $6)`, ["immich","public","geodata_places","GENERATED_COLUMN","admin1Key","\"countryCode\" || '.' || \"admin1Code\""]); - await queryRunner.query(`INSERT INTO "typeorm_metadata"("database", "schema", "table", "type", "name", "value") VALUES ($1, $2, $3, $4, $5, $6)`, ["immich","public","geodata_places","GENERATED_COLUMN","admin2Key","\"countryCode\" || '.' || \"admin1Code\" || '.' || \"admin2Code\""]); await queryRunner.query(`CREATE TABLE "geodata_places" ("id" integer NOT NULL, "name" character varying(200) NOT NULL, "longitude" double precision NOT NULL, "latitude" double precision NOT NULL, "countryCode" character(2) NOT NULL, "admin1Code" character varying(20), "admin2Code" character varying(80), "admin1Key" character varying GENERATED ALWAYS AS ("countryCode" || '.' || "admin1Code") STORED, "admin2Key" character varying GENERATED ALWAYS AS ("countryCode" || '.' || "admin1Code" || '.' || "admin2Code") STORED, "modificationDate" date NOT NULL, CONSTRAINT "PK_c29918988912ef4036f3d7fbff4" PRIMARY KEY ("id"))`); await queryRunner.query(`ALTER TABLE "geodata_places" ADD "earthCoord" earth GENERATED ALWAYS AS (ll_to_earth(latitude, longitude)) STORED`) await queryRunner.query(`CREATE INDEX "IDX_geodata_gist_earthcoord" ON "geodata_places" USING gist ("earthCoord");`) @@ -18,8 +16,6 @@ export class Geodata1700362016675 implements MigrationInterface { public async down(queryRunner: QueryRunner): Promise { await queryRunner.query(`DROP INDEX "IDX_geodata_gist_earthcoord"`); await queryRunner.query(`DROP TABLE "geodata_places"`); - await queryRunner.query(`DELETE FROM "typeorm_metadata" WHERE "type" = $1 AND "name" = $2 AND "database" = $3 AND "schema" = $4 AND "table" = $5`, ["GENERATED_COLUMN","admin2Key","immich","public","geodata_places"]); - await queryRunner.query(`DELETE FROM "typeorm_metadata" WHERE "type" = $1 AND "name" = $2 AND "database" = $3 AND "schema" = $4 AND "table" = $5`, ["GENERATED_COLUMN","admin1Key","immich","public","geodata_places"]); await queryRunner.query(`DROP TABLE "geodata_admin1"`); await queryRunner.query(`DROP TABLE "geodata_admin2"`); await queryRunner.query(`DROP EXTENSION cube`); diff --git a/server/src/migrations/1708059341865-GeodataLocationSearch.ts b/server/src/migrations/1708059341865-GeodataLocationSearch.ts index 136ca2598d..af2d5dc5f3 100644 --- a/server/src/migrations/1708059341865-GeodataLocationSearch.ts +++ b/server/src/migrations/1708059341865-GeodataLocationSearch.ts @@ -49,30 +49,6 @@ export class GeodataLocationSearch1708059341865 implements MigrationInterface { CREATE INDEX idx_geodata_places_admin2_name ON geodata_places USING gin (f_unaccent("admin2Name") gin_trgm_ops)`); - - await queryRunner.query( - ` - DELETE FROM "typeorm_metadata" - WHERE - "type" = $1 AND - "name" = $2 AND - "database" = $3 AND - "schema" = $4 AND - "table" = $5`, - ['GENERATED_COLUMN', 'admin1Key', 'immich', 'public', 'geodata_places'], - ); - - await queryRunner.query( - ` - DELETE FROM "typeorm_metadata" - WHERE - "type" = $1 AND - "name" = $2 AND - "database" = $3 AND - "schema" = $4 AND - "table" = $5`, - ['GENERATED_COLUMN', 'admin2Key', 'immich', 'public', 'geodata_places'], - ); } public async down(queryRunner: QueryRunner): Promise { @@ -91,7 +67,7 @@ export class GeodataLocationSearch1708059341865 implements MigrationInterface { )`); await queryRunner.query(` - ALTER TABLE geodata_places + ALTER TABLE geodata_places ADD COLUMN "admin1Key" character varying GENERATED ALWAYS AS ("countryCode" || '.' || "admin1Code") STORED, ADD COLUMN "admin2Key" character varying @@ -128,25 +104,5 @@ export class GeodataLocationSearch1708059341865 implements MigrationInterface { SET "admin2Name" = admin2.name FROM geodata_admin2 admin2 WHERE admin2.key = "admin2Key";`); - - await queryRunner.query( - ` - INSERT INTO "typeorm_metadata"("database", "schema", "table", "type", "name", "value") - VALUES ($1, $2, $3, $4, $5, $6)`, - ['immich', 'public', 'geodata_places', 'GENERATED_COLUMN', 'admin1Key', '"countryCode" || \'.\' || "admin1Code"'], - ); - - await queryRunner.query( - `INSERT INTO "typeorm_metadata"("database", "schema", "table", "type", "name", "value") - VALUES ($1, $2, $3, $4, $5, $6)`, - [ - 'immich', - 'public', - 'geodata_places', - 'GENERATED_COLUMN', - 'admin2Key', - '"countryCode" || \'.\' || "admin1Code" || \'.\' || "admin2Code"', - ], - ); } } diff --git a/server/src/migrations/1715623169039-RemoveTextSearchColumn.ts b/server/src/migrations/1715623169039-RemoveTextSearchColumn.ts new file mode 100644 index 0000000000..50e99f0b2a --- /dev/null +++ b/server/src/migrations/1715623169039-RemoveTextSearchColumn.ts @@ -0,0 +1,21 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class RemoveTextSearchColumn1715623169039 implements MigrationInterface { + name = 'RemoveTextSearchColumn1715623169039' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "exif" DROP COLUMN "exifTextSearchableColumn"`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "exif" ADD "exifTextSearchableColumn" tsvector GENERATED ALWAYS AS (TO_TSVECTOR('english', + COALESCE(make, '') || ' ' || + COALESCE(model, '') || ' ' || + COALESCE(orientation, '') || ' ' || + COALESCE("lensModel", '') || ' ' || + COALESCE("city", '') || ' ' || + COALESCE("state", '') || ' ' || + COALESCE("country", ''))) STORED NOT NULL`); + } + +} diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index 0e5395c5bd..ae7df36559 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -70,10 +70,9 @@ export enum Orientation { Rotate270CW = '8', } -type ExifEntityWithoutGeocodeAndTypeOrm = Omit< - ExifEntity, - 'city' | 'state' | 'country' | 'description' | 'exifTextSearchableColumn' -> & { dateTimeOriginal: Date }; +type ExifEntityWithoutGeocodeAndTypeOrm = Omit & { + dateTimeOriginal: Date; +}; const exifDate = (dt: ExifDateTime | string | undefined) => (dt instanceof ExifDateTime ? dt?.toDate() : null); const tzOffset = (dt: ExifDateTime | string | undefined) => (dt instanceof ExifDateTime ? dt?.tzoffsetMinutes : null); diff --git a/server/test/fixtures/shared-link.stub.ts b/server/test/fixtures/shared-link.stub.ts index 94f39a6978..2ffbe1eb2b 100644 --- a/server/test/fixtures/shared-link.stub.ts +++ b/server/test/fixtures/shared-link.stub.ts @@ -252,7 +252,6 @@ export const sharedLinkStub = { exposureTime: '1/16', fps: 100, asset: null as any, - exifTextSearchableColumn: '', profileDescription: 'sRGB', bitsPerSample: 8, colorspace: 'sRGB', From a05c990718025ea72a2963bae25c68608f1821ac Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Mon, 13 May 2024 16:40:33 -0400 Subject: [PATCH 028/163] feat(web): combine auth settings (#9427) --- .../settings/auth/auth-settings.svelte | 242 ++++++++++++++++++ .../settings/confirm-disable-login.svelte | 25 -- .../settings/oauth/oauth-settings.svelte | 213 --------------- .../password-login-settings.svelte | 68 ----- .../routes/admin/system-settings/+page.svelte | 36 +-- 5 files changed, 256 insertions(+), 328 deletions(-) create mode 100644 web/src/lib/components/admin-page/settings/auth/auth-settings.svelte delete mode 100644 web/src/lib/components/admin-page/settings/confirm-disable-login.svelte delete mode 100644 web/src/lib/components/admin-page/settings/oauth/oauth-settings.svelte delete mode 100644 web/src/lib/components/admin-page/settings/password-login/password-login-settings.svelte 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 new file mode 100644 index 0000000000..d9c879faff --- /dev/null +++ b/web/src/lib/components/admin-page/settings/auth/auth-settings.svelte @@ -0,0 +1,242 @@ + + +{#if isConfirmOpen} + (isConfirmOpen = false)} + onConfirm={() => handleSave(true)} + > + +
+

Are you sure you want to disable all login methods? Login will be completely disabled.

+

+ To re-enable, use a + + Server Command. +

+
+
+
+{/if} + +
+
+
+
+ +
+

+ For more details about this feature, refer to the docs. +

+ + + + {#if config.oauth.enabled} +
+ + + + + + + + + + + + + + + + + + + + + + + handleToggleOverride()} + bind:checked={config.oauth.mobileOverrideEnabled} + /> + + {#if config.oauth.mobileOverrideEnabled} + + {/if} + {/if} +
+
+ + +
+
+ +
+
+
+ + dispatch('reset', { ...detail, configKeys: ['passwordLogin', 'oauth'] })} + on:save={() => handleSave(false)} + /> +
+
+
+
diff --git a/web/src/lib/components/admin-page/settings/confirm-disable-login.svelte b/web/src/lib/components/admin-page/settings/confirm-disable-login.svelte deleted file mode 100644 index 621f51de9f..0000000000 --- a/web/src/lib/components/admin-page/settings/confirm-disable-login.svelte +++ /dev/null @@ -1,25 +0,0 @@ - - - - -
-

Are you sure you want to disable all login methods? Login will be completely disabled.

-

- To re-enable, use a - - Server Command. -

-
-
-
diff --git a/web/src/lib/components/admin-page/settings/oauth/oauth-settings.svelte b/web/src/lib/components/admin-page/settings/oauth/oauth-settings.svelte deleted file mode 100644 index 8173c353eb..0000000000 --- a/web/src/lib/components/admin-page/settings/oauth/oauth-settings.svelte +++ /dev/null @@ -1,213 +0,0 @@ - - -{#if isConfirmOpen} - handleConfirm(false)} onConfirm={() => handleConfirm(true)} /> -{/if} - -
-
-
-

- For more details about this feature, refer to the docs. -

- - - - {#if config.oauth.enabled} -
- - - - - - - - - - - - - - - - - - - - - - - handleToggleOverride()} - bind:checked={config.oauth.mobileOverrideEnabled} - /> - - {#if config.oauth.mobileOverrideEnabled} - - {/if} - {/if} - - dispatch('reset', { ...detail, configKeys: ['oauth'] })} - on:save={() => handleSave()} - showResetToDefault={!isEqual(savedConfig.oauth, defaultConfig.oauth)} - {disabled} - /> - -
-
diff --git a/web/src/lib/components/admin-page/settings/password-login/password-login-settings.svelte b/web/src/lib/components/admin-page/settings/password-login/password-login-settings.svelte deleted file mode 100644 index a022583527..0000000000 --- a/web/src/lib/components/admin-page/settings/password-login/password-login-settings.svelte +++ /dev/null @@ -1,68 +0,0 @@ - - -{#if isConfirmOpen} - handleConfirm(false)} onConfirm={() => handleConfirm(true)} /> -{/if} - -
-
-
-
- - - dispatch('reset', { ...detail, configKeys: ['passwordLogin'] })} - on:save={() => handleSave()} - showResetToDefault={!isEqual(savedConfig.passwordLogin, defaultConfig.passwordLogin)} - {disabled} - /> -
-
-
-
diff --git a/web/src/routes/admin/system-settings/+page.svelte b/web/src/routes/admin/system-settings/+page.svelte index c57a6b1697..20a9557c7f 100644 --- a/web/src/routes/admin/system-settings/+page.svelte +++ b/web/src/routes/admin/system-settings/+page.svelte @@ -1,34 +1,33 @@ - Opps! Error - Immich + Oops! Error - Immich
From 6fd6a8ba15df34a2b047646714707e41f6a5547a Mon Sep 17 00:00:00 2001 From: Eric Barch Date: Mon, 13 May 2024 23:29:32 -0400 Subject: [PATCH 033/163] fix(server): addAssets and removeAssets handle duplicate assetIds (#9436) * fix(server): addAssets and removeAssets handle duplicate assetIds * chore(server): Add e2e tests for duplicate album additions and removals --- e2e/src/api/specs/album.e2e-spec.ts | 27 +++++++++++++++++++++++++++ server/src/utils/asset.util.ts | 2 ++ 2 files changed, 29 insertions(+) diff --git a/e2e/src/api/specs/album.e2e-spec.ts b/e2e/src/api/specs/album.e2e-spec.ts index f7d05aac50..ec5238f376 100644 --- a/e2e/src/api/specs/album.e2e-spec.ts +++ b/e2e/src/api/specs/album.e2e-spec.ts @@ -434,6 +434,20 @@ describe('/album', () => { expect(status).toBe(400); expect(body).toEqual(errorDto.badRequest('Not found or no album.addAsset access')); }); + + it('should add duplicate assets only once', async () => { + const asset = await utils.createAsset(user1.accessToken); + const { status, body } = await request(app) + .put(`/album/${user1Albums[0].id}/assets`) + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ ids: [asset.id, asset.id] }); + + expect(status).toBe(200); + expect(body).toEqual([ + expect.objectContaining({ id: asset.id, success: true }), + expect.objectContaining({ id: asset.id, success: false, error: 'duplicate' }), + ]); + }); }); describe('PATCH /album/:id', () => { @@ -557,6 +571,19 @@ describe('/album', () => { expect(status).toBe(400); expect(body).toEqual(errorDto.badRequest('Not found or no album.removeAsset access')); }); + + it('should remove duplicate assets only once', async () => { + const { status, body } = await request(app) + .delete(`/album/${user1Albums[1].id}/assets`) + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ ids: [user1Asset1.id, user1Asset1.id] }); + + expect(status).toBe(200); + expect(body).toEqual([ + expect.objectContaining({ id: user1Asset1.id, success: true }), + expect.objectContaining({ id: user1Asset1.id, success: false, error: 'not_found' }), + ]); + }); }); describe('PUT :id/users', () => { diff --git a/server/src/utils/asset.util.ts b/server/src/utils/asset.util.ts index 253073919f..a55156e679 100644 --- a/server/src/utils/asset.util.ts +++ b/server/src/utils/asset.util.ts @@ -36,6 +36,7 @@ export const addAssets = async ( continue; } + existingAssetIds.add(assetId); results.push({ id: assetId, success: true }); } @@ -79,6 +80,7 @@ export const removeAssets = async ( continue; } + existingAssetIds.delete(assetId); results.push({ id: assetId, success: true }); } From f2be310aec40509a337c6f0b60a1d9bb502f7a12 Mon Sep 17 00:00:00 2001 From: Snowknight26 Date: Tue, 14 May 2024 04:52:39 -0500 Subject: [PATCH 034/163] fix(web): decrease asset viewer navigation area size (#9455) * fix(web): decrease asset viewer navigation area size * Remove unneeded class * Reduce wrapping div area --- web/src/lib/components/asset-viewer/asset-viewer.svelte | 4 ++-- web/src/lib/components/asset-viewer/navigation-area.svelte | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index 94c75f7177..1b6d9479a9 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -600,7 +600,7 @@ {/if} {#if $slideshowState === SlideshowState.None && showNavigation} -
+
navigateAsset('previous', e)} label="View previous asset"> @@ -691,7 +691,7 @@
{#if $slideshowState === SlideshowState.None && showNavigation} -
+
navigateAsset('next', e)} label="View next asset"> diff --git a/web/src/lib/components/asset-viewer/navigation-area.svelte b/web/src/lib/components/asset-viewer/navigation-area.svelte index efa2761cb1..801d203643 100644 --- a/web/src/lib/components/asset-viewer/navigation-area.svelte +++ b/web/src/lib/components/asset-viewer/navigation-area.svelte @@ -5,7 +5,7 @@ -
+
+
+ ($loopVideo = !$loopVideo)} + /> +
('delete-confirm-dialog', true, export const alwaysLoadOriginalFile = persisted('always-load-original-file', false, {}); export const playVideoThumbnailOnHover = persisted('play-video-thumbnail-on-hover', true, {}); + +export const loopVideo = persisted('loop-video', true, {}); From ce7bbe88f9e2c4c16a504b929d5bd447cec7ffe9 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Tue, 14 May 2024 17:29:57 -0400 Subject: [PATCH 064/163] fix(server): skip originals when deleting a library (#9496) --- server/src/services/asset.service.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index b1eff50a93..715b2bb4aa 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -434,12 +434,13 @@ export class AssetService { await this.jobRepository.queue({ name: JobName.ASSET_DELETION, data: { id: asset.livePhotoVideoId } }); } - await this.jobRepository.queue({ - name: JobName.DELETE_FILES, - data: { - files: [asset.thumbnailPath, asset.previewPath, asset.encodedVideoPath, asset.sidecarPath, asset.originalPath], - }, - }); + const files = [asset.thumbnailPath, asset.previewPath, asset.encodedVideoPath]; + // skip originals if the user deleted the whole library + if (!asset.library.deletedAt) { + files.push(asset.sidecarPath, asset.originalPath); + } + + await this.jobRepository.queue({ name: JobName.DELETE_FILES, data: { files } }); return JobStatus.SUCCESS; } From 88d43383486825e1fab3f6cc87d4edf465ce253e Mon Sep 17 00:00:00 2001 From: Alex The Bot Date: Tue, 14 May 2024 21:31:24 +0000 Subject: [PATCH 065/163] Version v1.105.1 --- cli/package-lock.json | 2 +- e2e/package-lock.json | 6 +++--- 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 +- 16 files changed, 23 insertions(+), 23 deletions(-) diff --git a/cli/package-lock.json b/cli/package-lock.json index a2e0233736..998a942546 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -47,7 +47,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.105.0", + "version": "1.105.1", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { diff --git a/e2e/package-lock.json b/e2e/package-lock.json index ee3cbfb4f5..9f3fe078be 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich-e2e", - "version": "1.105.0", + "version": "1.105.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich-e2e", - "version": "1.105.0", + "version": "1.105.1", "license": "GNU Affero General Public License version 3", "devDependencies": { "@immich/cli": "file:../cli", @@ -81,7 +81,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.105.0", + "version": "1.105.1", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { diff --git a/e2e/package.json b/e2e/package.json index a7c8837f98..88c40a8f0e 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -1,6 +1,6 @@ { "name": "immich-e2e", - "version": "1.105.0", + "version": "1.105.1", "description": "", "main": "index.js", "type": "module", diff --git a/machine-learning/pyproject.toml b/machine-learning/pyproject.toml index b2a4efa2a1..2c8eb39b59 100644 --- a/machine-learning/pyproject.toml +++ b/machine-learning/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "machine-learning" -version = "1.105.0" +version = "1.105.1" description = "" authors = ["Hau Tran "] readme = "README.md" diff --git a/mobile/android/fastlane/Fastfile b/mobile/android/fastlane/Fastfile index ab3ba51cf2..7a75bd1137 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" => 139, - "android.injected.version.name" => "1.105.0", + "android.injected.version.code" => 140, + "android.injected.version.name" => "1.105.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 732f81728e..603283547a 100644 --- a/mobile/ios/fastlane/Fastfile +++ b/mobile/ios/fastlane/Fastfile @@ -19,7 +19,7 @@ platform :ios do desc "iOS Beta" lane :beta do increment_version_number( - version_number: "1.105.0" + version_number: "1.105.1" ) increment_build_number( build_number: latest_testflight_build_number + 1, diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index dba9732b67..753c9a90a8 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.105.0 +- API version: 1.105.1 - Build package: org.openapitools.codegen.languages.DartClientCodegen ## Requirements diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index a50feffedd..8ee159651c 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.105.0+139 +version: 1.105.1+140 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 fa41b137d8..e87e55958a 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -6446,7 +6446,7 @@ "info": { "title": "Immich", "description": "Immich API", - "version": "1.105.0", + "version": "1.105.1", "contact": {} }, "tags": [], diff --git a/open-api/typescript-sdk/package-lock.json b/open-api/typescript-sdk/package-lock.json index 01173ba980..6bc90daa26 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.105.0", + "version": "1.105.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@immich/sdk", - "version": "1.105.0", + "version": "1.105.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 eec6809bd7..81c52c8b3e 100644 --- a/open-api/typescript-sdk/package.json +++ b/open-api/typescript-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@immich/sdk", - "version": "1.105.0", + "version": "1.105.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 a29ab64201..cbe9e5b1f4 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.105.0 + * 1.105.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 28d11d33b3..db43c435a2 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich", - "version": "1.105.0", + "version": "1.105.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "immich", - "version": "1.105.0", + "version": "1.105.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 9bc97bde5f..b59ce8fa76 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "immich", - "version": "1.105.0", + "version": "1.105.1", "description": "", "author": "", "private": true, diff --git a/web/package-lock.json b/web/package-lock.json index 1704257cb8..ea27891c1d 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich-web", - "version": "1.105.0", + "version": "1.105.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich-web", - "version": "1.105.0", + "version": "1.105.1", "license": "GNU Affero General Public License version 3", "dependencies": { "@immich/sdk": "file:../open-api/typescript-sdk", @@ -66,7 +66,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.105.0", + "version": "1.105.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 5f84de713c..01613c0cf3 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "immich-web", - "version": "1.105.0", + "version": "1.105.1", "license": "GNU Affero General Public License version 3", "scripts": { "dev": "vite dev --host 0.0.0.0 --port 3000", From efb844c6cded9a60f09c8587e927c8fa1147c0d2 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 14 May 2024 19:33:53 -0400 Subject: [PATCH 066/163] fix(deps): update dependency @zoom-image/svelte to v0.2.12 (#9487) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- web/package-lock.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/web/package-lock.json b/web/package-lock.json index ea27891c1d..fe53dd2cd0 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -2806,9 +2806,9 @@ "dev": true }, "node_modules/@zoom-image/core": { - "version": "0.34.1", - "resolved": "https://registry.npmjs.org/@zoom-image/core/-/core-0.34.1.tgz", - "integrity": "sha512-IHh5TSp/PGvBZs8plQ+ERDz2NXoZ52v+8JUMFNkvqRSYAVW87xuSup1JESKdp72qLaXWAGUfTqvqFlzmC+/37g==", + "version": "0.34.2", + "resolved": "https://registry.npmjs.org/@zoom-image/core/-/core-0.34.2.tgz", + "integrity": "sha512-VI/T2fokW25y0U+5wTWb1nWUEaMlR9Uhx4SCCzcmBP6EIOUw4XnyoWfyALb+NDyTD7KtMi8y6MCXdODVQtpRSQ==", "dependencies": { "@namnode/store": "^0.1.0" }, @@ -2818,11 +2818,11 @@ } }, "node_modules/@zoom-image/svelte": { - "version": "0.2.11", - "resolved": "https://registry.npmjs.org/@zoom-image/svelte/-/svelte-0.2.11.tgz", - "integrity": "sha512-cdq43YTEuOV0LmkHlddSvA8UG6USlYqv7BRTDwyGH6jWHGMJudhqvBiikvA93gfyND538qagacN9jAbWto3ASQ==", + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@zoom-image/svelte/-/svelte-0.2.12.tgz", + "integrity": "sha512-f8FesLWZuIrogjcGOUAsar8SHNuimFB2Kqf6WsgsYH8b6Px322X2ipmcpqchWKyxAzOiT77qRhM88io+9NmrxQ==", "dependencies": { - "@zoom-image/core": "0.34.1" + "@zoom-image/core": "0.34.2" }, "funding": { "type": "github", From 581b467b4be9a31cb7916823c9f1a252092e16cf Mon Sep 17 00:00:00 2001 From: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Date: Wed, 15 May 2024 13:21:35 +0200 Subject: [PATCH 067/163] fix(server): smtp certificate validation (#9506) --- server/src/repositories/notification.repository.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/repositories/notification.repository.ts b/server/src/repositories/notification.repository.ts index 23c81bb77b..e22198de80 100644 --- a/server/src/repositories/notification.repository.ts +++ b/server/src/repositories/notification.repository.ts @@ -59,7 +59,7 @@ export class NotificationRepository implements INotificationRepository { return createTransport({ host: options.host, port: options.port, - tls: { rejectUnauthorized: options.ignoreCert }, + tls: { rejectUnauthorized: !options.ignoreCert }, auth: options.username || options.password ? { From 73bf8f343ac1644825607849d6ca3371ff1168ba Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Wed, 15 May 2024 15:17:48 -0400 Subject: [PATCH 068/163] chore(server): remove unused property (#9521) --- mobile/openapi/doc/CreateLibraryDto.md | 1 - mobile/openapi/doc/UpdateLibraryDto.md | 1 - .../openapi/lib/model/create_library_dto.dart | 19 +--------- .../openapi/lib/model/update_library_dto.dart | 19 +--------- .../openapi/test/create_library_dto_test.dart | 5 --- .../openapi/test/update_library_dto_test.dart | 5 --- open-api/immich-openapi-specs.json | 6 ---- open-api/typescript-sdk/src/fetch-client.ts | 2 -- server/src/cores/user.core.ts | 1 - server/src/dtos/library.dto.ts | 6 ---- server/src/entities/library.entity.ts | 3 -- .../1715798702876-RemoveLibraryIsVisible.ts | 14 ++++++++ server/src/queries/library.repository.sql | 20 ++--------- server/src/repositories/library.repository.ts | 2 -- server/src/services/asset-v1.service.ts | 1 - server/src/services/library.service.spec.ts | 36 ------------------- server/src/services/library.service.ts | 1 - server/test/fixtures/library.stub.ts | 8 ----- 18 files changed, 19 insertions(+), 131 deletions(-) create mode 100644 server/src/migrations/1715798702876-RemoveLibraryIsVisible.ts diff --git a/mobile/openapi/doc/CreateLibraryDto.md b/mobile/openapi/doc/CreateLibraryDto.md index 01a2a0f917..f7d5c0ecfe 100644 --- a/mobile/openapi/doc/CreateLibraryDto.md +++ b/mobile/openapi/doc/CreateLibraryDto.md @@ -10,7 +10,6 @@ Name | Type | Description | Notes ------------ | ------------- | ------------- | ------------- **exclusionPatterns** | **List** | | [optional] [default to const []] **importPaths** | **List** | | [optional] [default to const []] -**isVisible** | **bool** | | [optional] **name** | **String** | | [optional] **ownerId** | **String** | | **type** | [**LibraryType**](LibraryType.md) | | diff --git a/mobile/openapi/doc/UpdateLibraryDto.md b/mobile/openapi/doc/UpdateLibraryDto.md index 0f0e2652b8..432e2aee41 100644 --- a/mobile/openapi/doc/UpdateLibraryDto.md +++ b/mobile/openapi/doc/UpdateLibraryDto.md @@ -10,7 +10,6 @@ Name | Type | Description | Notes ------------ | ------------- | ------------- | ------------- **exclusionPatterns** | **List** | | [optional] [default to const []] **importPaths** | **List** | | [optional] [default to const []] -**isVisible** | **bool** | | [optional] **name** | **String** | | [optional] [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) diff --git a/mobile/openapi/lib/model/create_library_dto.dart b/mobile/openapi/lib/model/create_library_dto.dart index 93fb89b701..532ddd68e3 100644 --- a/mobile/openapi/lib/model/create_library_dto.dart +++ b/mobile/openapi/lib/model/create_library_dto.dart @@ -15,7 +15,6 @@ class CreateLibraryDto { CreateLibraryDto({ this.exclusionPatterns = const [], this.importPaths = const [], - this.isVisible, this.name, required this.ownerId, required this.type, @@ -25,14 +24,6 @@ class CreateLibraryDto { List importPaths; - /// - /// 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; - /// /// 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 @@ -49,7 +40,6 @@ class CreateLibraryDto { bool operator ==(Object other) => identical(this, other) || other is CreateLibraryDto && _deepEquality.equals(other.exclusionPatterns, exclusionPatterns) && _deepEquality.equals(other.importPaths, importPaths) && - other.isVisible == isVisible && other.name == name && other.ownerId == ownerId && other.type == type; @@ -59,23 +49,17 @@ class CreateLibraryDto { // ignore: unnecessary_parenthesis (exclusionPatterns.hashCode) + (importPaths.hashCode) + - (isVisible == null ? 0 : isVisible!.hashCode) + (name == null ? 0 : name!.hashCode) + (ownerId.hashCode) + (type.hashCode); @override - String toString() => 'CreateLibraryDto[exclusionPatterns=$exclusionPatterns, importPaths=$importPaths, isVisible=$isVisible, name=$name, ownerId=$ownerId, type=$type]'; + String toString() => 'CreateLibraryDto[exclusionPatterns=$exclusionPatterns, importPaths=$importPaths, name=$name, ownerId=$ownerId, type=$type]'; Map toJson() { final json = {}; json[r'exclusionPatterns'] = this.exclusionPatterns; json[r'importPaths'] = this.importPaths; - if (this.isVisible != null) { - json[r'isVisible'] = this.isVisible; - } else { - // json[r'isVisible'] = null; - } if (this.name != null) { json[r'name'] = this.name; } else { @@ -100,7 +84,6 @@ class CreateLibraryDto { importPaths: json[r'importPaths'] is Iterable ? (json[r'importPaths'] as Iterable).cast().toList(growable: false) : const [], - isVisible: mapValueOfType(json, r'isVisible'), name: mapValueOfType(json, r'name'), ownerId: mapValueOfType(json, r'ownerId')!, type: LibraryType.fromJson(json[r'type'])!, diff --git a/mobile/openapi/lib/model/update_library_dto.dart b/mobile/openapi/lib/model/update_library_dto.dart index b870f240fe..f197ca8599 100644 --- a/mobile/openapi/lib/model/update_library_dto.dart +++ b/mobile/openapi/lib/model/update_library_dto.dart @@ -15,7 +15,6 @@ class UpdateLibraryDto { UpdateLibraryDto({ this.exclusionPatterns = const [], this.importPaths = const [], - this.isVisible, this.name, }); @@ -23,14 +22,6 @@ class UpdateLibraryDto { List importPaths; - /// - /// 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; - /// /// 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 @@ -43,7 +34,6 @@ class UpdateLibraryDto { bool operator ==(Object other) => identical(this, other) || other is UpdateLibraryDto && _deepEquality.equals(other.exclusionPatterns, exclusionPatterns) && _deepEquality.equals(other.importPaths, importPaths) && - other.isVisible == isVisible && other.name == name; @override @@ -51,21 +41,15 @@ class UpdateLibraryDto { // ignore: unnecessary_parenthesis (exclusionPatterns.hashCode) + (importPaths.hashCode) + - (isVisible == null ? 0 : isVisible!.hashCode) + (name == null ? 0 : name!.hashCode); @override - String toString() => 'UpdateLibraryDto[exclusionPatterns=$exclusionPatterns, importPaths=$importPaths, isVisible=$isVisible, name=$name]'; + String toString() => 'UpdateLibraryDto[exclusionPatterns=$exclusionPatterns, importPaths=$importPaths, name=$name]'; Map toJson() { final json = {}; json[r'exclusionPatterns'] = this.exclusionPatterns; json[r'importPaths'] = this.importPaths; - if (this.isVisible != null) { - json[r'isVisible'] = this.isVisible; - } else { - // json[r'isVisible'] = null; - } if (this.name != null) { json[r'name'] = this.name; } else { @@ -88,7 +72,6 @@ class UpdateLibraryDto { importPaths: json[r'importPaths'] is Iterable ? (json[r'importPaths'] as Iterable).cast().toList(growable: false) : const [], - isVisible: mapValueOfType(json, r'isVisible'), name: mapValueOfType(json, r'name'), ); } diff --git a/mobile/openapi/test/create_library_dto_test.dart b/mobile/openapi/test/create_library_dto_test.dart index 1dd77af251..eedb0d59d2 100644 --- a/mobile/openapi/test/create_library_dto_test.dart +++ b/mobile/openapi/test/create_library_dto_test.dart @@ -26,11 +26,6 @@ void main() { // TODO }); - // bool isVisible - test('to test the property `isVisible`', () async { - // TODO - }); - // String name test('to test the property `name`', () async { // TODO diff --git a/mobile/openapi/test/update_library_dto_test.dart b/mobile/openapi/test/update_library_dto_test.dart index 222eb333bc..0db376dddb 100644 --- a/mobile/openapi/test/update_library_dto_test.dart +++ b/mobile/openapi/test/update_library_dto_test.dart @@ -26,11 +26,6 @@ void main() { // TODO }); - // bool isVisible - test('to test the property `isVisible`', () async { - // TODO - }); - // String name test('to test the property `name`', () async { // TODO diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index e87e55958a..ac8634766a 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -7708,9 +7708,6 @@ }, "type": "array" }, - "isVisible": { - "type": "boolean" - }, "name": { "type": "string" }, @@ -10741,9 +10738,6 @@ }, "type": "array" }, - "isVisible": { - "type": "boolean" - }, "name": { "type": "string" } diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index cbe9e5b1f4..d6a2b2529f 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -442,7 +442,6 @@ export type LibraryResponseDto = { export type CreateLibraryDto = { exclusionPatterns?: string[]; importPaths?: string[]; - isVisible?: boolean; name?: string; ownerId: string; "type": LibraryType; @@ -450,7 +449,6 @@ export type CreateLibraryDto = { export type UpdateLibraryDto = { exclusionPatterns?: string[]; importPaths?: string[]; - isVisible?: boolean; name?: string; }; export type ScanLibraryDto = { diff --git a/server/src/cores/user.core.ts b/server/src/cores/user.core.ts index e8596db3e7..db2a9c780c 100644 --- a/server/src/cores/user.core.ts +++ b/server/src/cores/user.core.ts @@ -101,7 +101,6 @@ export class UserCore { type: LibraryType.UPLOAD, importPaths: [], exclusionPatterns: [], - isVisible: true, }); return userEntity; diff --git a/server/src/dtos/library.dto.ts b/server/src/dtos/library.dto.ts index b693d35adf..045aaecf54 100644 --- a/server/src/dtos/library.dto.ts +++ b/server/src/dtos/library.dto.ts @@ -16,9 +16,6 @@ export class CreateLibraryDto { @IsNotEmpty() name?: string; - @ValidateBoolean({ optional: true }) - isVisible?: boolean; - @Optional() @IsString({ each: true }) @IsNotEmpty({ each: true }) @@ -40,9 +37,6 @@ export class UpdateLibraryDto { @IsNotEmpty() name?: string; - @ValidateBoolean({ optional: true }) - isVisible?: boolean; - @Optional() @IsString({ each: true }) @IsNotEmpty({ each: true }) diff --git a/server/src/entities/library.entity.ts b/server/src/entities/library.entity.ts index 8be560a889..56e62dd062 100644 --- a/server/src/entities/library.entity.ts +++ b/server/src/entities/library.entity.ts @@ -50,9 +50,6 @@ export class LibraryEntity { @Column({ type: 'timestamptz', nullable: true }) refreshedAt!: Date | null; - - @Column({ type: 'boolean', default: true }) - isVisible!: boolean; } export enum LibraryType { diff --git a/server/src/migrations/1715798702876-RemoveLibraryIsVisible.ts b/server/src/migrations/1715798702876-RemoveLibraryIsVisible.ts new file mode 100644 index 0000000000..45f5248c1a --- /dev/null +++ b/server/src/migrations/1715798702876-RemoveLibraryIsVisible.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class RemoveLibraryIsVisible1715798702876 implements MigrationInterface { + name = 'RemoveLibraryIsVisible1715798702876' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "libraries" DROP COLUMN "isVisible"`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "libraries" ADD "isVisible" boolean NOT NULL DEFAULT true`); + } + +} diff --git a/server/src/queries/library.repository.sql b/server/src/queries/library.repository.sql index 93a6fc97fb..3e655d6506 100644 --- a/server/src/queries/library.repository.sql +++ b/server/src/queries/library.repository.sql @@ -16,7 +16,6 @@ FROM "LibraryEntity"."updatedAt" AS "LibraryEntity_updatedAt", "LibraryEntity"."deletedAt" AS "LibraryEntity_deletedAt", "LibraryEntity"."refreshedAt" AS "LibraryEntity_refreshedAt", - "LibraryEntity"."isVisible" AS "LibraryEntity_isVisible", "LibraryEntity__LibraryEntity_owner"."id" AS "LibraryEntity__LibraryEntity_owner_id", "LibraryEntity__LibraryEntity_owner"."name" AS "LibraryEntity__LibraryEntity_owner_name", "LibraryEntity__LibraryEntity_owner"."avatarColor" AS "LibraryEntity__LibraryEntity_owner_avatarColor", @@ -89,8 +88,7 @@ SELECT "LibraryEntity"."createdAt" AS "LibraryEntity_createdAt", "LibraryEntity"."updatedAt" AS "LibraryEntity_updatedAt", "LibraryEntity"."deletedAt" AS "LibraryEntity_deletedAt", - "LibraryEntity"."refreshedAt" AS "LibraryEntity_refreshedAt", - "LibraryEntity"."isVisible" AS "LibraryEntity_isVisible" + "LibraryEntity"."refreshedAt" AS "LibraryEntity_refreshedAt" FROM "libraries" "LibraryEntity" WHERE @@ -132,7 +130,6 @@ SELECT "LibraryEntity"."updatedAt" AS "LibraryEntity_updatedAt", "LibraryEntity"."deletedAt" AS "LibraryEntity_deletedAt", "LibraryEntity"."refreshedAt" AS "LibraryEntity_refreshedAt", - "LibraryEntity"."isVisible" AS "LibraryEntity_isVisible", "LibraryEntity__LibraryEntity_owner"."id" AS "LibraryEntity__LibraryEntity_owner_id", "LibraryEntity__LibraryEntity_owner"."name" AS "LibraryEntity__LibraryEntity_owner_name", "LibraryEntity__LibraryEntity_owner"."avatarColor" AS "LibraryEntity__LibraryEntity_owner_avatarColor", @@ -156,12 +153,7 @@ FROM "LibraryEntity__LibraryEntity_owner"."deletedAt" IS NULL ) WHERE - ( - ( - ("LibraryEntity"."ownerId" = $1) - AND ("LibraryEntity"."isVisible" = $2) - ) - ) + ((("LibraryEntity"."ownerId" = $1))) AND ("LibraryEntity"."deletedAt" IS NULL) ORDER BY "LibraryEntity"."createdAt" ASC @@ -178,7 +170,6 @@ SELECT "LibraryEntity"."updatedAt" AS "LibraryEntity_updatedAt", "LibraryEntity"."deletedAt" AS "LibraryEntity_deletedAt", "LibraryEntity"."refreshedAt" AS "LibraryEntity_refreshedAt", - "LibraryEntity"."isVisible" AS "LibraryEntity_isVisible", "LibraryEntity__LibraryEntity_owner"."id" AS "LibraryEntity__LibraryEntity_owner_id", "LibraryEntity__LibraryEntity_owner"."name" AS "LibraryEntity__LibraryEntity_owner_name", "LibraryEntity__LibraryEntity_owner"."avatarColor" AS "LibraryEntity__LibraryEntity_owner_avatarColor", @@ -218,7 +209,6 @@ SELECT "LibraryEntity"."updatedAt" AS "LibraryEntity_updatedAt", "LibraryEntity"."deletedAt" AS "LibraryEntity_deletedAt", "LibraryEntity"."refreshedAt" AS "LibraryEntity_refreshedAt", - "LibraryEntity"."isVisible" AS "LibraryEntity_isVisible", "LibraryEntity__LibraryEntity_owner"."id" AS "LibraryEntity__LibraryEntity_owner_id", "LibraryEntity__LibraryEntity_owner"."name" AS "LibraryEntity__LibraryEntity_owner_name", "LibraryEntity__LibraryEntity_owner"."avatarColor" AS "LibraryEntity__LibraryEntity_owner_avatarColor", @@ -239,10 +229,7 @@ FROM "libraries" "LibraryEntity" LEFT JOIN "users" "LibraryEntity__LibraryEntity_owner" ON "LibraryEntity__LibraryEntity_owner"."id" = "LibraryEntity"."ownerId" WHERE - ( - ("LibraryEntity"."isVisible" = $1) - AND (NOT ("LibraryEntity"."deletedAt" IS NULL)) - ) + ((NOT ("LibraryEntity"."deletedAt" IS NULL))) ORDER BY "LibraryEntity"."createdAt" ASC @@ -258,7 +245,6 @@ SELECT "libraries"."updatedAt" AS "libraries_updatedAt", "libraries"."deletedAt" AS "libraries_deletedAt", "libraries"."refreshedAt" AS "libraries_refreshedAt", - "libraries"."isVisible" AS "libraries_isVisible", COUNT("assets"."id") FILTER ( WHERE "assets"."type" = 'IMAGE' diff --git a/server/src/repositories/library.repository.ts b/server/src/repositories/library.repository.ts index b0350c14ec..25eb010356 100644 --- a/server/src/repositories/library.repository.ts +++ b/server/src/repositories/library.repository.ts @@ -67,7 +67,6 @@ export class LibraryRepository implements ILibraryRepository { return this.repository.find({ where: { ownerId, - isVisible: true, type, }, relations: { @@ -97,7 +96,6 @@ export class LibraryRepository implements ILibraryRepository { getAllDeleted(): Promise { return this.repository.find({ where: { - isVisible: true, deletedAt: Not(IsNull()), }, relations: { diff --git a/server/src/services/asset-v1.service.ts b/server/src/services/asset-v1.service.ts index 9667730fb3..bd6f540061 100644 --- a/server/src/services/asset-v1.service.ts +++ b/server/src/services/asset-v1.service.ts @@ -259,7 +259,6 @@ export class AssetServiceV1 { type: LibraryType.UPLOAD, importPaths: [], exclusionPatterns: [], - isVisible: true, }); } diff --git a/server/src/services/library.service.spec.ts b/server/src/services/library.service.spec.ts index f987fd1b57..fa45341784 100644 --- a/server/src/services/library.service.spec.ts +++ b/server/src/services/library.service.spec.ts @@ -830,7 +830,6 @@ describe(LibraryService.name, () => { type: LibraryType.EXTERNAL, importPaths: [], exclusionPatterns: [], - isVisible: true, }), ); }); @@ -860,37 +859,6 @@ describe(LibraryService.name, () => { type: LibraryType.EXTERNAL, importPaths: [], exclusionPatterns: [], - isVisible: true, - }), - ); - }); - - it('should create invisible', async () => { - libraryMock.create.mockResolvedValue(libraryStub.externalLibrary1); - await expect( - sut.create({ ownerId: authStub.admin.user.id, type: LibraryType.EXTERNAL, isVisible: false }), - ).resolves.toEqual( - expect.objectContaining({ - id: libraryStub.externalLibrary1.id, - type: LibraryType.EXTERNAL, - name: libraryStub.externalLibrary1.name, - ownerId: libraryStub.externalLibrary1.ownerId, - assetCount: 0, - importPaths: [], - exclusionPatterns: [], - createdAt: libraryStub.externalLibrary1.createdAt, - updatedAt: libraryStub.externalLibrary1.updatedAt, - refreshedAt: null, - }), - ); - - expect(libraryMock.create).toHaveBeenCalledWith( - expect.objectContaining({ - name: expect.any(String), - type: LibraryType.EXTERNAL, - importPaths: [], - exclusionPatterns: [], - isVisible: false, }), ); }); @@ -924,7 +892,6 @@ describe(LibraryService.name, () => { type: LibraryType.EXTERNAL, importPaths: ['/data/images', '/data/videos'], exclusionPatterns: [], - isVisible: true, }), ); }); @@ -972,7 +939,6 @@ describe(LibraryService.name, () => { type: LibraryType.EXTERNAL, importPaths: [], exclusionPatterns: ['*.tmp', '*.bak'], - isVisible: true, }), ); }); @@ -1002,7 +968,6 @@ describe(LibraryService.name, () => { type: LibraryType.UPLOAD, importPaths: [], exclusionPatterns: [], - isVisible: true, }), ); }); @@ -1032,7 +997,6 @@ describe(LibraryService.name, () => { type: LibraryType.UPLOAD, importPaths: [], exclusionPatterns: [], - isVisible: true, }), ); }); diff --git a/server/src/services/library.service.ts b/server/src/services/library.service.ts index a0d9b70d65..3c6e26a315 100644 --- a/server/src/services/library.service.ts +++ b/server/src/services/library.service.ts @@ -271,7 +271,6 @@ export class LibraryService { type: dto.type, importPaths: dto.importPaths ?? [], exclusionPatterns: dto.exclusionPatterns ?? [], - isVisible: dto.isVisible ?? true, }); this.logger.log(`Creating ${dto.type} library for ${dto.ownerId}}`); diff --git a/server/test/fixtures/library.stub.ts b/server/test/fixtures/library.stub.ts index dde250a7a1..bb95439d1c 100644 --- a/server/test/fixtures/library.stub.ts +++ b/server/test/fixtures/library.stub.ts @@ -16,7 +16,6 @@ export const libraryStub = { createdAt: new Date('2022-01-01'), updatedAt: new Date('2022-01-01'), refreshedAt: null, - isVisible: true, exclusionPatterns: [], }), externalLibrary1: Object.freeze({ @@ -30,7 +29,6 @@ export const libraryStub = { createdAt: new Date('2023-01-01'), updatedAt: new Date('2023-01-01'), refreshedAt: null, - isVisible: true, exclusionPatterns: [], }), externalLibrary2: Object.freeze({ @@ -44,7 +42,6 @@ export const libraryStub = { createdAt: new Date('2021-01-01'), updatedAt: new Date('2022-01-01'), refreshedAt: null, - isVisible: true, exclusionPatterns: [], }), externalLibraryWithImportPaths1: Object.freeze({ @@ -58,7 +55,6 @@ export const libraryStub = { createdAt: new Date('2023-01-01'), updatedAt: new Date('2023-01-01'), refreshedAt: null, - isVisible: true, exclusionPatterns: [], }), externalLibraryWithImportPaths2: Object.freeze({ @@ -72,7 +68,6 @@ export const libraryStub = { createdAt: new Date('2023-01-01'), updatedAt: new Date('2023-01-01'), refreshedAt: null, - isVisible: true, exclusionPatterns: [], }), externalLibraryWithExclusionPattern: Object.freeze({ @@ -86,7 +81,6 @@ export const libraryStub = { createdAt: new Date('2023-01-01'), updatedAt: new Date('2023-01-01'), refreshedAt: null, - isVisible: true, exclusionPatterns: ['**/dir1/**'], }), patternPath: Object.freeze({ @@ -100,7 +94,6 @@ export const libraryStub = { createdAt: new Date('2023-01-01'), updatedAt: new Date('2023-01-01'), refreshedAt: null, - isVisible: true, exclusionPatterns: ['**/dir1/**'], }), hasImmichPaths: Object.freeze({ @@ -114,7 +107,6 @@ export const libraryStub = { createdAt: new Date('2023-01-01'), updatedAt: new Date('2023-01-01'), refreshedAt: null, - isVisible: true, exclusionPatterns: ['**/dir1/**'], }), }; From 058957515406a429870f3f5a7142e4e3e141aa6c Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Wed, 15 May 2024 17:52:52 -0400 Subject: [PATCH 069/163] chore: bump open-api (#9522) --- mobile/openapi/.openapi-generator/VERSION | 2 +- mobile/openapi/README.md | 1 + mobile/openapi/lib/api.dart | 2 +- mobile/openapi/lib/api/activity_api.dart | 2 +- mobile/openapi/lib/api/album_api.dart | 2 +- mobile/openapi/lib/api/api_key_api.dart | 2 +- mobile/openapi/lib/api/asset_api.dart | 2 +- mobile/openapi/lib/api/audit_api.dart | 2 +- mobile/openapi/lib/api/authentication_api.dart | 2 +- mobile/openapi/lib/api/download_api.dart | 2 +- mobile/openapi/lib/api/face_api.dart | 2 +- mobile/openapi/lib/api/file_report_api.dart | 2 +- mobile/openapi/lib/api/job_api.dart | 2 +- mobile/openapi/lib/api/library_api.dart | 2 +- mobile/openapi/lib/api/memory_api.dart | 2 +- mobile/openapi/lib/api/o_auth_api.dart | 2 +- mobile/openapi/lib/api/partner_api.dart | 2 +- mobile/openapi/lib/api/person_api.dart | 2 +- mobile/openapi/lib/api/search_api.dart | 2 +- mobile/openapi/lib/api/server_info_api.dart | 2 +- mobile/openapi/lib/api/sessions_api.dart | 2 +- mobile/openapi/lib/api/shared_link_api.dart | 2 +- mobile/openapi/lib/api/sync_api.dart | 2 +- mobile/openapi/lib/api/system_config_api.dart | 2 +- mobile/openapi/lib/api/system_metadata_api.dart | 2 +- mobile/openapi/lib/api/tag_api.dart | 2 +- mobile/openapi/lib/api/timeline_api.dart | 2 +- mobile/openapi/lib/api/trash_api.dart | 2 +- mobile/openapi/lib/api/user_api.dart | 2 +- mobile/openapi/lib/api_client.dart | 2 +- mobile/openapi/lib/api_exception.dart | 2 +- mobile/openapi/lib/api_helper.dart | 2 +- mobile/openapi/lib/auth/api_key_auth.dart | 2 +- mobile/openapi/lib/auth/authentication.dart | 2 +- mobile/openapi/lib/auth/http_basic_auth.dart | 2 +- mobile/openapi/lib/auth/http_bearer_auth.dart | 2 +- mobile/openapi/lib/auth/oauth.dart | 2 +- mobile/openapi/lib/model/activity_create_dto.dart | 2 +- mobile/openapi/lib/model/activity_response_dto.dart | 2 +- .../openapi/lib/model/activity_statistics_response_dto.dart | 2 +- mobile/openapi/lib/model/add_users_dto.dart | 2 +- mobile/openapi/lib/model/admin_onboarding_update_dto.dart | 2 +- mobile/openapi/lib/model/album_count_response_dto.dart | 2 +- mobile/openapi/lib/model/album_response_dto.dart | 2 +- mobile/openapi/lib/model/album_user_add_dto.dart | 2 +- mobile/openapi/lib/model/album_user_create_dto.dart | 2 +- mobile/openapi/lib/model/album_user_response_dto.dart | 2 +- mobile/openapi/lib/model/album_user_role.dart | 2 +- mobile/openapi/lib/model/all_job_status_response_dto.dart | 2 +- mobile/openapi/lib/model/api_key_create_dto.dart | 2 +- mobile/openapi/lib/model/api_key_create_response_dto.dart | 2 +- mobile/openapi/lib/model/api_key_response_dto.dart | 2 +- mobile/openapi/lib/model/api_key_update_dto.dart | 2 +- mobile/openapi/lib/model/asset_bulk_delete_dto.dart | 2 +- mobile/openapi/lib/model/asset_bulk_update_dto.dart | 2 +- mobile/openapi/lib/model/asset_bulk_upload_check_dto.dart | 2 +- mobile/openapi/lib/model/asset_bulk_upload_check_item.dart | 2 +- .../lib/model/asset_bulk_upload_check_response_dto.dart | 2 +- mobile/openapi/lib/model/asset_bulk_upload_check_result.dart | 2 +- mobile/openapi/lib/model/asset_delta_sync_dto.dart | 2 +- mobile/openapi/lib/model/asset_delta_sync_response_dto.dart | 2 +- mobile/openapi/lib/model/asset_face_response_dto.dart | 2 +- mobile/openapi/lib/model/asset_face_update_dto.dart | 2 +- mobile/openapi/lib/model/asset_face_update_item.dart | 2 +- .../lib/model/asset_face_without_person_response_dto.dart | 2 +- mobile/openapi/lib/model/asset_file_upload_response_dto.dart | 2 +- mobile/openapi/lib/model/asset_full_sync_dto.dart | 2 +- mobile/openapi/lib/model/asset_ids_dto.dart | 2 +- mobile/openapi/lib/model/asset_ids_response_dto.dart | 2 +- mobile/openapi/lib/model/asset_job_name.dart | 2 +- mobile/openapi/lib/model/asset_jobs_dto.dart | 2 +- mobile/openapi/lib/model/asset_order.dart | 2 +- mobile/openapi/lib/model/asset_response_dto.dart | 2 +- mobile/openapi/lib/model/asset_stats_response_dto.dart | 2 +- mobile/openapi/lib/model/asset_type_enum.dart | 2 +- mobile/openapi/lib/model/audio_codec.dart | 2 +- mobile/openapi/lib/model/audit_deletes_response_dto.dart | 2 +- mobile/openapi/lib/model/bulk_id_response_dto.dart | 2 +- mobile/openapi/lib/model/bulk_ids_dto.dart | 2 +- mobile/openapi/lib/model/change_password_dto.dart | 2 +- mobile/openapi/lib/model/check_existing_assets_dto.dart | 2 +- .../openapi/lib/model/check_existing_assets_response_dto.dart | 2 +- mobile/openapi/lib/model/clip_config.dart | 2 +- mobile/openapi/lib/model/clip_mode.dart | 2 +- mobile/openapi/lib/model/colorspace.dart | 2 +- mobile/openapi/lib/model/cq_mode.dart | 2 +- mobile/openapi/lib/model/create_album_dto.dart | 2 +- mobile/openapi/lib/model/create_library_dto.dart | 2 +- .../openapi/lib/model/create_profile_image_response_dto.dart | 2 +- mobile/openapi/lib/model/create_tag_dto.dart | 2 +- mobile/openapi/lib/model/create_user_dto.dart | 2 +- mobile/openapi/lib/model/delete_user_dto.dart | 2 +- mobile/openapi/lib/model/download_archive_info.dart | 2 +- mobile/openapi/lib/model/download_info_dto.dart | 2 +- mobile/openapi/lib/model/download_response_dto.dart | 2 +- mobile/openapi/lib/model/entity_type.dart | 2 +- mobile/openapi/lib/model/exif_response_dto.dart | 2 +- mobile/openapi/lib/model/face_dto.dart | 2 +- mobile/openapi/lib/model/file_checksum_dto.dart | 2 +- mobile/openapi/lib/model/file_checksum_response_dto.dart | 2 +- mobile/openapi/lib/model/file_report_dto.dart | 2 +- mobile/openapi/lib/model/file_report_fix_dto.dart | 2 +- mobile/openapi/lib/model/file_report_item_dto.dart | 2 +- mobile/openapi/lib/model/image_format.dart | 2 +- mobile/openapi/lib/model/job_command.dart | 2 +- mobile/openapi/lib/model/job_command_dto.dart | 2 +- mobile/openapi/lib/model/job_counts_dto.dart | 2 +- mobile/openapi/lib/model/job_name.dart | 2 +- mobile/openapi/lib/model/job_settings_dto.dart | 2 +- mobile/openapi/lib/model/job_status_dto.dart | 2 +- mobile/openapi/lib/model/library_response_dto.dart | 2 +- mobile/openapi/lib/model/library_stats_response_dto.dart | 2 +- mobile/openapi/lib/model/library_type.dart | 2 +- mobile/openapi/lib/model/log_level.dart | 2 +- mobile/openapi/lib/model/login_credential_dto.dart | 2 +- mobile/openapi/lib/model/login_response_dto.dart | 2 +- mobile/openapi/lib/model/logout_response_dto.dart | 2 +- mobile/openapi/lib/model/map_marker_response_dto.dart | 2 +- mobile/openapi/lib/model/map_theme.dart | 2 +- mobile/openapi/lib/model/memory_create_dto.dart | 2 +- mobile/openapi/lib/model/memory_lane_response_dto.dart | 2 +- mobile/openapi/lib/model/memory_response_dto.dart | 2 +- mobile/openapi/lib/model/memory_type.dart | 2 +- mobile/openapi/lib/model/memory_update_dto.dart | 2 +- mobile/openapi/lib/model/merge_person_dto.dart | 2 +- mobile/openapi/lib/model/metadata_search_dto.dart | 2 +- mobile/openapi/lib/model/model_type.dart | 2 +- mobile/openapi/lib/model/o_auth_authorize_response_dto.dart | 2 +- mobile/openapi/lib/model/o_auth_callback_dto.dart | 2 +- mobile/openapi/lib/model/o_auth_config_dto.dart | 2 +- mobile/openapi/lib/model/on_this_day_dto.dart | 2 +- mobile/openapi/lib/model/partner_response_dto.dart | 2 +- mobile/openapi/lib/model/path_entity_type.dart | 2 +- mobile/openapi/lib/model/path_type.dart | 2 +- mobile/openapi/lib/model/people_response_dto.dart | 2 +- mobile/openapi/lib/model/people_update_dto.dart | 2 +- mobile/openapi/lib/model/people_update_item.dart | 2 +- mobile/openapi/lib/model/person_create_dto.dart | 2 +- mobile/openapi/lib/model/person_response_dto.dart | 2 +- mobile/openapi/lib/model/person_statistics_response_dto.dart | 2 +- mobile/openapi/lib/model/person_update_dto.dart | 2 +- mobile/openapi/lib/model/person_with_faces_response_dto.dart | 2 +- mobile/openapi/lib/model/places_response_dto.dart | 2 +- mobile/openapi/lib/model/queue_status_dto.dart | 2 +- mobile/openapi/lib/model/reaction_level.dart | 2 +- mobile/openapi/lib/model/reaction_type.dart | 2 +- mobile/openapi/lib/model/recognition_config.dart | 2 +- .../lib/model/reverse_geocoding_state_response_dto.dart | 2 +- mobile/openapi/lib/model/scan_library_dto.dart | 2 +- mobile/openapi/lib/model/search_album_response_dto.dart | 2 +- mobile/openapi/lib/model/search_asset_response_dto.dart | 2 +- mobile/openapi/lib/model/search_explore_item.dart | 2 +- mobile/openapi/lib/model/search_explore_response_dto.dart | 2 +- mobile/openapi/lib/model/search_facet_count_response_dto.dart | 2 +- mobile/openapi/lib/model/search_facet_response_dto.dart | 2 +- mobile/openapi/lib/model/search_response_dto.dart | 2 +- mobile/openapi/lib/model/search_suggestion_type.dart | 2 +- mobile/openapi/lib/model/server_config_dto.dart | 2 +- mobile/openapi/lib/model/server_features_dto.dart | 2 +- mobile/openapi/lib/model/server_info_response_dto.dart | 2 +- mobile/openapi/lib/model/server_media_types_response_dto.dart | 2 +- mobile/openapi/lib/model/server_ping_response.dart | 2 +- mobile/openapi/lib/model/server_stats_response_dto.dart | 2 +- mobile/openapi/lib/model/server_theme_dto.dart | 2 +- mobile/openapi/lib/model/server_version_response_dto.dart | 2 +- mobile/openapi/lib/model/session_response_dto.dart | 2 +- mobile/openapi/lib/model/shared_link_create_dto.dart | 2 +- mobile/openapi/lib/model/shared_link_edit_dto.dart | 2 +- mobile/openapi/lib/model/shared_link_response_dto.dart | 2 +- mobile/openapi/lib/model/shared_link_type.dart | 2 +- mobile/openapi/lib/model/sign_up_dto.dart | 2 +- mobile/openapi/lib/model/smart_info_response_dto.dart | 2 +- mobile/openapi/lib/model/smart_search_dto.dart | 2 +- mobile/openapi/lib/model/system_config_dto.dart | 2 +- mobile/openapi/lib/model/system_config_f_fmpeg_dto.dart | 2 +- mobile/openapi/lib/model/system_config_image_dto.dart | 2 +- mobile/openapi/lib/model/system_config_job_dto.dart | 2 +- mobile/openapi/lib/model/system_config_library_dto.dart | 2 +- mobile/openapi/lib/model/system_config_library_scan_dto.dart | 2 +- mobile/openapi/lib/model/system_config_library_watch_dto.dart | 2 +- mobile/openapi/lib/model/system_config_logging_dto.dart | 2 +- .../openapi/lib/model/system_config_machine_learning_dto.dart | 2 +- mobile/openapi/lib/model/system_config_map_dto.dart | 2 +- .../lib/model/system_config_new_version_check_dto.dart | 2 +- mobile/openapi/lib/model/system_config_notifications_dto.dart | 2 +- mobile/openapi/lib/model/system_config_o_auth_dto.dart | 2 +- .../openapi/lib/model/system_config_password_login_dto.dart | 2 +- .../lib/model/system_config_reverse_geocoding_dto.dart | 2 +- mobile/openapi/lib/model/system_config_server_dto.dart | 2 +- mobile/openapi/lib/model/system_config_smtp_dto.dart | 2 +- .../openapi/lib/model/system_config_smtp_transport_dto.dart | 2 +- .../openapi/lib/model/system_config_storage_template_dto.dart | 2 +- .../lib/model/system_config_template_storage_option_dto.dart | 2 +- mobile/openapi/lib/model/system_config_theme_dto.dart | 2 +- mobile/openapi/lib/model/system_config_trash_dto.dart | 2 +- mobile/openapi/lib/model/system_config_user_dto.dart | 2 +- mobile/openapi/lib/model/tag_response_dto.dart | 2 +- mobile/openapi/lib/model/tag_type_enum.dart | 2 +- mobile/openapi/lib/model/thumbnail_format.dart | 2 +- mobile/openapi/lib/model/time_bucket_response_dto.dart | 2 +- mobile/openapi/lib/model/time_bucket_size.dart | 2 +- mobile/openapi/lib/model/tone_mapping.dart | 2 +- mobile/openapi/lib/model/transcode_hw_accel.dart | 2 +- mobile/openapi/lib/model/transcode_policy.dart | 2 +- mobile/openapi/lib/model/update_album_dto.dart | 2 +- mobile/openapi/lib/model/update_album_user_dto.dart | 2 +- mobile/openapi/lib/model/update_asset_dto.dart | 2 +- mobile/openapi/lib/model/update_library_dto.dart | 2 +- mobile/openapi/lib/model/update_partner_dto.dart | 2 +- mobile/openapi/lib/model/update_stack_parent_dto.dart | 2 +- mobile/openapi/lib/model/update_tag_dto.dart | 2 +- mobile/openapi/lib/model/update_user_dto.dart | 2 +- mobile/openapi/lib/model/usage_by_user_dto.dart | 2 +- mobile/openapi/lib/model/user_avatar_color.dart | 2 +- mobile/openapi/lib/model/user_dto.dart | 2 +- mobile/openapi/lib/model/user_response_dto.dart | 2 +- mobile/openapi/lib/model/user_status.dart | 2 +- .../openapi/lib/model/validate_access_token_response_dto.dart | 2 +- mobile/openapi/lib/model/validate_library_dto.dart | 2 +- .../lib/model/validate_library_import_path_response_dto.dart | 2 +- mobile/openapi/lib/model/validate_library_response_dto.dart | 2 +- mobile/openapi/lib/model/video_codec.dart | 2 +- mobile/openapi/pubspec.yaml | 4 ++-- mobile/openapi/test/activity_api_test.dart | 2 +- mobile/openapi/test/activity_create_dto_test.dart | 2 +- mobile/openapi/test/activity_response_dto_test.dart | 2 +- .../openapi/test/activity_statistics_response_dto_test.dart | 2 +- mobile/openapi/test/add_users_dto_test.dart | 2 +- mobile/openapi/test/admin_onboarding_update_dto_test.dart | 2 +- mobile/openapi/test/album_api_test.dart | 2 +- mobile/openapi/test/album_count_response_dto_test.dart | 2 +- mobile/openapi/test/album_response_dto_test.dart | 2 +- mobile/openapi/test/album_user_add_dto_test.dart | 2 +- mobile/openapi/test/album_user_create_dto_test.dart | 2 +- mobile/openapi/test/album_user_response_dto_test.dart | 2 +- mobile/openapi/test/album_user_role_test.dart | 2 +- mobile/openapi/test/all_job_status_response_dto_test.dart | 2 +- mobile/openapi/test/api_key_api_test.dart | 2 +- mobile/openapi/test/api_key_create_dto_test.dart | 2 +- mobile/openapi/test/api_key_create_response_dto_test.dart | 2 +- mobile/openapi/test/api_key_response_dto_test.dart | 2 +- mobile/openapi/test/api_key_update_dto_test.dart | 2 +- mobile/openapi/test/asset_api_test.dart | 2 +- mobile/openapi/test/asset_bulk_delete_dto_test.dart | 2 +- mobile/openapi/test/asset_bulk_update_dto_test.dart | 2 +- mobile/openapi/test/asset_bulk_upload_check_dto_test.dart | 2 +- mobile/openapi/test/asset_bulk_upload_check_item_test.dart | 2 +- .../test/asset_bulk_upload_check_response_dto_test.dart | 2 +- mobile/openapi/test/asset_bulk_upload_check_result_test.dart | 2 +- mobile/openapi/test/asset_delta_sync_dto_test.dart | 2 +- mobile/openapi/test/asset_delta_sync_response_dto_test.dart | 2 +- mobile/openapi/test/asset_face_response_dto_test.dart | 2 +- mobile/openapi/test/asset_face_update_dto_test.dart | 2 +- mobile/openapi/test/asset_face_update_item_test.dart | 2 +- .../test/asset_face_without_person_response_dto_test.dart | 2 +- mobile/openapi/test/asset_file_upload_response_dto_test.dart | 2 +- mobile/openapi/test/asset_full_sync_dto_test.dart | 2 +- mobile/openapi/test/asset_ids_dto_test.dart | 2 +- mobile/openapi/test/asset_ids_response_dto_test.dart | 2 +- mobile/openapi/test/asset_job_name_test.dart | 2 +- mobile/openapi/test/asset_jobs_dto_test.dart | 2 +- mobile/openapi/test/asset_order_test.dart | 2 +- mobile/openapi/test/asset_response_dto_test.dart | 2 +- mobile/openapi/test/asset_stats_response_dto_test.dart | 2 +- mobile/openapi/test/asset_type_enum_test.dart | 2 +- mobile/openapi/test/audio_codec_test.dart | 2 +- mobile/openapi/test/audit_api_test.dart | 2 +- mobile/openapi/test/audit_deletes_response_dto_test.dart | 2 +- mobile/openapi/test/authentication_api_test.dart | 2 +- mobile/openapi/test/bulk_id_response_dto_test.dart | 2 +- mobile/openapi/test/bulk_ids_dto_test.dart | 2 +- mobile/openapi/test/change_password_dto_test.dart | 2 +- mobile/openapi/test/check_existing_assets_dto_test.dart | 2 +- .../openapi/test/check_existing_assets_response_dto_test.dart | 2 +- mobile/openapi/test/clip_config_test.dart | 2 +- mobile/openapi/test/clip_mode_test.dart | 2 +- mobile/openapi/test/colorspace_test.dart | 2 +- mobile/openapi/test/cq_mode_test.dart | 2 +- mobile/openapi/test/create_album_dto_test.dart | 2 +- mobile/openapi/test/create_library_dto_test.dart | 2 +- .../openapi/test/create_profile_image_response_dto_test.dart | 2 +- mobile/openapi/test/create_tag_dto_test.dart | 2 +- mobile/openapi/test/create_user_dto_test.dart | 2 +- mobile/openapi/test/delete_user_dto_test.dart | 2 +- mobile/openapi/test/download_api_test.dart | 2 +- mobile/openapi/test/download_archive_info_test.dart | 2 +- mobile/openapi/test/download_info_dto_test.dart | 2 +- mobile/openapi/test/download_response_dto_test.dart | 2 +- mobile/openapi/test/entity_type_test.dart | 2 +- mobile/openapi/test/exif_response_dto_test.dart | 2 +- mobile/openapi/test/face_api_test.dart | 2 +- mobile/openapi/test/face_dto_test.dart | 2 +- mobile/openapi/test/file_checksum_dto_test.dart | 2 +- mobile/openapi/test/file_checksum_response_dto_test.dart | 2 +- mobile/openapi/test/file_report_api_test.dart | 2 +- mobile/openapi/test/file_report_dto_test.dart | 2 +- mobile/openapi/test/file_report_fix_dto_test.dart | 2 +- mobile/openapi/test/file_report_item_dto_test.dart | 2 +- mobile/openapi/test/image_format_test.dart | 2 +- mobile/openapi/test/job_api_test.dart | 2 +- mobile/openapi/test/job_command_dto_test.dart | 2 +- mobile/openapi/test/job_command_test.dart | 2 +- mobile/openapi/test/job_counts_dto_test.dart | 2 +- mobile/openapi/test/job_name_test.dart | 2 +- mobile/openapi/test/job_settings_dto_test.dart | 2 +- mobile/openapi/test/job_status_dto_test.dart | 2 +- mobile/openapi/test/library_api_test.dart | 2 +- mobile/openapi/test/library_response_dto_test.dart | 2 +- mobile/openapi/test/library_stats_response_dto_test.dart | 2 +- mobile/openapi/test/library_type_test.dart | 2 +- mobile/openapi/test/log_level_test.dart | 2 +- mobile/openapi/test/login_credential_dto_test.dart | 2 +- mobile/openapi/test/login_response_dto_test.dart | 2 +- mobile/openapi/test/logout_response_dto_test.dart | 2 +- mobile/openapi/test/map_marker_response_dto_test.dart | 2 +- mobile/openapi/test/map_theme_test.dart | 2 +- mobile/openapi/test/memory_api_test.dart | 2 +- mobile/openapi/test/memory_create_dto_test.dart | 2 +- mobile/openapi/test/memory_lane_response_dto_test.dart | 2 +- mobile/openapi/test/memory_response_dto_test.dart | 2 +- mobile/openapi/test/memory_type_test.dart | 2 +- mobile/openapi/test/memory_update_dto_test.dart | 2 +- mobile/openapi/test/merge_person_dto_test.dart | 2 +- mobile/openapi/test/metadata_search_dto_test.dart | 2 +- mobile/openapi/test/model_type_test.dart | 2 +- mobile/openapi/test/o_auth_api_test.dart | 2 +- mobile/openapi/test/o_auth_authorize_response_dto_test.dart | 2 +- mobile/openapi/test/o_auth_callback_dto_test.dart | 2 +- mobile/openapi/test/o_auth_config_dto_test.dart | 2 +- mobile/openapi/test/on_this_day_dto_test.dart | 2 +- mobile/openapi/test/partner_api_test.dart | 2 +- mobile/openapi/test/partner_response_dto_test.dart | 2 +- mobile/openapi/test/path_entity_type_test.dart | 2 +- mobile/openapi/test/path_type_test.dart | 2 +- mobile/openapi/test/people_response_dto_test.dart | 2 +- mobile/openapi/test/people_update_dto_test.dart | 2 +- mobile/openapi/test/people_update_item_test.dart | 2 +- mobile/openapi/test/person_api_test.dart | 2 +- mobile/openapi/test/person_create_dto_test.dart | 2 +- mobile/openapi/test/person_response_dto_test.dart | 2 +- mobile/openapi/test/person_statistics_response_dto_test.dart | 2 +- mobile/openapi/test/person_update_dto_test.dart | 2 +- mobile/openapi/test/person_with_faces_response_dto_test.dart | 2 +- mobile/openapi/test/places_response_dto_test.dart | 2 +- mobile/openapi/test/queue_status_dto_test.dart | 2 +- mobile/openapi/test/reaction_level_test.dart | 2 +- mobile/openapi/test/reaction_type_test.dart | 2 +- mobile/openapi/test/recognition_config_test.dart | 2 +- .../test/reverse_geocoding_state_response_dto_test.dart | 2 +- mobile/openapi/test/scan_library_dto_test.dart | 2 +- mobile/openapi/test/search_album_response_dto_test.dart | 2 +- mobile/openapi/test/search_api_test.dart | 2 +- mobile/openapi/test/search_asset_response_dto_test.dart | 2 +- mobile/openapi/test/search_explore_item_test.dart | 2 +- mobile/openapi/test/search_explore_response_dto_test.dart | 2 +- mobile/openapi/test/search_facet_count_response_dto_test.dart | 2 +- mobile/openapi/test/search_facet_response_dto_test.dart | 2 +- mobile/openapi/test/search_response_dto_test.dart | 2 +- mobile/openapi/test/search_suggestion_type_test.dart | 2 +- mobile/openapi/test/server_config_dto_test.dart | 2 +- mobile/openapi/test/server_features_dto_test.dart | 2 +- mobile/openapi/test/server_info_api_test.dart | 2 +- mobile/openapi/test/server_info_response_dto_test.dart | 2 +- mobile/openapi/test/server_media_types_response_dto_test.dart | 2 +- mobile/openapi/test/server_ping_response_test.dart | 2 +- mobile/openapi/test/server_stats_response_dto_test.dart | 2 +- mobile/openapi/test/server_theme_dto_test.dart | 2 +- mobile/openapi/test/server_version_response_dto_test.dart | 2 +- mobile/openapi/test/session_response_dto_test.dart | 2 +- mobile/openapi/test/sessions_api_test.dart | 2 +- mobile/openapi/test/shared_link_api_test.dart | 2 +- mobile/openapi/test/shared_link_create_dto_test.dart | 2 +- mobile/openapi/test/shared_link_edit_dto_test.dart | 2 +- mobile/openapi/test/shared_link_response_dto_test.dart | 2 +- mobile/openapi/test/shared_link_type_test.dart | 2 +- mobile/openapi/test/sign_up_dto_test.dart | 2 +- mobile/openapi/test/smart_info_response_dto_test.dart | 2 +- mobile/openapi/test/smart_search_dto_test.dart | 2 +- mobile/openapi/test/sync_api_test.dart | 2 +- mobile/openapi/test/system_config_api_test.dart | 2 +- mobile/openapi/test/system_config_dto_test.dart | 2 +- mobile/openapi/test/system_config_f_fmpeg_dto_test.dart | 2 +- mobile/openapi/test/system_config_image_dto_test.dart | 2 +- mobile/openapi/test/system_config_job_dto_test.dart | 2 +- mobile/openapi/test/system_config_library_dto_test.dart | 2 +- mobile/openapi/test/system_config_library_scan_dto_test.dart | 2 +- mobile/openapi/test/system_config_library_watch_dto_test.dart | 2 +- mobile/openapi/test/system_config_logging_dto_test.dart | 2 +- .../openapi/test/system_config_machine_learning_dto_test.dart | 2 +- mobile/openapi/test/system_config_map_dto_test.dart | 2 +- .../test/system_config_new_version_check_dto_test.dart | 2 +- mobile/openapi/test/system_config_notifications_dto_test.dart | 2 +- mobile/openapi/test/system_config_o_auth_dto_test.dart | 2 +- .../openapi/test/system_config_password_login_dto_test.dart | 2 +- .../test/system_config_reverse_geocoding_dto_test.dart | 2 +- mobile/openapi/test/system_config_server_dto_test.dart | 2 +- mobile/openapi/test/system_config_smtp_dto_test.dart | 2 +- .../openapi/test/system_config_smtp_transport_dto_test.dart | 2 +- .../openapi/test/system_config_storage_template_dto_test.dart | 2 +- .../test/system_config_template_storage_option_dto_test.dart | 2 +- mobile/openapi/test/system_config_theme_dto_test.dart | 2 +- mobile/openapi/test/system_config_trash_dto_test.dart | 2 +- mobile/openapi/test/system_config_user_dto_test.dart | 2 +- mobile/openapi/test/system_metadata_api_test.dart | 2 +- mobile/openapi/test/tag_api_test.dart | 2 +- mobile/openapi/test/tag_response_dto_test.dart | 2 +- mobile/openapi/test/tag_type_enum_test.dart | 2 +- mobile/openapi/test/thumbnail_format_test.dart | 2 +- mobile/openapi/test/time_bucket_response_dto_test.dart | 2 +- mobile/openapi/test/time_bucket_size_test.dart | 2 +- mobile/openapi/test/timeline_api_test.dart | 2 +- mobile/openapi/test/tone_mapping_test.dart | 2 +- mobile/openapi/test/transcode_hw_accel_test.dart | 2 +- mobile/openapi/test/transcode_policy_test.dart | 2 +- mobile/openapi/test/trash_api_test.dart | 2 +- mobile/openapi/test/update_album_dto_test.dart | 2 +- mobile/openapi/test/update_album_user_dto_test.dart | 2 +- mobile/openapi/test/update_asset_dto_test.dart | 2 +- mobile/openapi/test/update_library_dto_test.dart | 2 +- mobile/openapi/test/update_partner_dto_test.dart | 2 +- mobile/openapi/test/update_stack_parent_dto_test.dart | 2 +- mobile/openapi/test/update_tag_dto_test.dart | 2 +- mobile/openapi/test/update_user_dto_test.dart | 2 +- mobile/openapi/test/usage_by_user_dto_test.dart | 2 +- mobile/openapi/test/user_api_test.dart | 2 +- mobile/openapi/test/user_avatar_color_test.dart | 2 +- mobile/openapi/test/user_dto_test.dart | 2 +- mobile/openapi/test/user_response_dto_test.dart | 2 +- mobile/openapi/test/user_status_test.dart | 2 +- .../openapi/test/validate_access_token_response_dto_test.dart | 2 +- mobile/openapi/test/validate_library_dto_test.dart | 2 +- .../test/validate_library_import_path_response_dto_test.dart | 2 +- mobile/openapi/test/validate_library_response_dto_test.dart | 2 +- mobile/openapi/test/video_codec_test.dart | 2 +- open-api/bin/generate-open-api.sh | 2 +- open-api/openapitools.json | 2 +- 436 files changed, 437 insertions(+), 436 deletions(-) diff --git a/mobile/openapi/.openapi-generator/VERSION b/mobile/openapi/.openapi-generator/VERSION index 4b49d9bb63..18bb4182dd 100644 --- a/mobile/openapi/.openapi-generator/VERSION +++ b/mobile/openapi/.openapi-generator/VERSION @@ -1 +1 @@ -7.2.0 \ No newline at end of file +7.5.0 diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 753c9a90a8..b9077d9350 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -4,6 +4,7 @@ Immich API This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project: - API version: 1.105.1 +- Generator version: 7.5.0 - Build package: org.openapitools.codegen.languages.DartClientCodegen ## Requirements diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 02da5876dc..1629dbb33c 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/api/activity_api.dart b/mobile/openapi/lib/api/activity_api.dart index 5d42af87b9..bf2c168fc9 100644 --- a/mobile/openapi/lib/api/activity_api.dart +++ b/mobile/openapi/lib/api/activity_api.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/api/album_api.dart b/mobile/openapi/lib/api/album_api.dart index 4596eff88b..52b3e466b4 100644 --- a/mobile/openapi/lib/api/album_api.dart +++ b/mobile/openapi/lib/api/album_api.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/api/api_key_api.dart b/mobile/openapi/lib/api/api_key_api.dart index 29e50a8865..43cb233114 100644 --- a/mobile/openapi/lib/api/api_key_api.dart +++ b/mobile/openapi/lib/api/api_key_api.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/api/asset_api.dart b/mobile/openapi/lib/api/asset_api.dart index 0363ee73b0..dba33fc181 100644 --- a/mobile/openapi/lib/api/asset_api.dart +++ b/mobile/openapi/lib/api/asset_api.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/api/audit_api.dart b/mobile/openapi/lib/api/audit_api.dart index 83dde34da7..f6d71eafdb 100644 --- a/mobile/openapi/lib/api/audit_api.dart +++ b/mobile/openapi/lib/api/audit_api.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/api/authentication_api.dart b/mobile/openapi/lib/api/authentication_api.dart index 62f8be353a..c2aa50e7e7 100644 --- a/mobile/openapi/lib/api/authentication_api.dart +++ b/mobile/openapi/lib/api/authentication_api.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/api/download_api.dart b/mobile/openapi/lib/api/download_api.dart index fa157366a8..2676313fae 100644 --- a/mobile/openapi/lib/api/download_api.dart +++ b/mobile/openapi/lib/api/download_api.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/api/face_api.dart b/mobile/openapi/lib/api/face_api.dart index 6ea96760ab..5d21a223a1 100644 --- a/mobile/openapi/lib/api/face_api.dart +++ b/mobile/openapi/lib/api/face_api.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/api/file_report_api.dart b/mobile/openapi/lib/api/file_report_api.dart index df307e12c7..4919dfeaf1 100644 --- a/mobile/openapi/lib/api/file_report_api.dart +++ b/mobile/openapi/lib/api/file_report_api.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/api/job_api.dart b/mobile/openapi/lib/api/job_api.dart index 6e1aa3c192..d7be8237d7 100644 --- a/mobile/openapi/lib/api/job_api.dart +++ b/mobile/openapi/lib/api/job_api.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/api/library_api.dart b/mobile/openapi/lib/api/library_api.dart index befd0aeeff..7a980535fa 100644 --- a/mobile/openapi/lib/api/library_api.dart +++ b/mobile/openapi/lib/api/library_api.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/api/memory_api.dart b/mobile/openapi/lib/api/memory_api.dart index 6b4a619b52..cbf2ad3e88 100644 --- a/mobile/openapi/lib/api/memory_api.dart +++ b/mobile/openapi/lib/api/memory_api.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/api/o_auth_api.dart b/mobile/openapi/lib/api/o_auth_api.dart index 0337384798..9c238f01dc 100644 --- a/mobile/openapi/lib/api/o_auth_api.dart +++ b/mobile/openapi/lib/api/o_auth_api.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/api/partner_api.dart b/mobile/openapi/lib/api/partner_api.dart index e798c5172e..6dac6286b1 100644 --- a/mobile/openapi/lib/api/partner_api.dart +++ b/mobile/openapi/lib/api/partner_api.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/api/person_api.dart b/mobile/openapi/lib/api/person_api.dart index 411c75d715..cf0d289388 100644 --- a/mobile/openapi/lib/api/person_api.dart +++ b/mobile/openapi/lib/api/person_api.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/api/search_api.dart b/mobile/openapi/lib/api/search_api.dart index 15654fc066..21af2d57cb 100644 --- a/mobile/openapi/lib/api/search_api.dart +++ b/mobile/openapi/lib/api/search_api.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/api/server_info_api.dart b/mobile/openapi/lib/api/server_info_api.dart index b67045add1..db72dd370d 100644 --- a/mobile/openapi/lib/api/server_info_api.dart +++ b/mobile/openapi/lib/api/server_info_api.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/api/sessions_api.dart b/mobile/openapi/lib/api/sessions_api.dart index bc0fed71e1..fcc6cb836f 100644 --- a/mobile/openapi/lib/api/sessions_api.dart +++ b/mobile/openapi/lib/api/sessions_api.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/api/shared_link_api.dart b/mobile/openapi/lib/api/shared_link_api.dart index 23ec4bcbae..7d4ef098b0 100644 --- a/mobile/openapi/lib/api/shared_link_api.dart +++ b/mobile/openapi/lib/api/shared_link_api.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/api/sync_api.dart b/mobile/openapi/lib/api/sync_api.dart index f131d54e9d..f94eb88081 100644 --- a/mobile/openapi/lib/api/sync_api.dart +++ b/mobile/openapi/lib/api/sync_api.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/api/system_config_api.dart b/mobile/openapi/lib/api/system_config_api.dart index 276f8c07df..1a5f381b43 100644 --- a/mobile/openapi/lib/api/system_config_api.dart +++ b/mobile/openapi/lib/api/system_config_api.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/api/system_metadata_api.dart b/mobile/openapi/lib/api/system_metadata_api.dart index f3952fda8a..822a54b14f 100644 --- a/mobile/openapi/lib/api/system_metadata_api.dart +++ b/mobile/openapi/lib/api/system_metadata_api.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/api/tag_api.dart b/mobile/openapi/lib/api/tag_api.dart index c9b75a7672..539e3c8e27 100644 --- a/mobile/openapi/lib/api/tag_api.dart +++ b/mobile/openapi/lib/api/tag_api.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/api/timeline_api.dart b/mobile/openapi/lib/api/timeline_api.dart index 0813f3e00c..4acb98bdf2 100644 --- a/mobile/openapi/lib/api/timeline_api.dart +++ b/mobile/openapi/lib/api/timeline_api.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/api/trash_api.dart b/mobile/openapi/lib/api/trash_api.dart index 91f1d1a747..9c346870ec 100644 --- a/mobile/openapi/lib/api/trash_api.dart +++ b/mobile/openapi/lib/api/trash_api.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/api/user_api.dart b/mobile/openapi/lib/api/user_api.dart index 241c1698c3..42a9532e28 100644 --- a/mobile/openapi/lib/api/user_api.dart +++ b/mobile/openapi/lib/api/user_api.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 3b21ff6e0f..ed6cec3a09 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/api_exception.dart b/mobile/openapi/lib/api_exception.dart index 796f7f7ee7..53077d686d 100644 --- a/mobile/openapi/lib/api_exception.dart +++ b/mobile/openapi/lib/api_exception.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/api_helper.dart b/mobile/openapi/lib/api_helper.dart index 8d92ad1f0a..a43ed6ecbf 100644 --- a/mobile/openapi/lib/api_helper.dart +++ b/mobile/openapi/lib/api_helper.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/auth/api_key_auth.dart b/mobile/openapi/lib/auth/api_key_auth.dart index 84dc2955c2..6c5621798f 100644 --- a/mobile/openapi/lib/auth/api_key_auth.dart +++ b/mobile/openapi/lib/auth/api_key_auth.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/auth/authentication.dart b/mobile/openapi/lib/auth/authentication.dart index 1b1b8ae11e..5377fb6f34 100644 --- a/mobile/openapi/lib/auth/authentication.dart +++ b/mobile/openapi/lib/auth/authentication.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/auth/http_basic_auth.dart b/mobile/openapi/lib/auth/http_basic_auth.dart index dfedaa50d0..5e8b1c4147 100644 --- a/mobile/openapi/lib/auth/http_basic_auth.dart +++ b/mobile/openapi/lib/auth/http_basic_auth.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/auth/http_bearer_auth.dart b/mobile/openapi/lib/auth/http_bearer_auth.dart index eddf3a59cb..847dc056e1 100644 --- a/mobile/openapi/lib/auth/http_bearer_auth.dart +++ b/mobile/openapi/lib/auth/http_bearer_auth.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/auth/oauth.dart b/mobile/openapi/lib/auth/oauth.dart index e9e7d784c3..73fd8202dc 100644 --- a/mobile/openapi/lib/auth/oauth.dart +++ b/mobile/openapi/lib/auth/oauth.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/activity_create_dto.dart b/mobile/openapi/lib/model/activity_create_dto.dart index adda496887..b54fa2ca72 100644 --- a/mobile/openapi/lib/model/activity_create_dto.dart +++ b/mobile/openapi/lib/model/activity_create_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/activity_response_dto.dart b/mobile/openapi/lib/model/activity_response_dto.dart index 83e7884f7a..d276d19e6c 100644 --- a/mobile/openapi/lib/model/activity_response_dto.dart +++ b/mobile/openapi/lib/model/activity_response_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/activity_statistics_response_dto.dart b/mobile/openapi/lib/model/activity_statistics_response_dto.dart index 2bb4e1d5bf..20d4696b1b 100644 --- a/mobile/openapi/lib/model/activity_statistics_response_dto.dart +++ b/mobile/openapi/lib/model/activity_statistics_response_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/add_users_dto.dart b/mobile/openapi/lib/model/add_users_dto.dart index ad58577b53..7ecb0de5e8 100644 --- a/mobile/openapi/lib/model/add_users_dto.dart +++ b/mobile/openapi/lib/model/add_users_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/admin_onboarding_update_dto.dart b/mobile/openapi/lib/model/admin_onboarding_update_dto.dart index 50c4ae090e..2277f0958c 100644 --- a/mobile/openapi/lib/model/admin_onboarding_update_dto.dart +++ b/mobile/openapi/lib/model/admin_onboarding_update_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/album_count_response_dto.dart b/mobile/openapi/lib/model/album_count_response_dto.dart index 62121c1f35..531a17a083 100644 --- a/mobile/openapi/lib/model/album_count_response_dto.dart +++ b/mobile/openapi/lib/model/album_count_response_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/album_response_dto.dart b/mobile/openapi/lib/model/album_response_dto.dart index 79c75bc58c..fcdb6c0960 100644 --- a/mobile/openapi/lib/model/album_response_dto.dart +++ b/mobile/openapi/lib/model/album_response_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/album_user_add_dto.dart b/mobile/openapi/lib/model/album_user_add_dto.dart index 0e259828f8..e654a2ff5d 100644 --- a/mobile/openapi/lib/model/album_user_add_dto.dart +++ b/mobile/openapi/lib/model/album_user_add_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/album_user_create_dto.dart b/mobile/openapi/lib/model/album_user_create_dto.dart index 4c8f2ec6d1..708acd472b 100644 --- a/mobile/openapi/lib/model/album_user_create_dto.dart +++ b/mobile/openapi/lib/model/album_user_create_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/album_user_response_dto.dart b/mobile/openapi/lib/model/album_user_response_dto.dart index 896c1cbb8f..8f86cf254e 100644 --- a/mobile/openapi/lib/model/album_user_response_dto.dart +++ b/mobile/openapi/lib/model/album_user_response_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/album_user_role.dart b/mobile/openapi/lib/model/album_user_role.dart index 991d6d182c..c0d61cd7f5 100644 --- a/mobile/openapi/lib/model/album_user_role.dart +++ b/mobile/openapi/lib/model/album_user_role.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first 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 c7760f043b..679740658f 100644 --- a/mobile/openapi/lib/model/all_job_status_response_dto.dart +++ b/mobile/openapi/lib/model/all_job_status_response_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/api_key_create_dto.dart b/mobile/openapi/lib/model/api_key_create_dto.dart index 91cf836b50..f6ff8e5f97 100644 --- a/mobile/openapi/lib/model/api_key_create_dto.dart +++ b/mobile/openapi/lib/model/api_key_create_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first 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 d117d7a5e4..93065654ac 100644 --- a/mobile/openapi/lib/model/api_key_create_response_dto.dart +++ b/mobile/openapi/lib/model/api_key_create_response_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/api_key_response_dto.dart b/mobile/openapi/lib/model/api_key_response_dto.dart index 157bb83e7e..764d5ec973 100644 --- a/mobile/openapi/lib/model/api_key_response_dto.dart +++ b/mobile/openapi/lib/model/api_key_response_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/api_key_update_dto.dart b/mobile/openapi/lib/model/api_key_update_dto.dart index c7002482da..318f4936e1 100644 --- a/mobile/openapi/lib/model/api_key_update_dto.dart +++ b/mobile/openapi/lib/model/api_key_update_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/asset_bulk_delete_dto.dart b/mobile/openapi/lib/model/asset_bulk_delete_dto.dart index 7edb777dc9..0f6913a7f4 100644 --- a/mobile/openapi/lib/model/asset_bulk_delete_dto.dart +++ b/mobile/openapi/lib/model/asset_bulk_delete_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/asset_bulk_update_dto.dart b/mobile/openapi/lib/model/asset_bulk_update_dto.dart index 1a100b52ac..baeabc35e5 100644 --- a/mobile/openapi/lib/model/asset_bulk_update_dto.dart +++ b/mobile/openapi/lib/model/asset_bulk_update_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first 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 9a88201a3f..55ea41b598 100644 --- a/mobile/openapi/lib/model/asset_bulk_upload_check_dto.dart +++ b/mobile/openapi/lib/model/asset_bulk_upload_check_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first 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 0d20bd8b3a..16294cdae6 100644 --- a/mobile/openapi/lib/model/asset_bulk_upload_check_item.dart +++ b/mobile/openapi/lib/model/asset_bulk_upload_check_item.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first 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 2c4f821a7b..5bfacbff57 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 @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first 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 ac252baec3..737186e589 100644 --- a/mobile/openapi/lib/model/asset_bulk_upload_check_result.dart +++ b/mobile/openapi/lib/model/asset_bulk_upload_check_result.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/asset_delta_sync_dto.dart b/mobile/openapi/lib/model/asset_delta_sync_dto.dart index c7f3ce618a..a5ee10f33e 100644 --- a/mobile/openapi/lib/model/asset_delta_sync_dto.dart +++ b/mobile/openapi/lib/model/asset_delta_sync_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first 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 5d7679e734..3b14fa68cf 100644 --- a/mobile/openapi/lib/model/asset_delta_sync_response_dto.dart +++ b/mobile/openapi/lib/model/asset_delta_sync_response_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/asset_face_response_dto.dart b/mobile/openapi/lib/model/asset_face_response_dto.dart index 74924f6a5b..812b165caa 100644 --- a/mobile/openapi/lib/model/asset_face_response_dto.dart +++ b/mobile/openapi/lib/model/asset_face_response_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/asset_face_update_dto.dart b/mobile/openapi/lib/model/asset_face_update_dto.dart index 6c68dc761b..58def49ae1 100644 --- a/mobile/openapi/lib/model/asset_face_update_dto.dart +++ b/mobile/openapi/lib/model/asset_face_update_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/asset_face_update_item.dart b/mobile/openapi/lib/model/asset_face_update_item.dart index 36bf94232c..5ea37ea4db 100644 --- a/mobile/openapi/lib/model/asset_face_update_item.dart +++ b/mobile/openapi/lib/model/asset_face_update_item.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first 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 7049386978..893f8ff353 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 @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/asset_file_upload_response_dto.dart b/mobile/openapi/lib/model/asset_file_upload_response_dto.dart index f75ec6addc..f198903a40 100644 --- a/mobile/openapi/lib/model/asset_file_upload_response_dto.dart +++ b/mobile/openapi/lib/model/asset_file_upload_response_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/asset_full_sync_dto.dart b/mobile/openapi/lib/model/asset_full_sync_dto.dart index fba8d65381..e08bdaddf3 100644 --- a/mobile/openapi/lib/model/asset_full_sync_dto.dart +++ b/mobile/openapi/lib/model/asset_full_sync_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/asset_ids_dto.dart b/mobile/openapi/lib/model/asset_ids_dto.dart index 1b4fceeaa8..c8c7a69b89 100644 --- a/mobile/openapi/lib/model/asset_ids_dto.dart +++ b/mobile/openapi/lib/model/asset_ids_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/asset_ids_response_dto.dart b/mobile/openapi/lib/model/asset_ids_response_dto.dart index 81aef4acac..a642c0924c 100644 --- a/mobile/openapi/lib/model/asset_ids_response_dto.dart +++ b/mobile/openapi/lib/model/asset_ids_response_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/asset_job_name.dart b/mobile/openapi/lib/model/asset_job_name.dart index 1191e55d84..a5b42f4ee5 100644 --- a/mobile/openapi/lib/model/asset_job_name.dart +++ b/mobile/openapi/lib/model/asset_job_name.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/asset_jobs_dto.dart b/mobile/openapi/lib/model/asset_jobs_dto.dart index 996c42bdb1..16ed2644fd 100644 --- a/mobile/openapi/lib/model/asset_jobs_dto.dart +++ b/mobile/openapi/lib/model/asset_jobs_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/asset_order.dart b/mobile/openapi/lib/model/asset_order.dart index 9afa72aa27..ca04e2b78f 100644 --- a/mobile/openapi/lib/model/asset_order.dart +++ b/mobile/openapi/lib/model/asset_order.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/asset_response_dto.dart b/mobile/openapi/lib/model/asset_response_dto.dart index 6ba7f27d60..86dec6392f 100644 --- a/mobile/openapi/lib/model/asset_response_dto.dart +++ b/mobile/openapi/lib/model/asset_response_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/asset_stats_response_dto.dart b/mobile/openapi/lib/model/asset_stats_response_dto.dart index d3c5f71ff7..c21d7fdbff 100644 --- a/mobile/openapi/lib/model/asset_stats_response_dto.dart +++ b/mobile/openapi/lib/model/asset_stats_response_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/asset_type_enum.dart b/mobile/openapi/lib/model/asset_type_enum.dart index ec98d5ca83..1022beb24e 100644 --- a/mobile/openapi/lib/model/asset_type_enum.dart +++ b/mobile/openapi/lib/model/asset_type_enum.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/audio_codec.dart b/mobile/openapi/lib/model/audio_codec.dart index bbc12f8dbc..ca195f7d06 100644 --- a/mobile/openapi/lib/model/audio_codec.dart +++ b/mobile/openapi/lib/model/audio_codec.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/audit_deletes_response_dto.dart b/mobile/openapi/lib/model/audit_deletes_response_dto.dart index 5980a0544e..690a52e811 100644 --- a/mobile/openapi/lib/model/audit_deletes_response_dto.dart +++ b/mobile/openapi/lib/model/audit_deletes_response_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/bulk_id_response_dto.dart b/mobile/openapi/lib/model/bulk_id_response_dto.dart index 957d906319..ef3cf2e0db 100644 --- a/mobile/openapi/lib/model/bulk_id_response_dto.dart +++ b/mobile/openapi/lib/model/bulk_id_response_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/bulk_ids_dto.dart b/mobile/openapi/lib/model/bulk_ids_dto.dart index ba2076844d..6942875f0a 100644 --- a/mobile/openapi/lib/model/bulk_ids_dto.dart +++ b/mobile/openapi/lib/model/bulk_ids_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/change_password_dto.dart b/mobile/openapi/lib/model/change_password_dto.dart index 3008f5b1c5..1074aaf74d 100644 --- a/mobile/openapi/lib/model/change_password_dto.dart +++ b/mobile/openapi/lib/model/change_password_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/check_existing_assets_dto.dart b/mobile/openapi/lib/model/check_existing_assets_dto.dart index cf3e4c2eae..49ef36cc09 100644 --- a/mobile/openapi/lib/model/check_existing_assets_dto.dart +++ b/mobile/openapi/lib/model/check_existing_assets_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first 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 db91cf1f0f..d8b0f43a6d 100644 --- a/mobile/openapi/lib/model/check_existing_assets_response_dto.dart +++ b/mobile/openapi/lib/model/check_existing_assets_response_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/clip_config.dart b/mobile/openapi/lib/model/clip_config.dart index 367102f3e8..5a1d429aea 100644 --- a/mobile/openapi/lib/model/clip_config.dart +++ b/mobile/openapi/lib/model/clip_config.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/clip_mode.dart b/mobile/openapi/lib/model/clip_mode.dart index f52525c38e..98a38759da 100644 --- a/mobile/openapi/lib/model/clip_mode.dart +++ b/mobile/openapi/lib/model/clip_mode.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/colorspace.dart b/mobile/openapi/lib/model/colorspace.dart index eb2cf3f681..e0c1658be5 100644 --- a/mobile/openapi/lib/model/colorspace.dart +++ b/mobile/openapi/lib/model/colorspace.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/cq_mode.dart b/mobile/openapi/lib/model/cq_mode.dart index cd918ce888..f660fabf1f 100644 --- a/mobile/openapi/lib/model/cq_mode.dart +++ b/mobile/openapi/lib/model/cq_mode.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/create_album_dto.dart b/mobile/openapi/lib/model/create_album_dto.dart index 7af3526a45..b6d55c967c 100644 --- a/mobile/openapi/lib/model/create_album_dto.dart +++ b/mobile/openapi/lib/model/create_album_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/create_library_dto.dart b/mobile/openapi/lib/model/create_library_dto.dart index 532ddd68e3..7d1ce0eea6 100644 --- a/mobile/openapi/lib/model/create_library_dto.dart +++ b/mobile/openapi/lib/model/create_library_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first 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 9e628efa8a..c9ae3ea651 100644 --- a/mobile/openapi/lib/model/create_profile_image_response_dto.dart +++ b/mobile/openapi/lib/model/create_profile_image_response_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/create_tag_dto.dart b/mobile/openapi/lib/model/create_tag_dto.dart index ee4a5fe649..31b194993d 100644 --- a/mobile/openapi/lib/model/create_tag_dto.dart +++ b/mobile/openapi/lib/model/create_tag_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/create_user_dto.dart b/mobile/openapi/lib/model/create_user_dto.dart index 6fbd887d7f..4b0bdd55da 100644 --- a/mobile/openapi/lib/model/create_user_dto.dart +++ b/mobile/openapi/lib/model/create_user_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/delete_user_dto.dart b/mobile/openapi/lib/model/delete_user_dto.dart index d62f40b1ee..a758991fa9 100644 --- a/mobile/openapi/lib/model/delete_user_dto.dart +++ b/mobile/openapi/lib/model/delete_user_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/download_archive_info.dart b/mobile/openapi/lib/model/download_archive_info.dart index 20cf8f9b5f..e324850bdc 100644 --- a/mobile/openapi/lib/model/download_archive_info.dart +++ b/mobile/openapi/lib/model/download_archive_info.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/download_info_dto.dart b/mobile/openapi/lib/model/download_info_dto.dart index 7de463b7cb..4c38769010 100644 --- a/mobile/openapi/lib/model/download_info_dto.dart +++ b/mobile/openapi/lib/model/download_info_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/download_response_dto.dart b/mobile/openapi/lib/model/download_response_dto.dart index 1945491a13..f32cba9253 100644 --- a/mobile/openapi/lib/model/download_response_dto.dart +++ b/mobile/openapi/lib/model/download_response_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/entity_type.dart b/mobile/openapi/lib/model/entity_type.dart index 9309e61a23..93a0d0d3cc 100644 --- a/mobile/openapi/lib/model/entity_type.dart +++ b/mobile/openapi/lib/model/entity_type.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/exif_response_dto.dart b/mobile/openapi/lib/model/exif_response_dto.dart index f508a5ca58..d29d485a05 100644 --- a/mobile/openapi/lib/model/exif_response_dto.dart +++ b/mobile/openapi/lib/model/exif_response_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/face_dto.dart b/mobile/openapi/lib/model/face_dto.dart index 4f1417ac1a..4fcc86debf 100644 --- a/mobile/openapi/lib/model/face_dto.dart +++ b/mobile/openapi/lib/model/face_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/file_checksum_dto.dart b/mobile/openapi/lib/model/file_checksum_dto.dart index 2ab0b79ff3..c7e8aa1da6 100644 --- a/mobile/openapi/lib/model/file_checksum_dto.dart +++ b/mobile/openapi/lib/model/file_checksum_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/file_checksum_response_dto.dart b/mobile/openapi/lib/model/file_checksum_response_dto.dart index 17c9f9ee55..d4bae3c273 100644 --- a/mobile/openapi/lib/model/file_checksum_response_dto.dart +++ b/mobile/openapi/lib/model/file_checksum_response_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/file_report_dto.dart b/mobile/openapi/lib/model/file_report_dto.dart index 517790411e..422215ff6c 100644 --- a/mobile/openapi/lib/model/file_report_dto.dart +++ b/mobile/openapi/lib/model/file_report_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/file_report_fix_dto.dart b/mobile/openapi/lib/model/file_report_fix_dto.dart index 420899a8d0..cf09242b0f 100644 --- a/mobile/openapi/lib/model/file_report_fix_dto.dart +++ b/mobile/openapi/lib/model/file_report_fix_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/file_report_item_dto.dart b/mobile/openapi/lib/model/file_report_item_dto.dart index fd937b04e2..5255005daa 100644 --- a/mobile/openapi/lib/model/file_report_item_dto.dart +++ b/mobile/openapi/lib/model/file_report_item_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/image_format.dart b/mobile/openapi/lib/model/image_format.dart index 570b6ca6e6..479b519e24 100644 --- a/mobile/openapi/lib/model/image_format.dart +++ b/mobile/openapi/lib/model/image_format.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/job_command.dart b/mobile/openapi/lib/model/job_command.dart index 1679776718..46ca7db68f 100644 --- a/mobile/openapi/lib/model/job_command.dart +++ b/mobile/openapi/lib/model/job_command.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/job_command_dto.dart b/mobile/openapi/lib/model/job_command_dto.dart index 00fc4a7e0d..5c56715644 100644 --- a/mobile/openapi/lib/model/job_command_dto.dart +++ b/mobile/openapi/lib/model/job_command_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/job_counts_dto.dart b/mobile/openapi/lib/model/job_counts_dto.dart index 7622673982..cf1d0b457d 100644 --- a/mobile/openapi/lib/model/job_counts_dto.dart +++ b/mobile/openapi/lib/model/job_counts_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/job_name.dart b/mobile/openapi/lib/model/job_name.dart index 771d434c7c..f4b53d3c22 100644 --- a/mobile/openapi/lib/model/job_name.dart +++ b/mobile/openapi/lib/model/job_name.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/job_settings_dto.dart b/mobile/openapi/lib/model/job_settings_dto.dart index c7c10c267b..9c59d503ca 100644 --- a/mobile/openapi/lib/model/job_settings_dto.dart +++ b/mobile/openapi/lib/model/job_settings_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/job_status_dto.dart b/mobile/openapi/lib/model/job_status_dto.dart index de372a36a9..fd925bd53a 100644 --- a/mobile/openapi/lib/model/job_status_dto.dart +++ b/mobile/openapi/lib/model/job_status_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/library_response_dto.dart b/mobile/openapi/lib/model/library_response_dto.dart index eadd3049fb..c2a25913c5 100644 --- a/mobile/openapi/lib/model/library_response_dto.dart +++ b/mobile/openapi/lib/model/library_response_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/library_stats_response_dto.dart b/mobile/openapi/lib/model/library_stats_response_dto.dart index 36215d094e..8cfb292855 100644 --- a/mobile/openapi/lib/model/library_stats_response_dto.dart +++ b/mobile/openapi/lib/model/library_stats_response_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/library_type.dart b/mobile/openapi/lib/model/library_type.dart index 37cfeb5ec4..0e5f56b518 100644 --- a/mobile/openapi/lib/model/library_type.dart +++ b/mobile/openapi/lib/model/library_type.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/log_level.dart b/mobile/openapi/lib/model/log_level.dart index 73f9fe5e24..2129096da2 100644 --- a/mobile/openapi/lib/model/log_level.dart +++ b/mobile/openapi/lib/model/log_level.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/login_credential_dto.dart b/mobile/openapi/lib/model/login_credential_dto.dart index 8dc3d9d58f..ac2f511691 100644 --- a/mobile/openapi/lib/model/login_credential_dto.dart +++ b/mobile/openapi/lib/model/login_credential_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/login_response_dto.dart b/mobile/openapi/lib/model/login_response_dto.dart index b116f0c96f..6a0eb2355c 100644 --- a/mobile/openapi/lib/model/login_response_dto.dart +++ b/mobile/openapi/lib/model/login_response_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/logout_response_dto.dart b/mobile/openapi/lib/model/logout_response_dto.dart index 11fcda7ecf..ca1e8d23bb 100644 --- a/mobile/openapi/lib/model/logout_response_dto.dart +++ b/mobile/openapi/lib/model/logout_response_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/map_marker_response_dto.dart b/mobile/openapi/lib/model/map_marker_response_dto.dart index 8331f0679c..ca1ec3c8a1 100644 --- a/mobile/openapi/lib/model/map_marker_response_dto.dart +++ b/mobile/openapi/lib/model/map_marker_response_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/map_theme.dart b/mobile/openapi/lib/model/map_theme.dart index a2e43425b4..e2553790c6 100644 --- a/mobile/openapi/lib/model/map_theme.dart +++ b/mobile/openapi/lib/model/map_theme.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/memory_create_dto.dart b/mobile/openapi/lib/model/memory_create_dto.dart index c48a07c5f0..2efdf88936 100644 --- a/mobile/openapi/lib/model/memory_create_dto.dart +++ b/mobile/openapi/lib/model/memory_create_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/memory_lane_response_dto.dart b/mobile/openapi/lib/model/memory_lane_response_dto.dart index 2f1f659529..bf6e8e2cce 100644 --- a/mobile/openapi/lib/model/memory_lane_response_dto.dart +++ b/mobile/openapi/lib/model/memory_lane_response_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/memory_response_dto.dart b/mobile/openapi/lib/model/memory_response_dto.dart index 8671e3c645..06e0e3114b 100644 --- a/mobile/openapi/lib/model/memory_response_dto.dart +++ b/mobile/openapi/lib/model/memory_response_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/memory_type.dart b/mobile/openapi/lib/model/memory_type.dart index 513b7c2d45..aee7bd1ba1 100644 --- a/mobile/openapi/lib/model/memory_type.dart +++ b/mobile/openapi/lib/model/memory_type.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/memory_update_dto.dart b/mobile/openapi/lib/model/memory_update_dto.dart index adf42330df..318f4b42ad 100644 --- a/mobile/openapi/lib/model/memory_update_dto.dart +++ b/mobile/openapi/lib/model/memory_update_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/merge_person_dto.dart b/mobile/openapi/lib/model/merge_person_dto.dart index cb3a5f4c3b..ea23042e2c 100644 --- a/mobile/openapi/lib/model/merge_person_dto.dart +++ b/mobile/openapi/lib/model/merge_person_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/metadata_search_dto.dart b/mobile/openapi/lib/model/metadata_search_dto.dart index 6ef3ffcfcd..c5aeb11066 100644 --- a/mobile/openapi/lib/model/metadata_search_dto.dart +++ b/mobile/openapi/lib/model/metadata_search_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/model_type.dart b/mobile/openapi/lib/model/model_type.dart index 4b7c350321..36f7424199 100644 --- a/mobile/openapi/lib/model/model_type.dart +++ b/mobile/openapi/lib/model/model_type.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first 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 f042713cce..ffd017f816 100644 --- a/mobile/openapi/lib/model/o_auth_authorize_response_dto.dart +++ b/mobile/openapi/lib/model/o_auth_authorize_response_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/o_auth_callback_dto.dart b/mobile/openapi/lib/model/o_auth_callback_dto.dart index 0ec446cead..89ad0f60b0 100644 --- a/mobile/openapi/lib/model/o_auth_callback_dto.dart +++ b/mobile/openapi/lib/model/o_auth_callback_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/o_auth_config_dto.dart b/mobile/openapi/lib/model/o_auth_config_dto.dart index a86b5ab714..7d76758864 100644 --- a/mobile/openapi/lib/model/o_auth_config_dto.dart +++ b/mobile/openapi/lib/model/o_auth_config_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/on_this_day_dto.dart b/mobile/openapi/lib/model/on_this_day_dto.dart index 81b13e391b..be170caf85 100644 --- a/mobile/openapi/lib/model/on_this_day_dto.dart +++ b/mobile/openapi/lib/model/on_this_day_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/partner_response_dto.dart b/mobile/openapi/lib/model/partner_response_dto.dart index 37602d04b7..1efd91c346 100644 --- a/mobile/openapi/lib/model/partner_response_dto.dart +++ b/mobile/openapi/lib/model/partner_response_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/path_entity_type.dart b/mobile/openapi/lib/model/path_entity_type.dart index e1f45f0e83..fdcdae4f1b 100644 --- a/mobile/openapi/lib/model/path_entity_type.dart +++ b/mobile/openapi/lib/model/path_entity_type.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/path_type.dart b/mobile/openapi/lib/model/path_type.dart index 11cdf41ea1..bfb16c6667 100644 --- a/mobile/openapi/lib/model/path_type.dart +++ b/mobile/openapi/lib/model/path_type.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/people_response_dto.dart b/mobile/openapi/lib/model/people_response_dto.dart index 02a82cadf1..423ea989d4 100644 --- a/mobile/openapi/lib/model/people_response_dto.dart +++ b/mobile/openapi/lib/model/people_response_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/people_update_dto.dart b/mobile/openapi/lib/model/people_update_dto.dart index a98934b521..9fcfdc8761 100644 --- a/mobile/openapi/lib/model/people_update_dto.dart +++ b/mobile/openapi/lib/model/people_update_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/people_update_item.dart b/mobile/openapi/lib/model/people_update_item.dart index 565743e11d..8af0a8b11a 100644 --- a/mobile/openapi/lib/model/people_update_item.dart +++ b/mobile/openapi/lib/model/people_update_item.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/person_create_dto.dart b/mobile/openapi/lib/model/person_create_dto.dart index 4811de3efe..9889328dee 100644 --- a/mobile/openapi/lib/model/person_create_dto.dart +++ b/mobile/openapi/lib/model/person_create_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/person_response_dto.dart b/mobile/openapi/lib/model/person_response_dto.dart index 30edc062be..e1f6b44e5b 100644 --- a/mobile/openapi/lib/model/person_response_dto.dart +++ b/mobile/openapi/lib/model/person_response_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/person_statistics_response_dto.dart b/mobile/openapi/lib/model/person_statistics_response_dto.dart index 17dd4f356b..929fbc29d2 100644 --- a/mobile/openapi/lib/model/person_statistics_response_dto.dart +++ b/mobile/openapi/lib/model/person_statistics_response_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/person_update_dto.dart b/mobile/openapi/lib/model/person_update_dto.dart index 611efe8171..1af03890a2 100644 --- a/mobile/openapi/lib/model/person_update_dto.dart +++ b/mobile/openapi/lib/model/person_update_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first 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 67ac02ca02..b15e620250 100644 --- a/mobile/openapi/lib/model/person_with_faces_response_dto.dart +++ b/mobile/openapi/lib/model/person_with_faces_response_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/places_response_dto.dart b/mobile/openapi/lib/model/places_response_dto.dart index a2d8378883..d3e1fc449b 100644 --- a/mobile/openapi/lib/model/places_response_dto.dart +++ b/mobile/openapi/lib/model/places_response_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/queue_status_dto.dart b/mobile/openapi/lib/model/queue_status_dto.dart index 96775de8e9..7f7d310f6f 100644 --- a/mobile/openapi/lib/model/queue_status_dto.dart +++ b/mobile/openapi/lib/model/queue_status_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/reaction_level.dart b/mobile/openapi/lib/model/reaction_level.dart index 2f96b4b32c..29568b9d11 100644 --- a/mobile/openapi/lib/model/reaction_level.dart +++ b/mobile/openapi/lib/model/reaction_level.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/reaction_type.dart b/mobile/openapi/lib/model/reaction_type.dart index 8ea9afcdb0..4c788138fb 100644 --- a/mobile/openapi/lib/model/reaction_type.dart +++ b/mobile/openapi/lib/model/reaction_type.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/recognition_config.dart b/mobile/openapi/lib/model/recognition_config.dart index 3d4171bf68..3080023bbd 100644 --- a/mobile/openapi/lib/model/recognition_config.dart +++ b/mobile/openapi/lib/model/recognition_config.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first 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 71e1d3ad99..eb414be984 100644 --- a/mobile/openapi/lib/model/reverse_geocoding_state_response_dto.dart +++ b/mobile/openapi/lib/model/reverse_geocoding_state_response_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/scan_library_dto.dart b/mobile/openapi/lib/model/scan_library_dto.dart index 0f5dedf64a..1b31aaaf01 100644 --- a/mobile/openapi/lib/model/scan_library_dto.dart +++ b/mobile/openapi/lib/model/scan_library_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/search_album_response_dto.dart b/mobile/openapi/lib/model/search_album_response_dto.dart index 7925356102..46ce5273ac 100644 --- a/mobile/openapi/lib/model/search_album_response_dto.dart +++ b/mobile/openapi/lib/model/search_album_response_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/search_asset_response_dto.dart b/mobile/openapi/lib/model/search_asset_response_dto.dart index abdbc5d4e3..21ddbbb213 100644 --- a/mobile/openapi/lib/model/search_asset_response_dto.dart +++ b/mobile/openapi/lib/model/search_asset_response_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/search_explore_item.dart b/mobile/openapi/lib/model/search_explore_item.dart index 218f7f4c6f..951fdd1bc8 100644 --- a/mobile/openapi/lib/model/search_explore_item.dart +++ b/mobile/openapi/lib/model/search_explore_item.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/search_explore_response_dto.dart b/mobile/openapi/lib/model/search_explore_response_dto.dart index ad317642d9..5bc601de9e 100644 --- a/mobile/openapi/lib/model/search_explore_response_dto.dart +++ b/mobile/openapi/lib/model/search_explore_response_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first 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 a53ae6b86c..b40710e525 100644 --- a/mobile/openapi/lib/model/search_facet_count_response_dto.dart +++ b/mobile/openapi/lib/model/search_facet_count_response_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/search_facet_response_dto.dart b/mobile/openapi/lib/model/search_facet_response_dto.dart index 30dd1215fa..0784921c6b 100644 --- a/mobile/openapi/lib/model/search_facet_response_dto.dart +++ b/mobile/openapi/lib/model/search_facet_response_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/search_response_dto.dart b/mobile/openapi/lib/model/search_response_dto.dart index 2f697f3dd8..9b2b7fd3cf 100644 --- a/mobile/openapi/lib/model/search_response_dto.dart +++ b/mobile/openapi/lib/model/search_response_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/search_suggestion_type.dart b/mobile/openapi/lib/model/search_suggestion_type.dart index d33b4a69d9..3f905e029d 100644 --- a/mobile/openapi/lib/model/search_suggestion_type.dart +++ b/mobile/openapi/lib/model/search_suggestion_type.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/server_config_dto.dart b/mobile/openapi/lib/model/server_config_dto.dart index faa167c73a..47cc52fb2c 100644 --- a/mobile/openapi/lib/model/server_config_dto.dart +++ b/mobile/openapi/lib/model/server_config_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/server_features_dto.dart b/mobile/openapi/lib/model/server_features_dto.dart index f9f8233f49..8c51a70793 100644 --- a/mobile/openapi/lib/model/server_features_dto.dart +++ b/mobile/openapi/lib/model/server_features_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/server_info_response_dto.dart b/mobile/openapi/lib/model/server_info_response_dto.dart index e66cfe44bf..295e0f7e6a 100644 --- a/mobile/openapi/lib/model/server_info_response_dto.dart +++ b/mobile/openapi/lib/model/server_info_response_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first 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 cf5a93bf8c..35ddef1956 100644 --- a/mobile/openapi/lib/model/server_media_types_response_dto.dart +++ b/mobile/openapi/lib/model/server_media_types_response_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/server_ping_response.dart b/mobile/openapi/lib/model/server_ping_response.dart index 280a50a44b..e23dc15c61 100644 --- a/mobile/openapi/lib/model/server_ping_response.dart +++ b/mobile/openapi/lib/model/server_ping_response.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/server_stats_response_dto.dart b/mobile/openapi/lib/model/server_stats_response_dto.dart index c4afaa348c..6996e49aa5 100644 --- a/mobile/openapi/lib/model/server_stats_response_dto.dart +++ b/mobile/openapi/lib/model/server_stats_response_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/server_theme_dto.dart b/mobile/openapi/lib/model/server_theme_dto.dart index e822c33df1..65b9b9163e 100644 --- a/mobile/openapi/lib/model/server_theme_dto.dart +++ b/mobile/openapi/lib/model/server_theme_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/server_version_response_dto.dart b/mobile/openapi/lib/model/server_version_response_dto.dart index 65f76311b8..e507f3372a 100644 --- a/mobile/openapi/lib/model/server_version_response_dto.dart +++ b/mobile/openapi/lib/model/server_version_response_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/session_response_dto.dart b/mobile/openapi/lib/model/session_response_dto.dart index 6a44fc24bb..82673b3874 100644 --- a/mobile/openapi/lib/model/session_response_dto.dart +++ b/mobile/openapi/lib/model/session_response_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/shared_link_create_dto.dart b/mobile/openapi/lib/model/shared_link_create_dto.dart index 920e62e52e..623bc3125f 100644 --- a/mobile/openapi/lib/model/shared_link_create_dto.dart +++ b/mobile/openapi/lib/model/shared_link_create_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/shared_link_edit_dto.dart b/mobile/openapi/lib/model/shared_link_edit_dto.dart index ff4e270a8e..2369c85db1 100644 --- a/mobile/openapi/lib/model/shared_link_edit_dto.dart +++ b/mobile/openapi/lib/model/shared_link_edit_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/shared_link_response_dto.dart b/mobile/openapi/lib/model/shared_link_response_dto.dart index b114b9cd6a..018a1a51de 100644 --- a/mobile/openapi/lib/model/shared_link_response_dto.dart +++ b/mobile/openapi/lib/model/shared_link_response_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/shared_link_type.dart b/mobile/openapi/lib/model/shared_link_type.dart index 317c7037f9..efab97c209 100644 --- a/mobile/openapi/lib/model/shared_link_type.dart +++ b/mobile/openapi/lib/model/shared_link_type.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/sign_up_dto.dart b/mobile/openapi/lib/model/sign_up_dto.dart index b9f5261abe..772749fdba 100644 --- a/mobile/openapi/lib/model/sign_up_dto.dart +++ b/mobile/openapi/lib/model/sign_up_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/smart_info_response_dto.dart b/mobile/openapi/lib/model/smart_info_response_dto.dart index 72e958297a..52e7c108b8 100644 --- a/mobile/openapi/lib/model/smart_info_response_dto.dart +++ b/mobile/openapi/lib/model/smart_info_response_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/smart_search_dto.dart b/mobile/openapi/lib/model/smart_search_dto.dart index 6b9b17e352..0ff8cf6115 100644 --- a/mobile/openapi/lib/model/smart_search_dto.dart +++ b/mobile/openapi/lib/model/smart_search_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/system_config_dto.dart b/mobile/openapi/lib/model/system_config_dto.dart index c4fe990f13..e56169742a 100644 --- a/mobile/openapi/lib/model/system_config_dto.dart +++ b/mobile/openapi/lib/model/system_config_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first 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 758bc37fa4..136f1eec32 100644 --- a/mobile/openapi/lib/model/system_config_f_fmpeg_dto.dart +++ b/mobile/openapi/lib/model/system_config_f_fmpeg_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/system_config_image_dto.dart b/mobile/openapi/lib/model/system_config_image_dto.dart index 46e598d6be..388949c759 100644 --- a/mobile/openapi/lib/model/system_config_image_dto.dart +++ b/mobile/openapi/lib/model/system_config_image_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/system_config_job_dto.dart b/mobile/openapi/lib/model/system_config_job_dto.dart index 0ddf69e465..1bc0f6b29c 100644 --- a/mobile/openapi/lib/model/system_config_job_dto.dart +++ b/mobile/openapi/lib/model/system_config_job_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/system_config_library_dto.dart b/mobile/openapi/lib/model/system_config_library_dto.dart index 4f1dad23e6..4f55e33e80 100644 --- a/mobile/openapi/lib/model/system_config_library_dto.dart +++ b/mobile/openapi/lib/model/system_config_library_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first 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 971a1e7585..31df272594 100644 --- a/mobile/openapi/lib/model/system_config_library_scan_dto.dart +++ b/mobile/openapi/lib/model/system_config_library_scan_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first 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 0bcb6f1771..9d152f366a 100644 --- a/mobile/openapi/lib/model/system_config_library_watch_dto.dart +++ b/mobile/openapi/lib/model/system_config_library_watch_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/system_config_logging_dto.dart b/mobile/openapi/lib/model/system_config_logging_dto.dart index 6692fd01fd..60c0be3d2c 100644 --- a/mobile/openapi/lib/model/system_config_logging_dto.dart +++ b/mobile/openapi/lib/model/system_config_logging_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first 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 7d5e4cba0f..5d7e6afd76 100644 --- a/mobile/openapi/lib/model/system_config_machine_learning_dto.dart +++ b/mobile/openapi/lib/model/system_config_machine_learning_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/system_config_map_dto.dart b/mobile/openapi/lib/model/system_config_map_dto.dart index f9f4a4d5de..6631885182 100644 --- a/mobile/openapi/lib/model/system_config_map_dto.dart +++ b/mobile/openapi/lib/model/system_config_map_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first 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 4ab93dfca3..c7b8c98695 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 @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/system_config_notifications_dto.dart b/mobile/openapi/lib/model/system_config_notifications_dto.dart index 0c353b0b0c..22f08b3ab4 100644 --- a/mobile/openapi/lib/model/system_config_notifications_dto.dart +++ b/mobile/openapi/lib/model/system_config_notifications_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first 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 1b43f3eb72..a8af9c1132 100644 --- a/mobile/openapi/lib/model/system_config_o_auth_dto.dart +++ b/mobile/openapi/lib/model/system_config_o_auth_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first 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 840206acc9..61896a890c 100644 --- a/mobile/openapi/lib/model/system_config_password_login_dto.dart +++ b/mobile/openapi/lib/model/system_config_password_login_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first 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 9e5ea396d4..2eb586cac6 100644 --- a/mobile/openapi/lib/model/system_config_reverse_geocoding_dto.dart +++ b/mobile/openapi/lib/model/system_config_reverse_geocoding_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/system_config_server_dto.dart b/mobile/openapi/lib/model/system_config_server_dto.dart index 998aec96e1..ccb48ee61d 100644 --- a/mobile/openapi/lib/model/system_config_server_dto.dart +++ b/mobile/openapi/lib/model/system_config_server_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/system_config_smtp_dto.dart b/mobile/openapi/lib/model/system_config_smtp_dto.dart index d8e490473f..6588d244ee 100644 --- a/mobile/openapi/lib/model/system_config_smtp_dto.dart +++ b/mobile/openapi/lib/model/system_config_smtp_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first 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 fa8e9f6c0a..63dfdca4cf 100644 --- a/mobile/openapi/lib/model/system_config_smtp_transport_dto.dart +++ b/mobile/openapi/lib/model/system_config_smtp_transport_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first 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 f8348c33fc..13323aebda 100644 --- a/mobile/openapi/lib/model/system_config_storage_template_dto.dart +++ b/mobile/openapi/lib/model/system_config_storage_template_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first 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 1992530b40..82e0a6f747 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 @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/system_config_theme_dto.dart b/mobile/openapi/lib/model/system_config_theme_dto.dart index d6897bab37..2f7f4d2f3b 100644 --- a/mobile/openapi/lib/model/system_config_theme_dto.dart +++ b/mobile/openapi/lib/model/system_config_theme_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/system_config_trash_dto.dart b/mobile/openapi/lib/model/system_config_trash_dto.dart index 255be5cbda..336019fde4 100644 --- a/mobile/openapi/lib/model/system_config_trash_dto.dart +++ b/mobile/openapi/lib/model/system_config_trash_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/system_config_user_dto.dart b/mobile/openapi/lib/model/system_config_user_dto.dart index 95b9c289e8..c466374460 100644 --- a/mobile/openapi/lib/model/system_config_user_dto.dart +++ b/mobile/openapi/lib/model/system_config_user_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/tag_response_dto.dart b/mobile/openapi/lib/model/tag_response_dto.dart index 566e7a0f18..d371bd1c04 100644 --- a/mobile/openapi/lib/model/tag_response_dto.dart +++ b/mobile/openapi/lib/model/tag_response_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/tag_type_enum.dart b/mobile/openapi/lib/model/tag_type_enum.dart index a5e4316a2b..3f2e723796 100644 --- a/mobile/openapi/lib/model/tag_type_enum.dart +++ b/mobile/openapi/lib/model/tag_type_enum.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/thumbnail_format.dart b/mobile/openapi/lib/model/thumbnail_format.dart index 506726e3bc..a255fac25e 100644 --- a/mobile/openapi/lib/model/thumbnail_format.dart +++ b/mobile/openapi/lib/model/thumbnail_format.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/time_bucket_response_dto.dart b/mobile/openapi/lib/model/time_bucket_response_dto.dart index da86bc4650..2c86a56b3c 100644 --- a/mobile/openapi/lib/model/time_bucket_response_dto.dart +++ b/mobile/openapi/lib/model/time_bucket_response_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/time_bucket_size.dart b/mobile/openapi/lib/model/time_bucket_size.dart index 443beab28f..e843b43f43 100644 --- a/mobile/openapi/lib/model/time_bucket_size.dart +++ b/mobile/openapi/lib/model/time_bucket_size.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/tone_mapping.dart b/mobile/openapi/lib/model/tone_mapping.dart index bb6206193a..e05aea2b77 100644 --- a/mobile/openapi/lib/model/tone_mapping.dart +++ b/mobile/openapi/lib/model/tone_mapping.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/transcode_hw_accel.dart b/mobile/openapi/lib/model/transcode_hw_accel.dart index 50428d3954..de5006341e 100644 --- a/mobile/openapi/lib/model/transcode_hw_accel.dart +++ b/mobile/openapi/lib/model/transcode_hw_accel.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/transcode_policy.dart b/mobile/openapi/lib/model/transcode_policy.dart index 7d586bc2dc..6e9617428a 100644 --- a/mobile/openapi/lib/model/transcode_policy.dart +++ b/mobile/openapi/lib/model/transcode_policy.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/update_album_dto.dart b/mobile/openapi/lib/model/update_album_dto.dart index d9408cedfb..f9c9762887 100644 --- a/mobile/openapi/lib/model/update_album_dto.dart +++ b/mobile/openapi/lib/model/update_album_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/update_album_user_dto.dart b/mobile/openapi/lib/model/update_album_user_dto.dart index 8e85349318..f77223acf5 100644 --- a/mobile/openapi/lib/model/update_album_user_dto.dart +++ b/mobile/openapi/lib/model/update_album_user_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/update_asset_dto.dart b/mobile/openapi/lib/model/update_asset_dto.dart index e8402f1bf8..e9a4d8d6b8 100644 --- a/mobile/openapi/lib/model/update_asset_dto.dart +++ b/mobile/openapi/lib/model/update_asset_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/update_library_dto.dart b/mobile/openapi/lib/model/update_library_dto.dart index f197ca8599..85847c0ddf 100644 --- a/mobile/openapi/lib/model/update_library_dto.dart +++ b/mobile/openapi/lib/model/update_library_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/update_partner_dto.dart b/mobile/openapi/lib/model/update_partner_dto.dart index 8156803c61..f695f99535 100644 --- a/mobile/openapi/lib/model/update_partner_dto.dart +++ b/mobile/openapi/lib/model/update_partner_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/update_stack_parent_dto.dart b/mobile/openapi/lib/model/update_stack_parent_dto.dart index 12fda9d630..4247c2e29f 100644 --- a/mobile/openapi/lib/model/update_stack_parent_dto.dart +++ b/mobile/openapi/lib/model/update_stack_parent_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/update_tag_dto.dart b/mobile/openapi/lib/model/update_tag_dto.dart index 6075bc7963..dfa9b8cfc0 100644 --- a/mobile/openapi/lib/model/update_tag_dto.dart +++ b/mobile/openapi/lib/model/update_tag_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/update_user_dto.dart b/mobile/openapi/lib/model/update_user_dto.dart index 7a8f097975..caa0600793 100644 --- a/mobile/openapi/lib/model/update_user_dto.dart +++ b/mobile/openapi/lib/model/update_user_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/usage_by_user_dto.dart b/mobile/openapi/lib/model/usage_by_user_dto.dart index b9b4fc6c3e..0bbbba00bb 100644 --- a/mobile/openapi/lib/model/usage_by_user_dto.dart +++ b/mobile/openapi/lib/model/usage_by_user_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/user_avatar_color.dart b/mobile/openapi/lib/model/user_avatar_color.dart index a783f8c37e..4cd7dd3204 100644 --- a/mobile/openapi/lib/model/user_avatar_color.dart +++ b/mobile/openapi/lib/model/user_avatar_color.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/user_dto.dart b/mobile/openapi/lib/model/user_dto.dart index 431648605c..1c4c4eb0b4 100644 --- a/mobile/openapi/lib/model/user_dto.dart +++ b/mobile/openapi/lib/model/user_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/user_response_dto.dart b/mobile/openapi/lib/model/user_response_dto.dart index df68128e71..063b3d33b6 100644 --- a/mobile/openapi/lib/model/user_response_dto.dart +++ b/mobile/openapi/lib/model/user_response_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/user_status.dart b/mobile/openapi/lib/model/user_status.dart index cbbe1b56d9..596abf324e 100644 --- a/mobile/openapi/lib/model/user_status.dart +++ b/mobile/openapi/lib/model/user_status.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first 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 f04ca200fc..e970f7e840 100644 --- a/mobile/openapi/lib/model/validate_access_token_response_dto.dart +++ b/mobile/openapi/lib/model/validate_access_token_response_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/validate_library_dto.dart b/mobile/openapi/lib/model/validate_library_dto.dart index a29a622079..05e122b1a1 100644 --- a/mobile/openapi/lib/model/validate_library_dto.dart +++ b/mobile/openapi/lib/model/validate_library_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first 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 142055f2cd..23aac0b742 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 @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/validate_library_response_dto.dart b/mobile/openapi/lib/model/validate_library_response_dto.dart index bb975da23a..b213f9ba98 100644 --- a/mobile/openapi/lib/model/validate_library_response_dto.dart +++ b/mobile/openapi/lib/model/validate_library_response_dto.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/lib/model/video_codec.dart b/mobile/openapi/lib/model/video_codec.dart index 36e1c681a6..307b208757 100644 --- a/mobile/openapi/lib/model/video_codec.dart +++ b/mobile/openapi/lib/model/video_codec.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/pubspec.yaml b/mobile/openapi/pubspec.yaml index 41ab08885c..f033028432 100644 --- a/mobile/openapi/pubspec.yaml +++ b/mobile/openapi/pubspec.yaml @@ -11,7 +11,7 @@ environment: dependencies: collection: '^1.17.0' http: '>=0.13.0 <0.14.0' - intl: '^0.18.0' + intl: any meta: '^1.1.8' dev_dependencies: - test: '>=1.16.0 <1.18.0' + test: '>=1.21.6 <1.22.0' diff --git a/mobile/openapi/test/activity_api_test.dart b/mobile/openapi/test/activity_api_test.dart index 05ffd928e0..db109e421b 100644 --- a/mobile/openapi/test/activity_api_test.dart +++ b/mobile/openapi/test/activity_api_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/activity_create_dto_test.dart b/mobile/openapi/test/activity_create_dto_test.dart index 263f1e27d7..eea7426d52 100644 --- a/mobile/openapi/test/activity_create_dto_test.dart +++ b/mobile/openapi/test/activity_create_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/activity_response_dto_test.dart b/mobile/openapi/test/activity_response_dto_test.dart index 5f70944b60..5ab293b619 100644 --- a/mobile/openapi/test/activity_response_dto_test.dart +++ b/mobile/openapi/test/activity_response_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/activity_statistics_response_dto_test.dart b/mobile/openapi/test/activity_statistics_response_dto_test.dart index 05f8bfdd07..fc29c936b6 100644 --- a/mobile/openapi/test/activity_statistics_response_dto_test.dart +++ b/mobile/openapi/test/activity_statistics_response_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/add_users_dto_test.dart b/mobile/openapi/test/add_users_dto_test.dart index b0d66c56d8..63c4b12860 100644 --- a/mobile/openapi/test/add_users_dto_test.dart +++ b/mobile/openapi/test/add_users_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/admin_onboarding_update_dto_test.dart b/mobile/openapi/test/admin_onboarding_update_dto_test.dart index 09cc73e977..3c2bfe10ce 100644 --- a/mobile/openapi/test/admin_onboarding_update_dto_test.dart +++ b/mobile/openapi/test/admin_onboarding_update_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/album_api_test.dart b/mobile/openapi/test/album_api_test.dart index 1a6d3ab3bb..fb3c66409a 100644 --- a/mobile/openapi/test/album_api_test.dart +++ b/mobile/openapi/test/album_api_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/album_count_response_dto_test.dart b/mobile/openapi/test/album_count_response_dto_test.dart index 5da1acb072..95ea2dd7e1 100644 --- a/mobile/openapi/test/album_count_response_dto_test.dart +++ b/mobile/openapi/test/album_count_response_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/album_response_dto_test.dart b/mobile/openapi/test/album_response_dto_test.dart index b9702165b5..d9ec98735f 100644 --- a/mobile/openapi/test/album_response_dto_test.dart +++ b/mobile/openapi/test/album_response_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/album_user_add_dto_test.dart b/mobile/openapi/test/album_user_add_dto_test.dart index 3f315ea2bb..b3d275d20d 100644 --- a/mobile/openapi/test/album_user_add_dto_test.dart +++ b/mobile/openapi/test/album_user_add_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/album_user_create_dto_test.dart b/mobile/openapi/test/album_user_create_dto_test.dart index a1459172f7..f2c458b473 100644 --- a/mobile/openapi/test/album_user_create_dto_test.dart +++ b/mobile/openapi/test/album_user_create_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/album_user_response_dto_test.dart b/mobile/openapi/test/album_user_response_dto_test.dart index 19f15a305d..6693ff8ead 100644 --- a/mobile/openapi/test/album_user_response_dto_test.dart +++ b/mobile/openapi/test/album_user_response_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/album_user_role_test.dart b/mobile/openapi/test/album_user_role_test.dart index bc09896215..f4e6e3c895 100644 --- a/mobile/openapi/test/album_user_role_test.dart +++ b/mobile/openapi/test/album_user_role_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/all_job_status_response_dto_test.dart b/mobile/openapi/test/all_job_status_response_dto_test.dart index 36bebbaf4d..afadb2ffc8 100644 --- a/mobile/openapi/test/all_job_status_response_dto_test.dart +++ b/mobile/openapi/test/all_job_status_response_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/api_key_api_test.dart b/mobile/openapi/test/api_key_api_test.dart index 679abffbd4..592caec343 100644 --- a/mobile/openapi/test/api_key_api_test.dart +++ b/mobile/openapi/test/api_key_api_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/api_key_create_dto_test.dart b/mobile/openapi/test/api_key_create_dto_test.dart index a09181ef0f..2d8fa49187 100644 --- a/mobile/openapi/test/api_key_create_dto_test.dart +++ b/mobile/openapi/test/api_key_create_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/api_key_create_response_dto_test.dart b/mobile/openapi/test/api_key_create_response_dto_test.dart index 9d9a8d24ee..a069d764c9 100644 --- a/mobile/openapi/test/api_key_create_response_dto_test.dart +++ b/mobile/openapi/test/api_key_create_response_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/api_key_response_dto_test.dart b/mobile/openapi/test/api_key_response_dto_test.dart index 399a01429a..61b7ecee70 100644 --- a/mobile/openapi/test/api_key_response_dto_test.dart +++ b/mobile/openapi/test/api_key_response_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/api_key_update_dto_test.dart b/mobile/openapi/test/api_key_update_dto_test.dart index ca7bc2187e..37e2955d51 100644 --- a/mobile/openapi/test/api_key_update_dto_test.dart +++ b/mobile/openapi/test/api_key_update_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/asset_api_test.dart b/mobile/openapi/test/asset_api_test.dart index aa6fa6c278..de84e53546 100644 --- a/mobile/openapi/test/asset_api_test.dart +++ b/mobile/openapi/test/asset_api_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/asset_bulk_delete_dto_test.dart b/mobile/openapi/test/asset_bulk_delete_dto_test.dart index d4245531f0..d213a23398 100644 --- a/mobile/openapi/test/asset_bulk_delete_dto_test.dart +++ b/mobile/openapi/test/asset_bulk_delete_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/asset_bulk_update_dto_test.dart b/mobile/openapi/test/asset_bulk_update_dto_test.dart index d04bdd8091..51ae9409ab 100644 --- a/mobile/openapi/test/asset_bulk_update_dto_test.dart +++ b/mobile/openapi/test/asset_bulk_update_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/asset_bulk_upload_check_dto_test.dart b/mobile/openapi/test/asset_bulk_upload_check_dto_test.dart index 830cf2e29e..1133a35af4 100644 --- a/mobile/openapi/test/asset_bulk_upload_check_dto_test.dart +++ b/mobile/openapi/test/asset_bulk_upload_check_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/asset_bulk_upload_check_item_test.dart b/mobile/openapi/test/asset_bulk_upload_check_item_test.dart index 9f3e6e72fa..ef6a2a3094 100644 --- a/mobile/openapi/test/asset_bulk_upload_check_item_test.dart +++ b/mobile/openapi/test/asset_bulk_upload_check_item_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/asset_bulk_upload_check_response_dto_test.dart b/mobile/openapi/test/asset_bulk_upload_check_response_dto_test.dart index 1af1fede08..ad25af37f6 100644 --- a/mobile/openapi/test/asset_bulk_upload_check_response_dto_test.dart +++ b/mobile/openapi/test/asset_bulk_upload_check_response_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/asset_bulk_upload_check_result_test.dart b/mobile/openapi/test/asset_bulk_upload_check_result_test.dart index dd0a28ba57..0cda1fdb7e 100644 --- a/mobile/openapi/test/asset_bulk_upload_check_result_test.dart +++ b/mobile/openapi/test/asset_bulk_upload_check_result_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/asset_delta_sync_dto_test.dart b/mobile/openapi/test/asset_delta_sync_dto_test.dart index 41676d610b..6306876795 100644 --- a/mobile/openapi/test/asset_delta_sync_dto_test.dart +++ b/mobile/openapi/test/asset_delta_sync_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/asset_delta_sync_response_dto_test.dart b/mobile/openapi/test/asset_delta_sync_response_dto_test.dart index 20104c08c6..eaba49ab39 100644 --- a/mobile/openapi/test/asset_delta_sync_response_dto_test.dart +++ b/mobile/openapi/test/asset_delta_sync_response_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/asset_face_response_dto_test.dart b/mobile/openapi/test/asset_face_response_dto_test.dart index cfedbeca9b..b2feb1807f 100644 --- a/mobile/openapi/test/asset_face_response_dto_test.dart +++ b/mobile/openapi/test/asset_face_response_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/asset_face_update_dto_test.dart b/mobile/openapi/test/asset_face_update_dto_test.dart index 5338a0bad9..fb3fa8a1c3 100644 --- a/mobile/openapi/test/asset_face_update_dto_test.dart +++ b/mobile/openapi/test/asset_face_update_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/asset_face_update_item_test.dart b/mobile/openapi/test/asset_face_update_item_test.dart index a3ef4c3357..9fd1aee84a 100644 --- a/mobile/openapi/test/asset_face_update_item_test.dart +++ b/mobile/openapi/test/asset_face_update_item_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/asset_face_without_person_response_dto_test.dart b/mobile/openapi/test/asset_face_without_person_response_dto_test.dart index 5eb7e5d939..052b96aeb3 100644 --- a/mobile/openapi/test/asset_face_without_person_response_dto_test.dart +++ b/mobile/openapi/test/asset_face_without_person_response_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/asset_file_upload_response_dto_test.dart b/mobile/openapi/test/asset_file_upload_response_dto_test.dart index 6d050c589b..c52704fe68 100644 --- a/mobile/openapi/test/asset_file_upload_response_dto_test.dart +++ b/mobile/openapi/test/asset_file_upload_response_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/asset_full_sync_dto_test.dart b/mobile/openapi/test/asset_full_sync_dto_test.dart index cf838ae89e..d29564d2eb 100644 --- a/mobile/openapi/test/asset_full_sync_dto_test.dart +++ b/mobile/openapi/test/asset_full_sync_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/asset_ids_dto_test.dart b/mobile/openapi/test/asset_ids_dto_test.dart index 840f6f5cca..dcb4c3d531 100644 --- a/mobile/openapi/test/asset_ids_dto_test.dart +++ b/mobile/openapi/test/asset_ids_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/asset_ids_response_dto_test.dart b/mobile/openapi/test/asset_ids_response_dto_test.dart index cb03844cca..7790a668dd 100644 --- a/mobile/openapi/test/asset_ids_response_dto_test.dart +++ b/mobile/openapi/test/asset_ids_response_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/asset_job_name_test.dart b/mobile/openapi/test/asset_job_name_test.dart index dc6313c927..0a8a74cb8e 100644 --- a/mobile/openapi/test/asset_job_name_test.dart +++ b/mobile/openapi/test/asset_job_name_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/asset_jobs_dto_test.dart b/mobile/openapi/test/asset_jobs_dto_test.dart index e114d9fb2c..d4462c90ae 100644 --- a/mobile/openapi/test/asset_jobs_dto_test.dart +++ b/mobile/openapi/test/asset_jobs_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/asset_order_test.dart b/mobile/openapi/test/asset_order_test.dart index 4a14908100..6c9c000450 100644 --- a/mobile/openapi/test/asset_order_test.dart +++ b/mobile/openapi/test/asset_order_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/asset_response_dto_test.dart b/mobile/openapi/test/asset_response_dto_test.dart index fa12bc9f15..6e927e6014 100644 --- a/mobile/openapi/test/asset_response_dto_test.dart +++ b/mobile/openapi/test/asset_response_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/asset_stats_response_dto_test.dart b/mobile/openapi/test/asset_stats_response_dto_test.dart index eaeace92a7..f99c76a4c6 100644 --- a/mobile/openapi/test/asset_stats_response_dto_test.dart +++ b/mobile/openapi/test/asset_stats_response_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/asset_type_enum_test.dart b/mobile/openapi/test/asset_type_enum_test.dart index a826ee679f..deabc07169 100644 --- a/mobile/openapi/test/asset_type_enum_test.dart +++ b/mobile/openapi/test/asset_type_enum_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/audio_codec_test.dart b/mobile/openapi/test/audio_codec_test.dart index a6c61661d6..0905ef8c4f 100644 --- a/mobile/openapi/test/audio_codec_test.dart +++ b/mobile/openapi/test/audio_codec_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/audit_api_test.dart b/mobile/openapi/test/audit_api_test.dart index 8114283a1a..ec72955e71 100644 --- a/mobile/openapi/test/audit_api_test.dart +++ b/mobile/openapi/test/audit_api_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/audit_deletes_response_dto_test.dart b/mobile/openapi/test/audit_deletes_response_dto_test.dart index 45dbccc28d..832fe29942 100644 --- a/mobile/openapi/test/audit_deletes_response_dto_test.dart +++ b/mobile/openapi/test/audit_deletes_response_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/authentication_api_test.dart b/mobile/openapi/test/authentication_api_test.dart index dea20ec9b1..becacb7cd3 100644 --- a/mobile/openapi/test/authentication_api_test.dart +++ b/mobile/openapi/test/authentication_api_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/bulk_id_response_dto_test.dart b/mobile/openapi/test/bulk_id_response_dto_test.dart index 23e13ef507..05e7fa84ca 100644 --- a/mobile/openapi/test/bulk_id_response_dto_test.dart +++ b/mobile/openapi/test/bulk_id_response_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/bulk_ids_dto_test.dart b/mobile/openapi/test/bulk_ids_dto_test.dart index 22b11ec8f0..31f7fde5b3 100644 --- a/mobile/openapi/test/bulk_ids_dto_test.dart +++ b/mobile/openapi/test/bulk_ids_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/change_password_dto_test.dart b/mobile/openapi/test/change_password_dto_test.dart index 21ed393bc4..35bbccfe5b 100644 --- a/mobile/openapi/test/change_password_dto_test.dart +++ b/mobile/openapi/test/change_password_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/check_existing_assets_dto_test.dart b/mobile/openapi/test/check_existing_assets_dto_test.dart index df4c51e707..a97b2d4279 100644 --- a/mobile/openapi/test/check_existing_assets_dto_test.dart +++ b/mobile/openapi/test/check_existing_assets_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/check_existing_assets_response_dto_test.dart b/mobile/openapi/test/check_existing_assets_response_dto_test.dart index 250fec3c62..5537256dc1 100644 --- a/mobile/openapi/test/check_existing_assets_response_dto_test.dart +++ b/mobile/openapi/test/check_existing_assets_response_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/clip_config_test.dart b/mobile/openapi/test/clip_config_test.dart index 77069e5b98..6c704ff1c2 100644 --- a/mobile/openapi/test/clip_config_test.dart +++ b/mobile/openapi/test/clip_config_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/clip_mode_test.dart b/mobile/openapi/test/clip_mode_test.dart index 8b6627e7eb..d829382d6f 100644 --- a/mobile/openapi/test/clip_mode_test.dart +++ b/mobile/openapi/test/clip_mode_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/colorspace_test.dart b/mobile/openapi/test/colorspace_test.dart index f689d519ed..9dc37aa56a 100644 --- a/mobile/openapi/test/colorspace_test.dart +++ b/mobile/openapi/test/colorspace_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/cq_mode_test.dart b/mobile/openapi/test/cq_mode_test.dart index 13d4a7b0c4..95fb52cdad 100644 --- a/mobile/openapi/test/cq_mode_test.dart +++ b/mobile/openapi/test/cq_mode_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/create_album_dto_test.dart b/mobile/openapi/test/create_album_dto_test.dart index f3dc3c8647..460c473636 100644 --- a/mobile/openapi/test/create_album_dto_test.dart +++ b/mobile/openapi/test/create_album_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/create_library_dto_test.dart b/mobile/openapi/test/create_library_dto_test.dart index eedb0d59d2..a85578d6ab 100644 --- a/mobile/openapi/test/create_library_dto_test.dart +++ b/mobile/openapi/test/create_library_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/create_profile_image_response_dto_test.dart b/mobile/openapi/test/create_profile_image_response_dto_test.dart index a09fed9504..27ecc35bff 100644 --- a/mobile/openapi/test/create_profile_image_response_dto_test.dart +++ b/mobile/openapi/test/create_profile_image_response_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/create_tag_dto_test.dart b/mobile/openapi/test/create_tag_dto_test.dart index 6bffb3200a..ec81decf7e 100644 --- a/mobile/openapi/test/create_tag_dto_test.dart +++ b/mobile/openapi/test/create_tag_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/create_user_dto_test.dart b/mobile/openapi/test/create_user_dto_test.dart index 0d474f932c..ba60af4dd5 100644 --- a/mobile/openapi/test/create_user_dto_test.dart +++ b/mobile/openapi/test/create_user_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/delete_user_dto_test.dart b/mobile/openapi/test/delete_user_dto_test.dart index 475681d420..7bc12a8bb6 100644 --- a/mobile/openapi/test/delete_user_dto_test.dart +++ b/mobile/openapi/test/delete_user_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/download_api_test.dart b/mobile/openapi/test/download_api_test.dart index 09ba5d5e40..6b6c65aa7b 100644 --- a/mobile/openapi/test/download_api_test.dart +++ b/mobile/openapi/test/download_api_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/download_archive_info_test.dart b/mobile/openapi/test/download_archive_info_test.dart index 53c705a378..fe75b61d2a 100644 --- a/mobile/openapi/test/download_archive_info_test.dart +++ b/mobile/openapi/test/download_archive_info_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/download_info_dto_test.dart b/mobile/openapi/test/download_info_dto_test.dart index 5efd4e11eb..ecfb5e655e 100644 --- a/mobile/openapi/test/download_info_dto_test.dart +++ b/mobile/openapi/test/download_info_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/download_response_dto_test.dart b/mobile/openapi/test/download_response_dto_test.dart index 949c9517aa..fe4b98845a 100644 --- a/mobile/openapi/test/download_response_dto_test.dart +++ b/mobile/openapi/test/download_response_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/entity_type_test.dart b/mobile/openapi/test/entity_type_test.dart index 81f023308c..d3b79a05b9 100644 --- a/mobile/openapi/test/entity_type_test.dart +++ b/mobile/openapi/test/entity_type_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/exif_response_dto_test.dart b/mobile/openapi/test/exif_response_dto_test.dart index 43d5b2673b..08d435bf36 100644 --- a/mobile/openapi/test/exif_response_dto_test.dart +++ b/mobile/openapi/test/exif_response_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/face_api_test.dart b/mobile/openapi/test/face_api_test.dart index 3bd4d982f4..fa0cdb8a60 100644 --- a/mobile/openapi/test/face_api_test.dart +++ b/mobile/openapi/test/face_api_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/face_dto_test.dart b/mobile/openapi/test/face_dto_test.dart index ea8091f2e3..bdb6c4087e 100644 --- a/mobile/openapi/test/face_dto_test.dart +++ b/mobile/openapi/test/face_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/file_checksum_dto_test.dart b/mobile/openapi/test/file_checksum_dto_test.dart index 6eb3a39023..fcc8ad41f4 100644 --- a/mobile/openapi/test/file_checksum_dto_test.dart +++ b/mobile/openapi/test/file_checksum_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/file_checksum_response_dto_test.dart b/mobile/openapi/test/file_checksum_response_dto_test.dart index a90fc61649..14850af8dc 100644 --- a/mobile/openapi/test/file_checksum_response_dto_test.dart +++ b/mobile/openapi/test/file_checksum_response_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/file_report_api_test.dart b/mobile/openapi/test/file_report_api_test.dart index 255c787002..ec3a1b37c5 100644 --- a/mobile/openapi/test/file_report_api_test.dart +++ b/mobile/openapi/test/file_report_api_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/file_report_dto_test.dart b/mobile/openapi/test/file_report_dto_test.dart index a843046683..43dfcc87ce 100644 --- a/mobile/openapi/test/file_report_dto_test.dart +++ b/mobile/openapi/test/file_report_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/file_report_fix_dto_test.dart b/mobile/openapi/test/file_report_fix_dto_test.dart index 44e7344295..dfc1588c05 100644 --- a/mobile/openapi/test/file_report_fix_dto_test.dart +++ b/mobile/openapi/test/file_report_fix_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/file_report_item_dto_test.dart b/mobile/openapi/test/file_report_item_dto_test.dart index 7e90322f70..d8908245c2 100644 --- a/mobile/openapi/test/file_report_item_dto_test.dart +++ b/mobile/openapi/test/file_report_item_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/image_format_test.dart b/mobile/openapi/test/image_format_test.dart index 2bb1512a68..44575af138 100644 --- a/mobile/openapi/test/image_format_test.dart +++ b/mobile/openapi/test/image_format_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/job_api_test.dart b/mobile/openapi/test/job_api_test.dart index c30811bb05..70c77bdb5b 100644 --- a/mobile/openapi/test/job_api_test.dart +++ b/mobile/openapi/test/job_api_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/job_command_dto_test.dart b/mobile/openapi/test/job_command_dto_test.dart index 83a27b4d9c..26bdc514c4 100644 --- a/mobile/openapi/test/job_command_dto_test.dart +++ b/mobile/openapi/test/job_command_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/job_command_test.dart b/mobile/openapi/test/job_command_test.dart index df6822c9d4..378835ad7a 100644 --- a/mobile/openapi/test/job_command_test.dart +++ b/mobile/openapi/test/job_command_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/job_counts_dto_test.dart b/mobile/openapi/test/job_counts_dto_test.dart index 43e24df0fc..85cdd78f14 100644 --- a/mobile/openapi/test/job_counts_dto_test.dart +++ b/mobile/openapi/test/job_counts_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/job_name_test.dart b/mobile/openapi/test/job_name_test.dart index 4c14d76be0..74581df1d5 100644 --- a/mobile/openapi/test/job_name_test.dart +++ b/mobile/openapi/test/job_name_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/job_settings_dto_test.dart b/mobile/openapi/test/job_settings_dto_test.dart index e06900185a..16381f3938 100644 --- a/mobile/openapi/test/job_settings_dto_test.dart +++ b/mobile/openapi/test/job_settings_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/job_status_dto_test.dart b/mobile/openapi/test/job_status_dto_test.dart index ae353baf0c..e5e7976289 100644 --- a/mobile/openapi/test/job_status_dto_test.dart +++ b/mobile/openapi/test/job_status_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/library_api_test.dart b/mobile/openapi/test/library_api_test.dart index 21afeff544..d34286f992 100644 --- a/mobile/openapi/test/library_api_test.dart +++ b/mobile/openapi/test/library_api_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/library_response_dto_test.dart b/mobile/openapi/test/library_response_dto_test.dart index 9fd196d1b0..d07c464d90 100644 --- a/mobile/openapi/test/library_response_dto_test.dart +++ b/mobile/openapi/test/library_response_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/library_stats_response_dto_test.dart b/mobile/openapi/test/library_stats_response_dto_test.dart index 91e9bb7040..ca2c4edea7 100644 --- a/mobile/openapi/test/library_stats_response_dto_test.dart +++ b/mobile/openapi/test/library_stats_response_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/library_type_test.dart b/mobile/openapi/test/library_type_test.dart index 991e21c9e2..3101173c59 100644 --- a/mobile/openapi/test/library_type_test.dart +++ b/mobile/openapi/test/library_type_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/log_level_test.dart b/mobile/openapi/test/log_level_test.dart index dfe841bf07..094beffe87 100644 --- a/mobile/openapi/test/log_level_test.dart +++ b/mobile/openapi/test/log_level_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/login_credential_dto_test.dart b/mobile/openapi/test/login_credential_dto_test.dart index 9995af7304..9692e216c8 100644 --- a/mobile/openapi/test/login_credential_dto_test.dart +++ b/mobile/openapi/test/login_credential_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/login_response_dto_test.dart b/mobile/openapi/test/login_response_dto_test.dart index a8365ff062..13b6c997c1 100644 --- a/mobile/openapi/test/login_response_dto_test.dart +++ b/mobile/openapi/test/login_response_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/logout_response_dto_test.dart b/mobile/openapi/test/logout_response_dto_test.dart index 1d16ab3c6c..bd12f44b7a 100644 --- a/mobile/openapi/test/logout_response_dto_test.dart +++ b/mobile/openapi/test/logout_response_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/map_marker_response_dto_test.dart b/mobile/openapi/test/map_marker_response_dto_test.dart index 9668260839..82dd5e7a3d 100644 --- a/mobile/openapi/test/map_marker_response_dto_test.dart +++ b/mobile/openapi/test/map_marker_response_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/map_theme_test.dart b/mobile/openapi/test/map_theme_test.dart index 82fa9ff3d8..92c4ab4b0e 100644 --- a/mobile/openapi/test/map_theme_test.dart +++ b/mobile/openapi/test/map_theme_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/memory_api_test.dart b/mobile/openapi/test/memory_api_test.dart index 1a930782ea..f9da69390e 100644 --- a/mobile/openapi/test/memory_api_test.dart +++ b/mobile/openapi/test/memory_api_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/memory_create_dto_test.dart b/mobile/openapi/test/memory_create_dto_test.dart index bfb6f09e15..516d56b30a 100644 --- a/mobile/openapi/test/memory_create_dto_test.dart +++ b/mobile/openapi/test/memory_create_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/memory_lane_response_dto_test.dart b/mobile/openapi/test/memory_lane_response_dto_test.dart index a48d757e2c..a0662d5904 100644 --- a/mobile/openapi/test/memory_lane_response_dto_test.dart +++ b/mobile/openapi/test/memory_lane_response_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/memory_response_dto_test.dart b/mobile/openapi/test/memory_response_dto_test.dart index 90b2e78347..9c13a383ef 100644 --- a/mobile/openapi/test/memory_response_dto_test.dart +++ b/mobile/openapi/test/memory_response_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/memory_type_test.dart b/mobile/openapi/test/memory_type_test.dart index 0a6589d9ad..9dd1d9318f 100644 --- a/mobile/openapi/test/memory_type_test.dart +++ b/mobile/openapi/test/memory_type_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/memory_update_dto_test.dart b/mobile/openapi/test/memory_update_dto_test.dart index 173128e395..386be15f95 100644 --- a/mobile/openapi/test/memory_update_dto_test.dart +++ b/mobile/openapi/test/memory_update_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/merge_person_dto_test.dart b/mobile/openapi/test/merge_person_dto_test.dart index 4a22063a81..45a97b32b6 100644 --- a/mobile/openapi/test/merge_person_dto_test.dart +++ b/mobile/openapi/test/merge_person_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/metadata_search_dto_test.dart b/mobile/openapi/test/metadata_search_dto_test.dart index c630fec9a5..5665918e9e 100644 --- a/mobile/openapi/test/metadata_search_dto_test.dart +++ b/mobile/openapi/test/metadata_search_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/model_type_test.dart b/mobile/openapi/test/model_type_test.dart index 17089fcae6..dd219668d9 100644 --- a/mobile/openapi/test/model_type_test.dart +++ b/mobile/openapi/test/model_type_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/o_auth_api_test.dart b/mobile/openapi/test/o_auth_api_test.dart index 055963aaec..7c430fc96c 100644 --- a/mobile/openapi/test/o_auth_api_test.dart +++ b/mobile/openapi/test/o_auth_api_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/o_auth_authorize_response_dto_test.dart b/mobile/openapi/test/o_auth_authorize_response_dto_test.dart index 76b016642a..8a993216ba 100644 --- a/mobile/openapi/test/o_auth_authorize_response_dto_test.dart +++ b/mobile/openapi/test/o_auth_authorize_response_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/o_auth_callback_dto_test.dart b/mobile/openapi/test/o_auth_callback_dto_test.dart index 701b4666ab..4571dffc57 100644 --- a/mobile/openapi/test/o_auth_callback_dto_test.dart +++ b/mobile/openapi/test/o_auth_callback_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/o_auth_config_dto_test.dart b/mobile/openapi/test/o_auth_config_dto_test.dart index d887635d7a..423021caed 100644 --- a/mobile/openapi/test/o_auth_config_dto_test.dart +++ b/mobile/openapi/test/o_auth_config_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/on_this_day_dto_test.dart b/mobile/openapi/test/on_this_day_dto_test.dart index 71379f8bb0..b233ed1832 100644 --- a/mobile/openapi/test/on_this_day_dto_test.dart +++ b/mobile/openapi/test/on_this_day_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/partner_api_test.dart b/mobile/openapi/test/partner_api_test.dart index daaf656442..de63ad4e3a 100644 --- a/mobile/openapi/test/partner_api_test.dart +++ b/mobile/openapi/test/partner_api_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/partner_response_dto_test.dart b/mobile/openapi/test/partner_response_dto_test.dart index 2eef7f0c83..8aece38a6e 100644 --- a/mobile/openapi/test/partner_response_dto_test.dart +++ b/mobile/openapi/test/partner_response_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/path_entity_type_test.dart b/mobile/openapi/test/path_entity_type_test.dart index 7a9c9a714e..77dcd26abb 100644 --- a/mobile/openapi/test/path_entity_type_test.dart +++ b/mobile/openapi/test/path_entity_type_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/path_type_test.dart b/mobile/openapi/test/path_type_test.dart index 20862a0ef5..565b4ba9ee 100644 --- a/mobile/openapi/test/path_type_test.dart +++ b/mobile/openapi/test/path_type_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/people_response_dto_test.dart b/mobile/openapi/test/people_response_dto_test.dart index 94db6eb86b..22cc9f62c6 100644 --- a/mobile/openapi/test/people_response_dto_test.dart +++ b/mobile/openapi/test/people_response_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/people_update_dto_test.dart b/mobile/openapi/test/people_update_dto_test.dart index 841b6de6d8..560fe5e4f3 100644 --- a/mobile/openapi/test/people_update_dto_test.dart +++ b/mobile/openapi/test/people_update_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/people_update_item_test.dart b/mobile/openapi/test/people_update_item_test.dart index 8829352cf6..091a363ec9 100644 --- a/mobile/openapi/test/people_update_item_test.dart +++ b/mobile/openapi/test/people_update_item_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/person_api_test.dart b/mobile/openapi/test/person_api_test.dart index 959230cc59..a87851cbf3 100644 --- a/mobile/openapi/test/person_api_test.dart +++ b/mobile/openapi/test/person_api_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/person_create_dto_test.dart b/mobile/openapi/test/person_create_dto_test.dart index 96f1fe6d39..38b5ff123e 100644 --- a/mobile/openapi/test/person_create_dto_test.dart +++ b/mobile/openapi/test/person_create_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/person_response_dto_test.dart b/mobile/openapi/test/person_response_dto_test.dart index 0ba7306117..5691d2bc96 100644 --- a/mobile/openapi/test/person_response_dto_test.dart +++ b/mobile/openapi/test/person_response_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/person_statistics_response_dto_test.dart b/mobile/openapi/test/person_statistics_response_dto_test.dart index a58310d933..59a8b8e719 100644 --- a/mobile/openapi/test/person_statistics_response_dto_test.dart +++ b/mobile/openapi/test/person_statistics_response_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/person_update_dto_test.dart b/mobile/openapi/test/person_update_dto_test.dart index 6ed4482cc6..5b7ec50f96 100644 --- a/mobile/openapi/test/person_update_dto_test.dart +++ b/mobile/openapi/test/person_update_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/person_with_faces_response_dto_test.dart b/mobile/openapi/test/person_with_faces_response_dto_test.dart index 7f7e0f89ac..a97a7b3837 100644 --- a/mobile/openapi/test/person_with_faces_response_dto_test.dart +++ b/mobile/openapi/test/person_with_faces_response_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/places_response_dto_test.dart b/mobile/openapi/test/places_response_dto_test.dart index 5a320fce64..094223d729 100644 --- a/mobile/openapi/test/places_response_dto_test.dart +++ b/mobile/openapi/test/places_response_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/queue_status_dto_test.dart b/mobile/openapi/test/queue_status_dto_test.dart index f85eb9da86..ca9242aff1 100644 --- a/mobile/openapi/test/queue_status_dto_test.dart +++ b/mobile/openapi/test/queue_status_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/reaction_level_test.dart b/mobile/openapi/test/reaction_level_test.dart index 6fcba58b1a..aec882c659 100644 --- a/mobile/openapi/test/reaction_level_test.dart +++ b/mobile/openapi/test/reaction_level_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/reaction_type_test.dart b/mobile/openapi/test/reaction_type_test.dart index 4c0dfc5955..2adc0a549b 100644 --- a/mobile/openapi/test/reaction_type_test.dart +++ b/mobile/openapi/test/reaction_type_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/recognition_config_test.dart b/mobile/openapi/test/recognition_config_test.dart index ec006cf3d9..cb5b8b142d 100644 --- a/mobile/openapi/test/recognition_config_test.dart +++ b/mobile/openapi/test/recognition_config_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/reverse_geocoding_state_response_dto_test.dart b/mobile/openapi/test/reverse_geocoding_state_response_dto_test.dart index 91fdfcfea4..d99a1b5732 100644 --- a/mobile/openapi/test/reverse_geocoding_state_response_dto_test.dart +++ b/mobile/openapi/test/reverse_geocoding_state_response_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/scan_library_dto_test.dart b/mobile/openapi/test/scan_library_dto_test.dart index 2b3c758670..8634e88b05 100644 --- a/mobile/openapi/test/scan_library_dto_test.dart +++ b/mobile/openapi/test/scan_library_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/search_album_response_dto_test.dart b/mobile/openapi/test/search_album_response_dto_test.dart index 7002d34b09..9c29bc59c3 100644 --- a/mobile/openapi/test/search_album_response_dto_test.dart +++ b/mobile/openapi/test/search_album_response_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/search_api_test.dart b/mobile/openapi/test/search_api_test.dart index a00b1290f4..ce073bff61 100644 --- a/mobile/openapi/test/search_api_test.dart +++ b/mobile/openapi/test/search_api_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/search_asset_response_dto_test.dart b/mobile/openapi/test/search_asset_response_dto_test.dart index 56e8276171..430473e601 100644 --- a/mobile/openapi/test/search_asset_response_dto_test.dart +++ b/mobile/openapi/test/search_asset_response_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/search_explore_item_test.dart b/mobile/openapi/test/search_explore_item_test.dart index 2bc3ad7aee..6ae075859d 100644 --- a/mobile/openapi/test/search_explore_item_test.dart +++ b/mobile/openapi/test/search_explore_item_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/search_explore_response_dto_test.dart b/mobile/openapi/test/search_explore_response_dto_test.dart index ccc82a0d75..75d0413191 100644 --- a/mobile/openapi/test/search_explore_response_dto_test.dart +++ b/mobile/openapi/test/search_explore_response_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/search_facet_count_response_dto_test.dart b/mobile/openapi/test/search_facet_count_response_dto_test.dart index fb3caee627..e09de52e71 100644 --- a/mobile/openapi/test/search_facet_count_response_dto_test.dart +++ b/mobile/openapi/test/search_facet_count_response_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/search_facet_response_dto_test.dart b/mobile/openapi/test/search_facet_response_dto_test.dart index ed5e0d83aa..cc04e0c3dd 100644 --- a/mobile/openapi/test/search_facet_response_dto_test.dart +++ b/mobile/openapi/test/search_facet_response_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/search_response_dto_test.dart b/mobile/openapi/test/search_response_dto_test.dart index 06f8fa7a3a..e2ba5e7359 100644 --- a/mobile/openapi/test/search_response_dto_test.dart +++ b/mobile/openapi/test/search_response_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/search_suggestion_type_test.dart b/mobile/openapi/test/search_suggestion_type_test.dart index f86a7de90a..8f46ca13cf 100644 --- a/mobile/openapi/test/search_suggestion_type_test.dart +++ b/mobile/openapi/test/search_suggestion_type_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/server_config_dto_test.dart b/mobile/openapi/test/server_config_dto_test.dart index f76556c50f..fc8b5d8f06 100644 --- a/mobile/openapi/test/server_config_dto_test.dart +++ b/mobile/openapi/test/server_config_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/server_features_dto_test.dart b/mobile/openapi/test/server_features_dto_test.dart index b16d4cb6ca..5e2749e0a9 100644 --- a/mobile/openapi/test/server_features_dto_test.dart +++ b/mobile/openapi/test/server_features_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/server_info_api_test.dart b/mobile/openapi/test/server_info_api_test.dart index dac465116e..17c9a198da 100644 --- a/mobile/openapi/test/server_info_api_test.dart +++ b/mobile/openapi/test/server_info_api_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/server_info_response_dto_test.dart b/mobile/openapi/test/server_info_response_dto_test.dart index e41635ec51..bdcbbbfbff 100644 --- a/mobile/openapi/test/server_info_response_dto_test.dart +++ b/mobile/openapi/test/server_info_response_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/server_media_types_response_dto_test.dart b/mobile/openapi/test/server_media_types_response_dto_test.dart index 9ae2bfa2d9..706a66e382 100644 --- a/mobile/openapi/test/server_media_types_response_dto_test.dart +++ b/mobile/openapi/test/server_media_types_response_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/server_ping_response_test.dart b/mobile/openapi/test/server_ping_response_test.dart index cb72680176..dc76005d01 100644 --- a/mobile/openapi/test/server_ping_response_test.dart +++ b/mobile/openapi/test/server_ping_response_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/server_stats_response_dto_test.dart b/mobile/openapi/test/server_stats_response_dto_test.dart index fa7e032aa1..a09227105f 100644 --- a/mobile/openapi/test/server_stats_response_dto_test.dart +++ b/mobile/openapi/test/server_stats_response_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/server_theme_dto_test.dart b/mobile/openapi/test/server_theme_dto_test.dart index d340b8f55d..7551718204 100644 --- a/mobile/openapi/test/server_theme_dto_test.dart +++ b/mobile/openapi/test/server_theme_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/server_version_response_dto_test.dart b/mobile/openapi/test/server_version_response_dto_test.dart index add42ccd66..8cfdf83d2d 100644 --- a/mobile/openapi/test/server_version_response_dto_test.dart +++ b/mobile/openapi/test/server_version_response_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/session_response_dto_test.dart b/mobile/openapi/test/session_response_dto_test.dart index d704b2e5eb..eae28f6e84 100644 --- a/mobile/openapi/test/session_response_dto_test.dart +++ b/mobile/openapi/test/session_response_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/sessions_api_test.dart b/mobile/openapi/test/sessions_api_test.dart index 9fc6093c19..638ebbda66 100644 --- a/mobile/openapi/test/sessions_api_test.dart +++ b/mobile/openapi/test/sessions_api_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/shared_link_api_test.dart b/mobile/openapi/test/shared_link_api_test.dart index ebe5bf199f..63e2db6585 100644 --- a/mobile/openapi/test/shared_link_api_test.dart +++ b/mobile/openapi/test/shared_link_api_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/shared_link_create_dto_test.dart b/mobile/openapi/test/shared_link_create_dto_test.dart index 982d72a140..60a78a7f3f 100644 --- a/mobile/openapi/test/shared_link_create_dto_test.dart +++ b/mobile/openapi/test/shared_link_create_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/shared_link_edit_dto_test.dart b/mobile/openapi/test/shared_link_edit_dto_test.dart index f5c45190c3..3a07ef96ec 100644 --- a/mobile/openapi/test/shared_link_edit_dto_test.dart +++ b/mobile/openapi/test/shared_link_edit_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/shared_link_response_dto_test.dart b/mobile/openapi/test/shared_link_response_dto_test.dart index 0eb4ed50a7..e85a99d041 100644 --- a/mobile/openapi/test/shared_link_response_dto_test.dart +++ b/mobile/openapi/test/shared_link_response_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/shared_link_type_test.dart b/mobile/openapi/test/shared_link_type_test.dart index 6a2c8cdf51..427d027eba 100644 --- a/mobile/openapi/test/shared_link_type_test.dart +++ b/mobile/openapi/test/shared_link_type_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/sign_up_dto_test.dart b/mobile/openapi/test/sign_up_dto_test.dart index 3c255f7265..6a04249dfa 100644 --- a/mobile/openapi/test/sign_up_dto_test.dart +++ b/mobile/openapi/test/sign_up_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/smart_info_response_dto_test.dart b/mobile/openapi/test/smart_info_response_dto_test.dart index 15758d0636..e9c30c4056 100644 --- a/mobile/openapi/test/smart_info_response_dto_test.dart +++ b/mobile/openapi/test/smart_info_response_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/smart_search_dto_test.dart b/mobile/openapi/test/smart_search_dto_test.dart index 6ad95107ba..60f69e9152 100644 --- a/mobile/openapi/test/smart_search_dto_test.dart +++ b/mobile/openapi/test/smart_search_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/sync_api_test.dart b/mobile/openapi/test/sync_api_test.dart index c2f548aeb2..851235ea85 100644 --- a/mobile/openapi/test/sync_api_test.dart +++ b/mobile/openapi/test/sync_api_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/system_config_api_test.dart b/mobile/openapi/test/system_config_api_test.dart index 0330d6a3d0..f4d595dee6 100644 --- a/mobile/openapi/test/system_config_api_test.dart +++ b/mobile/openapi/test/system_config_api_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/system_config_dto_test.dart b/mobile/openapi/test/system_config_dto_test.dart index 3a6eb5523b..0437a299b3 100644 --- a/mobile/openapi/test/system_config_dto_test.dart +++ b/mobile/openapi/test/system_config_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/system_config_f_fmpeg_dto_test.dart b/mobile/openapi/test/system_config_f_fmpeg_dto_test.dart index b0a4f2afb8..3c038172bc 100644 --- a/mobile/openapi/test/system_config_f_fmpeg_dto_test.dart +++ b/mobile/openapi/test/system_config_f_fmpeg_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/system_config_image_dto_test.dart b/mobile/openapi/test/system_config_image_dto_test.dart index b46340455b..71089af094 100644 --- a/mobile/openapi/test/system_config_image_dto_test.dart +++ b/mobile/openapi/test/system_config_image_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/system_config_job_dto_test.dart b/mobile/openapi/test/system_config_job_dto_test.dart index 1bf0fa85ba..098943df0a 100644 --- a/mobile/openapi/test/system_config_job_dto_test.dart +++ b/mobile/openapi/test/system_config_job_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/system_config_library_dto_test.dart b/mobile/openapi/test/system_config_library_dto_test.dart index 6b24124591..9f92a4e0c9 100644 --- a/mobile/openapi/test/system_config_library_dto_test.dart +++ b/mobile/openapi/test/system_config_library_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/system_config_library_scan_dto_test.dart b/mobile/openapi/test/system_config_library_scan_dto_test.dart index 574013e752..4487ae55cf 100644 --- a/mobile/openapi/test/system_config_library_scan_dto_test.dart +++ b/mobile/openapi/test/system_config_library_scan_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/system_config_library_watch_dto_test.dart b/mobile/openapi/test/system_config_library_watch_dto_test.dart index 19b5de05fe..68e323e5ab 100644 --- a/mobile/openapi/test/system_config_library_watch_dto_test.dart +++ b/mobile/openapi/test/system_config_library_watch_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/system_config_logging_dto_test.dart b/mobile/openapi/test/system_config_logging_dto_test.dart index cc638f5310..62cc828f4a 100644 --- a/mobile/openapi/test/system_config_logging_dto_test.dart +++ b/mobile/openapi/test/system_config_logging_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/system_config_machine_learning_dto_test.dart b/mobile/openapi/test/system_config_machine_learning_dto_test.dart index 377f25ce09..61183846f7 100644 --- a/mobile/openapi/test/system_config_machine_learning_dto_test.dart +++ b/mobile/openapi/test/system_config_machine_learning_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/system_config_map_dto_test.dart b/mobile/openapi/test/system_config_map_dto_test.dart index b42753d4f4..eedd117897 100644 --- a/mobile/openapi/test/system_config_map_dto_test.dart +++ b/mobile/openapi/test/system_config_map_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/system_config_new_version_check_dto_test.dart b/mobile/openapi/test/system_config_new_version_check_dto_test.dart index 8711a52456..3bf0bb28e1 100644 --- a/mobile/openapi/test/system_config_new_version_check_dto_test.dart +++ b/mobile/openapi/test/system_config_new_version_check_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/system_config_notifications_dto_test.dart b/mobile/openapi/test/system_config_notifications_dto_test.dart index 4c18515ff0..01d0acd4b9 100644 --- a/mobile/openapi/test/system_config_notifications_dto_test.dart +++ b/mobile/openapi/test/system_config_notifications_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/system_config_o_auth_dto_test.dart b/mobile/openapi/test/system_config_o_auth_dto_test.dart index e855ff9608..1121d5c3bb 100644 --- a/mobile/openapi/test/system_config_o_auth_dto_test.dart +++ b/mobile/openapi/test/system_config_o_auth_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/system_config_password_login_dto_test.dart b/mobile/openapi/test/system_config_password_login_dto_test.dart index a8d87d1547..2518aedd44 100644 --- a/mobile/openapi/test/system_config_password_login_dto_test.dart +++ b/mobile/openapi/test/system_config_password_login_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/system_config_reverse_geocoding_dto_test.dart b/mobile/openapi/test/system_config_reverse_geocoding_dto_test.dart index b4aa477df3..15efaa8405 100644 --- a/mobile/openapi/test/system_config_reverse_geocoding_dto_test.dart +++ b/mobile/openapi/test/system_config_reverse_geocoding_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/system_config_server_dto_test.dart b/mobile/openapi/test/system_config_server_dto_test.dart index 5d10e0d7a2..8a6ef8621d 100644 --- a/mobile/openapi/test/system_config_server_dto_test.dart +++ b/mobile/openapi/test/system_config_server_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/system_config_smtp_dto_test.dart b/mobile/openapi/test/system_config_smtp_dto_test.dart index 92332ef5dc..c0cb2d967d 100644 --- a/mobile/openapi/test/system_config_smtp_dto_test.dart +++ b/mobile/openapi/test/system_config_smtp_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/system_config_smtp_transport_dto_test.dart b/mobile/openapi/test/system_config_smtp_transport_dto_test.dart index f1b5db0004..c42882ecfa 100644 --- a/mobile/openapi/test/system_config_smtp_transport_dto_test.dart +++ b/mobile/openapi/test/system_config_smtp_transport_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/system_config_storage_template_dto_test.dart b/mobile/openapi/test/system_config_storage_template_dto_test.dart index aa00b78fe4..a1bfa03900 100644 --- a/mobile/openapi/test/system_config_storage_template_dto_test.dart +++ b/mobile/openapi/test/system_config_storage_template_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/system_config_template_storage_option_dto_test.dart b/mobile/openapi/test/system_config_template_storage_option_dto_test.dart index 6082748336..c81bd4877a 100644 --- a/mobile/openapi/test/system_config_template_storage_option_dto_test.dart +++ b/mobile/openapi/test/system_config_template_storage_option_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/system_config_theme_dto_test.dart b/mobile/openapi/test/system_config_theme_dto_test.dart index 98e283559e..8ba707633e 100644 --- a/mobile/openapi/test/system_config_theme_dto_test.dart +++ b/mobile/openapi/test/system_config_theme_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/system_config_trash_dto_test.dart b/mobile/openapi/test/system_config_trash_dto_test.dart index a1b79f1138..af28a8bdf7 100644 --- a/mobile/openapi/test/system_config_trash_dto_test.dart +++ b/mobile/openapi/test/system_config_trash_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/system_config_user_dto_test.dart b/mobile/openapi/test/system_config_user_dto_test.dart index d3c7be050d..8173548f3a 100644 --- a/mobile/openapi/test/system_config_user_dto_test.dart +++ b/mobile/openapi/test/system_config_user_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/system_metadata_api_test.dart b/mobile/openapi/test/system_metadata_api_test.dart index bc1ce6f6f3..f6e8b10d9a 100644 --- a/mobile/openapi/test/system_metadata_api_test.dart +++ b/mobile/openapi/test/system_metadata_api_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/tag_api_test.dart b/mobile/openapi/test/tag_api_test.dart index 1b4d797448..ee5ab22232 100644 --- a/mobile/openapi/test/tag_api_test.dart +++ b/mobile/openapi/test/tag_api_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/tag_response_dto_test.dart b/mobile/openapi/test/tag_response_dto_test.dart index 88f483caf5..5f33df3aad 100644 --- a/mobile/openapi/test/tag_response_dto_test.dart +++ b/mobile/openapi/test/tag_response_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/tag_type_enum_test.dart b/mobile/openapi/test/tag_type_enum_test.dart index 07a0389466..8ce0779d86 100644 --- a/mobile/openapi/test/tag_type_enum_test.dart +++ b/mobile/openapi/test/tag_type_enum_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/thumbnail_format_test.dart b/mobile/openapi/test/thumbnail_format_test.dart index 9fb4c53878..10ca6e9dbd 100644 --- a/mobile/openapi/test/thumbnail_format_test.dart +++ b/mobile/openapi/test/thumbnail_format_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/time_bucket_response_dto_test.dart b/mobile/openapi/test/time_bucket_response_dto_test.dart index 9a09f2017d..ff2189686a 100644 --- a/mobile/openapi/test/time_bucket_response_dto_test.dart +++ b/mobile/openapi/test/time_bucket_response_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/time_bucket_size_test.dart b/mobile/openapi/test/time_bucket_size_test.dart index 8f0ca35b99..3f583e4d07 100644 --- a/mobile/openapi/test/time_bucket_size_test.dart +++ b/mobile/openapi/test/time_bucket_size_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/timeline_api_test.dart b/mobile/openapi/test/timeline_api_test.dart index ae217b2e40..4b79a3149c 100644 --- a/mobile/openapi/test/timeline_api_test.dart +++ b/mobile/openapi/test/timeline_api_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/tone_mapping_test.dart b/mobile/openapi/test/tone_mapping_test.dart index 0a719125de..9aca354366 100644 --- a/mobile/openapi/test/tone_mapping_test.dart +++ b/mobile/openapi/test/tone_mapping_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/transcode_hw_accel_test.dart b/mobile/openapi/test/transcode_hw_accel_test.dart index c9887c87d5..f6f8d9f7b5 100644 --- a/mobile/openapi/test/transcode_hw_accel_test.dart +++ b/mobile/openapi/test/transcode_hw_accel_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/transcode_policy_test.dart b/mobile/openapi/test/transcode_policy_test.dart index 4a27e2a881..2f46df1c3f 100644 --- a/mobile/openapi/test/transcode_policy_test.dart +++ b/mobile/openapi/test/transcode_policy_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/trash_api_test.dart b/mobile/openapi/test/trash_api_test.dart index e96c254e4f..5e40910f7d 100644 --- a/mobile/openapi/test/trash_api_test.dart +++ b/mobile/openapi/test/trash_api_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/update_album_dto_test.dart b/mobile/openapi/test/update_album_dto_test.dart index 7f1591a52c..c3845baad2 100644 --- a/mobile/openapi/test/update_album_dto_test.dart +++ b/mobile/openapi/test/update_album_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/update_album_user_dto_test.dart b/mobile/openapi/test/update_album_user_dto_test.dart index a42ca38b2c..15c5756b22 100644 --- a/mobile/openapi/test/update_album_user_dto_test.dart +++ b/mobile/openapi/test/update_album_user_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/update_asset_dto_test.dart b/mobile/openapi/test/update_asset_dto_test.dart index 9d9874beb8..4c3a918824 100644 --- a/mobile/openapi/test/update_asset_dto_test.dart +++ b/mobile/openapi/test/update_asset_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/update_library_dto_test.dart b/mobile/openapi/test/update_library_dto_test.dart index 0db376dddb..9b9f6c53b8 100644 --- a/mobile/openapi/test/update_library_dto_test.dart +++ b/mobile/openapi/test/update_library_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/update_partner_dto_test.dart b/mobile/openapi/test/update_partner_dto_test.dart index ca569914b7..b5af5cfaea 100644 --- a/mobile/openapi/test/update_partner_dto_test.dart +++ b/mobile/openapi/test/update_partner_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/update_stack_parent_dto_test.dart b/mobile/openapi/test/update_stack_parent_dto_test.dart index 6af71854ec..d158333ab5 100644 --- a/mobile/openapi/test/update_stack_parent_dto_test.dart +++ b/mobile/openapi/test/update_stack_parent_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/update_tag_dto_test.dart b/mobile/openapi/test/update_tag_dto_test.dart index 7c67e55d74..805eccf3e2 100644 --- a/mobile/openapi/test/update_tag_dto_test.dart +++ b/mobile/openapi/test/update_tag_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/update_user_dto_test.dart b/mobile/openapi/test/update_user_dto_test.dart index 10c506666d..284d48be12 100644 --- a/mobile/openapi/test/update_user_dto_test.dart +++ b/mobile/openapi/test/update_user_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/usage_by_user_dto_test.dart b/mobile/openapi/test/usage_by_user_dto_test.dart index 68a9f20846..ceaa01c6b8 100644 --- a/mobile/openapi/test/usage_by_user_dto_test.dart +++ b/mobile/openapi/test/usage_by_user_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/user_api_test.dart b/mobile/openapi/test/user_api_test.dart index 61df36243d..6cc025cccd 100644 --- a/mobile/openapi/test/user_api_test.dart +++ b/mobile/openapi/test/user_api_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/user_avatar_color_test.dart b/mobile/openapi/test/user_avatar_color_test.dart index 83480b580f..740af29be6 100644 --- a/mobile/openapi/test/user_avatar_color_test.dart +++ b/mobile/openapi/test/user_avatar_color_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/user_dto_test.dart b/mobile/openapi/test/user_dto_test.dart index 20229ff65b..5a4d033bf2 100644 --- a/mobile/openapi/test/user_dto_test.dart +++ b/mobile/openapi/test/user_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/user_response_dto_test.dart b/mobile/openapi/test/user_response_dto_test.dart index 71fa57f488..be0b83e9e0 100644 --- a/mobile/openapi/test/user_response_dto_test.dart +++ b/mobile/openapi/test/user_response_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/user_status_test.dart b/mobile/openapi/test/user_status_test.dart index 88abba0459..db28381a7b 100644 --- a/mobile/openapi/test/user_status_test.dart +++ b/mobile/openapi/test/user_status_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/validate_access_token_response_dto_test.dart b/mobile/openapi/test/validate_access_token_response_dto_test.dart index fff824700c..6c0427dcb1 100644 --- a/mobile/openapi/test/validate_access_token_response_dto_test.dart +++ b/mobile/openapi/test/validate_access_token_response_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/validate_library_dto_test.dart b/mobile/openapi/test/validate_library_dto_test.dart index 8d4922ee4a..2cc196f85e 100644 --- a/mobile/openapi/test/validate_library_dto_test.dart +++ b/mobile/openapi/test/validate_library_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/validate_library_import_path_response_dto_test.dart b/mobile/openapi/test/validate_library_import_path_response_dto_test.dart index a118698a13..a1a3650ba1 100644 --- a/mobile/openapi/test/validate_library_import_path_response_dto_test.dart +++ b/mobile/openapi/test/validate_library_import_path_response_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/validate_library_response_dto_test.dart b/mobile/openapi/test/validate_library_response_dto_test.dart index 6750809066..a11aff4993 100644 --- a/mobile/openapi/test/validate_library_response_dto_test.dart +++ b/mobile/openapi/test/validate_library_response_dto_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/mobile/openapi/test/video_codec_test.dart b/mobile/openapi/test/video_codec_test.dart index 8c1e4a17ff..ed5e26c1f4 100644 --- a/mobile/openapi/test/video_codec_test.dart +++ b/mobile/openapi/test/video_codec_test.dart @@ -1,7 +1,7 @@ // // AUTO-GENERATED FILE, DO NOT MODIFY! // -// @dart=2.12 +// @dart=2.18 // ignore_for_file: unused_element, unused_import // ignore_for_file: always_put_required_named_parameters_first diff --git a/open-api/bin/generate-open-api.sh b/open-api/bin/generate-open-api.sh index c972a4c806..4eb121102d 100755 --- a/open-api/bin/generate-open-api.sh +++ b/open-api/bin/generate-open-api.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -OPENAPI_GENERATOR_VERSION=v7.2.0 +OPENAPI_GENERATOR_VERSION=v7.5.0 # usage: ./bin/generate-open-api.sh diff --git a/open-api/openapitools.json b/open-api/openapitools.json index e73b975830..cfe74d51f8 100644 --- a/open-api/openapitools.json +++ b/open-api/openapitools.json @@ -2,6 +2,6 @@ "$schema": "./node_modules/@openapitools/openapi-generator-cli/config.schema.json", "spaces": 2, "generator-cli": { - "version": "7.2.0" + "version": "7.5.0" } } From 7f0f016f2e3463283d74c4458aec5d136f927558 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 15 May 2024 18:06:25 -0400 Subject: [PATCH 070/163] chore(deps): update dependency eslint-plugin-unicorn to v53 (#9502) * chore(deps): update dependency eslint-plugin-unicorn to v53 * use structured clone to match new eslint rules * use raw string instead of escaping slash --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Daniel Dietzler --- cli/package-lock.json | 145 +++++-- cli/package.json | 2 +- e2e/package-lock.json | 119 ++++-- e2e/package.json | 2 +- server/.eslintrc.js | 1 + server/package-lock.json | 353 ++++++++++++------ server/package.json | 2 +- ...faultOnboardingForExistingInstallations.ts | 2 +- web/package-lock.json | 129 ++++--- web/package.json | 2 +- 10 files changed, 511 insertions(+), 246 deletions(-) diff --git a/cli/package-lock.json b/cli/package-lock.json index 998a942546..fdb14af426 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -31,7 +31,7 @@ "eslint": "^8.56.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-prettier": "^5.1.3", - "eslint-plugin-unicorn": "^52.0.0", + "eslint-plugin-unicorn": "^53.0.0", "mock-fs": "^5.2.0", "prettier": "^3.2.5", "prettier-plugin-organize-imports": "^3.2.4", @@ -174,9 +174,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.22.20", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", - "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==", + "version": "7.24.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.5.tgz", + "integrity": "sha512-3q93SSKX2TWCG30M2G2kwaKeTYgEUp5Snjuj8qm729SObL6nbtUldAi37qbxkD5gg3xnBio+f9nqpSepGZMvxA==", "dev": true, "engines": { "node": ">=6.9.0" @@ -1822,12 +1822,12 @@ "dev": true }, "node_modules/core-js-compat": { - "version": "3.36.0", - "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.36.0.tgz", - "integrity": "sha512-iV9Pd/PsgjNWBXeq8XRtWVSgz2tKAfhfvBs7qxYty+RlRd+OCksaWmOnc4JKrTc1cToXL1N0s3l/vwlxPtdElw==", + "version": "3.37.1", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.37.1.tgz", + "integrity": "sha512-9TNiImhKvQqSUkOvk/mMRZzOANTiEVC7WaBNhHcKM7x+/5E1l5NvsysR19zuDQScE8k+kfQXWRN3AtS/eOSHpg==", "dev": true, "dependencies": { - "browserslist": "^4.22.3" + "browserslist": "^4.23.0" }, "funding": { "type": "opencollective", @@ -2094,17 +2094,17 @@ } }, "node_modules/eslint-plugin-unicorn": { - "version": "52.0.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-unicorn/-/eslint-plugin-unicorn-52.0.0.tgz", - "integrity": "sha512-1Yzm7/m+0R4djH0tjDjfVei/ju2w3AzUGjG6q8JnuNIL5xIwsflyCooW5sfBvQp2pMYQFSWWCFONsjCax1EHng==", + "version": "53.0.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-unicorn/-/eslint-plugin-unicorn-53.0.0.tgz", + "integrity": "sha512-kuTcNo9IwwUCfyHGwQFOK/HjJAYzbODHN3wP0PgqbW+jbXqpNWxNVpVhj2tO9SixBwuAdmal8rVcWKBxwFnGuw==", "dev": true, "dependencies": { - "@babel/helper-validator-identifier": "^7.22.20", + "@babel/helper-validator-identifier": "^7.24.5", "@eslint-community/eslint-utils": "^4.4.0", - "@eslint/eslintrc": "^2.1.4", + "@eslint/eslintrc": "^3.0.2", "ci-info": "^4.0.0", "clean-regexp": "^1.0.0", - "core-js-compat": "^3.34.0", + "core-js-compat": "^3.37.0", "esquery": "^1.5.0", "indent-string": "^4.0.0", "is-builtin-module": "^3.2.1", @@ -2113,11 +2113,11 @@ "read-pkg-up": "^7.0.1", "regexp-tree": "^0.1.27", "regjsparser": "^0.10.0", - "semver": "^7.5.4", + "semver": "^7.6.1", "strip-indent": "^3.0.0" }, "engines": { - "node": ">=16" + "node": ">=18.18" }, "funding": { "url": "https://github.com/sindresorhus/eslint-plugin-unicorn?sponsor=1" @@ -2126,6 +2126,92 @@ "eslint": ">=8.56.0" } }, + "node_modules/eslint-plugin-unicorn/node_modules/@eslint/eslintrc": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.0.2.tgz", + "integrity": "sha512-wV19ZEGEMAC1eHgrS7UQPqsdEiCIbTKTasEfcXAigzoXICcqZSjBZEHlZwNVvKg6UBCjSlos84XiLqsRJnIcIg==", + "dev": true, + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-plugin-unicorn/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint-plugin-unicorn/node_modules/eslint-visitor-keys": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.0.0.tgz", + "integrity": "sha512-OtIRv/2GyiF6o/d8K7MYKKbXrOUBIK6SfkIRM4Z0dY3w+LiQ0vy3F57m0Z71bjbyeiWFiHJ8brqnmE6H6/jEuw==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-plugin-unicorn/node_modules/espree": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.0.1.tgz", + "integrity": "sha512-MWkrWZbJsL2UwnjxTX3gG8FneachS/Mwg7tdGXce011sJd5b0JG54vat5KHnfSBODZ3Wvzd2WnjxyzsRoVv+ww==", + "dev": true, + "dependencies": { + "acorn": "^8.11.3", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-plugin-unicorn/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint-plugin-unicorn/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/eslint-scope": { "version": "7.2.2", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", @@ -3711,13 +3797,10 @@ } }, "node_modules/semver": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", - "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", + "version": "7.6.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", + "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", "dev": true, - "dependencies": { - "lru-cache": "^6.0.0" - }, "bin": { "semver": "bin/semver.js" }, @@ -3725,18 +3808,6 @@ "node": ">=10" } }, - "node_modules/semver/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -4407,12 +4478,6 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "dev": true }, - "node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, "node_modules/yaml": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.4.2.tgz", diff --git a/cli/package.json b/cli/package.json index b2f09d8481..1f6214ab5c 100644 --- a/cli/package.json +++ b/cli/package.json @@ -28,7 +28,7 @@ "eslint": "^8.56.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-prettier": "^5.1.3", - "eslint-plugin-unicorn": "^52.0.0", + "eslint-plugin-unicorn": "^53.0.0", "mock-fs": "^5.2.0", "prettier": "^3.2.5", "prettier-plugin-organize-imports": "^3.2.4", diff --git a/e2e/package-lock.json b/e2e/package-lock.json index 9f3fe078be..d79a017494 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -23,7 +23,7 @@ "eslint": "^8.57.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-prettier": "^5.1.3", - "eslint-plugin-unicorn": "^52.0.0", + "eslint-plugin-unicorn": "^53.0.0", "exiftool-vendored": "^26.0.0", "luxon": "^3.4.4", "pg": "^8.11.3", @@ -65,7 +65,7 @@ "eslint": "^8.56.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-prettier": "^5.1.3", - "eslint-plugin-unicorn": "^52.0.0", + "eslint-plugin-unicorn": "^53.0.0", "mock-fs": "^5.2.0", "prettier": "^3.2.5", "prettier-plugin-organize-imports": "^3.2.4", @@ -208,9 +208,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.22.20", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", - "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==", + "version": "7.24.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.5.tgz", + "integrity": "sha512-3q93SSKX2TWCG30M2G2kwaKeTYgEUp5Snjuj8qm729SObL6nbtUldAi37qbxkD5gg3xnBio+f9nqpSepGZMvxA==", "dev": true, "engines": { "node": ">=6.9.0" @@ -2121,12 +2121,12 @@ "dev": true }, "node_modules/core-js-compat": { - "version": "3.36.0", - "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.36.0.tgz", - "integrity": "sha512-iV9Pd/PsgjNWBXeq8XRtWVSgz2tKAfhfvBs7qxYty+RlRd+OCksaWmOnc4JKrTc1cToXL1N0s3l/vwlxPtdElw==", + "version": "3.37.1", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.37.1.tgz", + "integrity": "sha512-9TNiImhKvQqSUkOvk/mMRZzOANTiEVC7WaBNhHcKM7x+/5E1l5NvsysR19zuDQScE8k+kfQXWRN3AtS/eOSHpg==", "dev": true, "dependencies": { - "browserslist": "^4.22.3" + "browserslist": "^4.23.0" }, "funding": { "type": "opencollective", @@ -2487,17 +2487,17 @@ } }, "node_modules/eslint-plugin-unicorn": { - "version": "52.0.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-unicorn/-/eslint-plugin-unicorn-52.0.0.tgz", - "integrity": "sha512-1Yzm7/m+0R4djH0tjDjfVei/ju2w3AzUGjG6q8JnuNIL5xIwsflyCooW5sfBvQp2pMYQFSWWCFONsjCax1EHng==", + "version": "53.0.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-unicorn/-/eslint-plugin-unicorn-53.0.0.tgz", + "integrity": "sha512-kuTcNo9IwwUCfyHGwQFOK/HjJAYzbODHN3wP0PgqbW+jbXqpNWxNVpVhj2tO9SixBwuAdmal8rVcWKBxwFnGuw==", "dev": true, "dependencies": { - "@babel/helper-validator-identifier": "^7.22.20", + "@babel/helper-validator-identifier": "^7.24.5", "@eslint-community/eslint-utils": "^4.4.0", - "@eslint/eslintrc": "^2.1.4", + "@eslint/eslintrc": "^3.0.2", "ci-info": "^4.0.0", "clean-regexp": "^1.0.0", - "core-js-compat": "^3.34.0", + "core-js-compat": "^3.37.0", "esquery": "^1.5.0", "indent-string": "^4.0.0", "is-builtin-module": "^3.2.1", @@ -2506,11 +2506,11 @@ "read-pkg-up": "^7.0.1", "regexp-tree": "^0.1.27", "regjsparser": "^0.10.0", - "semver": "^7.5.4", + "semver": "^7.6.1", "strip-indent": "^3.0.0" }, "engines": { - "node": ">=16" + "node": ">=18.18" }, "funding": { "url": "https://github.com/sindresorhus/eslint-plugin-unicorn?sponsor=1" @@ -2519,6 +2519,70 @@ "eslint": ">=8.56.0" } }, + "node_modules/eslint-plugin-unicorn/node_modules/@eslint/eslintrc": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.0.2.tgz", + "integrity": "sha512-wV19ZEGEMAC1eHgrS7UQPqsdEiCIbTKTasEfcXAigzoXICcqZSjBZEHlZwNVvKg6UBCjSlos84XiLqsRJnIcIg==", + "dev": true, + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-plugin-unicorn/node_modules/eslint-visitor-keys": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.0.0.tgz", + "integrity": "sha512-OtIRv/2GyiF6o/d8K7MYKKbXrOUBIK6SfkIRM4Z0dY3w+LiQ0vy3F57m0Z71bjbyeiWFiHJ8brqnmE6H6/jEuw==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-plugin-unicorn/node_modules/espree": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.0.1.tgz", + "integrity": "sha512-MWkrWZbJsL2UwnjxTX3gG8FneachS/Mwg7tdGXce011sJd5b0JG54vat5KHnfSBODZ3Wvzd2WnjxyzsRoVv+ww==", + "dev": true, + "dependencies": { + "acorn": "^8.11.3", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-plugin-unicorn/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/eslint-scope": { "version": "7.2.2", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", @@ -3491,18 +3555,6 @@ "get-func-name": "^2.0.1" } }, - "node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/luxon": { "version": "3.4.4", "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.4.4.tgz", @@ -4710,13 +4762,10 @@ ] }, "node_modules/semver": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", - "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", + "version": "7.6.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", + "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", "dev": true, - "dependencies": { - "lru-cache": "^6.0.0" - }, "bin": { "semver": "bin/semver.js" }, diff --git a/e2e/package.json b/e2e/package.json index 88c40a8f0e..ba07d43648 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -33,7 +33,7 @@ "eslint": "^8.57.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-prettier": "^5.1.3", - "eslint-plugin-unicorn": "^52.0.0", + "eslint-plugin-unicorn": "^53.0.0", "exiftool-vendored": "^26.0.0", "luxon": "^3.4.4", "pg": "^8.11.3", diff --git a/server/.eslintrc.js b/server/.eslintrc.js index 79c48c4015..243f1b11e0 100644 --- a/server/.eslintrc.js +++ b/server/.eslintrc.js @@ -25,6 +25,7 @@ module.exports = { 'unicorn/prefer-event-target': 'off', 'unicorn/no-thenable': 'off', 'unicorn/import-style': 'off', + 'unicorn/prefer-structured-clone': 'off', '@typescript-eslint/await-thenable': 'error', '@typescript-eslint/no-floating-promises': 'error', '@typescript-eslint/no-misused-promises': 'error', diff --git a/server/package-lock.json b/server/package-lock.json index db43c435a2..8939386afb 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -91,7 +91,7 @@ "eslint": "^8.56.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-prettier": "^5.1.3", - "eslint-plugin-unicorn": "^52.0.0", + "eslint-plugin-unicorn": "^53.0.0", "mock-fs": "^5.2.0", "prettier": "^3.0.2", "prettier-plugin-organize-imports": "^3.2.3", @@ -342,9 +342,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.22.20", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", - "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==", + "version": "7.24.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.5.tgz", + "integrity": "sha512-3q93SSKX2TWCG30M2G2kwaKeTYgEUp5Snjuj8qm729SObL6nbtUldAi37qbxkD5gg3xnBio+f9nqpSepGZMvxA==", "engines": { "node": ">=6.9.0" } @@ -7133,9 +7133,9 @@ } }, "node_modules/browserslist": { - "version": "4.22.3", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.22.3.tgz", - "integrity": "sha512-UAp55yfwNv0klWNapjs/ktHoguxuQNGnOzxYmfnXIS+8AsRDZkSDxg7R1AX3GKzn078SBI5dzwzj/Yx0Or0e3A==", + "version": "4.23.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.0.tgz", + "integrity": "sha512-QW8HiM1shhT2GuzkvklfjcKDiWFXHOeFCIA/huJPwHsslwcydgk7X+z2zXpEijP98UCY7HbubZt5J2Zgvf0CaQ==", "funding": [ { "type": "opencollective", @@ -7151,8 +7151,8 @@ } ], "dependencies": { - "caniuse-lite": "^1.0.30001580", - "electron-to-chromium": "^1.4.648", + "caniuse-lite": "^1.0.30001587", + "electron-to-chromium": "^1.4.668", "node-releases": "^2.0.14", "update-browserslist-db": "^1.0.13" }, @@ -7346,9 +7346,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001581", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001581.tgz", - "integrity": "sha512-whlTkwhqV2tUmP3oYhtNfaWGYHDdS3JYFQBKXxcUR9qqPWsRhFHhoISO2Xnl/g0xyKzht9mI1LZpiNWfMzHixQ==", + "version": "1.0.30001618", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001618.tgz", + "integrity": "sha512-p407+D1tIkDvsEAPS22lJxLQQaG8OTBEqo0KhzfABGk0TU4juBNDSfH0hyAp/HRyx+M8L17z/ltyhxh27FTfQg==", "funding": [ { "type": "opencollective", @@ -7453,6 +7453,21 @@ "node": ">=6.0" } }, + "node_modules/ci-info": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.0.0.tgz", + "integrity": "sha512-TdHqgGf9odd8SXNuxtUBVx8Nv+qZOejE6qyqiy5NtbYYQOeFa6zmHkxlPzmaLxWWHsU6nJmB7AETdVPi+2NBUg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "engines": { + "node": ">=8" + } + }, "node_modules/cjs-module-lexer": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.2.3.tgz", @@ -7825,12 +7840,12 @@ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" }, "node_modules/core-js-compat": { - "version": "3.35.1", - "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.35.1.tgz", - "integrity": "sha512-sftHa5qUJY3rs9Zht1WEnmkvXputCyDBczPnr7QDgL8n3qrF3CMXY4VPSYtOLLiOUJcah2WNXREd48iOl6mQIw==", + "version": "3.37.1", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.37.1.tgz", + "integrity": "sha512-9TNiImhKvQqSUkOvk/mMRZzOANTiEVC7WaBNhHcKM7x+/5E1l5NvsysR19zuDQScE8k+kfQXWRN3AtS/eOSHpg==", "dev": true, "dependencies": { - "browserslist": "^4.22.2" + "browserslist": "^4.23.0" }, "funding": { "type": "opencollective", @@ -8405,9 +8420,9 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" }, "node_modules/electron-to-chromium": { - "version": "1.4.650", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.650.tgz", - "integrity": "sha512-sYSQhJCJa4aGA1wYol5cMQgekDBlbVfTRavlGZVr3WZpDdOPcp6a6xUnFfrt8TqZhsBYYbDxJZCjGfHuGupCRQ==" + "version": "1.4.769", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.769.tgz", + "integrity": "sha512-bZu7p623NEA2rHTc9K1vykl57ektSPQYFFqQir8BOYf6EKOB+yIsbFB9Kpm7Cgt6tsLr9sRkqfqSZUw7LP1XxQ==" }, "node_modules/emoji-regex": { "version": "8.0.0", @@ -8704,17 +8719,17 @@ } }, "node_modules/eslint-plugin-unicorn": { - "version": "52.0.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-unicorn/-/eslint-plugin-unicorn-52.0.0.tgz", - "integrity": "sha512-1Yzm7/m+0R4djH0tjDjfVei/ju2w3AzUGjG6q8JnuNIL5xIwsflyCooW5sfBvQp2pMYQFSWWCFONsjCax1EHng==", + "version": "53.0.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-unicorn/-/eslint-plugin-unicorn-53.0.0.tgz", + "integrity": "sha512-kuTcNo9IwwUCfyHGwQFOK/HjJAYzbODHN3wP0PgqbW+jbXqpNWxNVpVhj2tO9SixBwuAdmal8rVcWKBxwFnGuw==", "dev": true, "dependencies": { - "@babel/helper-validator-identifier": "^7.22.20", + "@babel/helper-validator-identifier": "^7.24.5", "@eslint-community/eslint-utils": "^4.4.0", - "@eslint/eslintrc": "^2.1.4", + "@eslint/eslintrc": "^3.0.2", "ci-info": "^4.0.0", "clean-regexp": "^1.0.0", - "core-js-compat": "^3.34.0", + "core-js-compat": "^3.37.0", "esquery": "^1.5.0", "indent-string": "^4.0.0", "is-builtin-module": "^3.2.1", @@ -8723,11 +8738,11 @@ "read-pkg-up": "^7.0.1", "regexp-tree": "^0.1.27", "regjsparser": "^0.10.0", - "semver": "^7.5.4", + "semver": "^7.6.1", "strip-indent": "^3.0.0" }, "engines": { - "node": ">=16" + "node": ">=18.18" }, "funding": { "url": "https://github.com/sindresorhus/eslint-plugin-unicorn?sponsor=1" @@ -8736,33 +8751,92 @@ "eslint": ">=8.56.0" } }, - "node_modules/eslint-plugin-unicorn/node_modules/ci-info": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.0.0.tgz", - "integrity": "sha512-TdHqgGf9odd8SXNuxtUBVx8Nv+qZOejE6qyqiy5NtbYYQOeFa6zmHkxlPzmaLxWWHsU6nJmB7AETdVPi+2NBUg==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], - "engines": { - "node": ">=8" - } - }, - "node_modules/eslint-plugin-unicorn/node_modules/jsesc": { + "node_modules/eslint-plugin-unicorn/node_modules/@eslint/eslintrc": { "version": "3.0.2", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", - "integrity": "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.0.2.tgz", + "integrity": "sha512-wV19ZEGEMAC1eHgrS7UQPqsdEiCIbTKTasEfcXAigzoXICcqZSjBZEHlZwNVvKg6UBCjSlos84XiLqsRJnIcIg==", "dev": true, - "bin": { - "jsesc": "bin/jsesc" + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" }, "engines": { - "node": ">=6" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint-plugin-unicorn/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/eslint-plugin-unicorn/node_modules/eslint-visitor-keys": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.0.0.tgz", + "integrity": "sha512-OtIRv/2GyiF6o/d8K7MYKKbXrOUBIK6SfkIRM4Z0dY3w+LiQ0vy3F57m0Z71bjbyeiWFiHJ8brqnmE6H6/jEuw==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-plugin-unicorn/node_modules/espree": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.0.1.tgz", + "integrity": "sha512-MWkrWZbJsL2UwnjxTX3gG8FneachS/Mwg7tdGXce011sJd5b0JG54vat5KHnfSBODZ3Wvzd2WnjxyzsRoVv+ww==", + "dev": true, + "dependencies": { + "acorn": "^8.11.3", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-plugin-unicorn/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint-plugin-unicorn/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, "node_modules/eslint-scope": { "version": "7.2.2", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", @@ -10496,6 +10570,18 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsesc": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", + "integrity": "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==", + "dev": true, + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/json-bigint": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", @@ -14018,12 +14104,9 @@ } }, "node_modules/semver": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", - "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", - "dependencies": { - "lru-cache": "^6.0.0" - }, + "version": "7.6.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", + "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", "bin": { "semver": "bin/semver.js" }, @@ -14031,22 +14114,6 @@ "node": ">=10" } }, - "node_modules/semver/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/semver/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" - }, "node_modules/send": { "version": "0.18.0", "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz", @@ -16679,9 +16746,9 @@ "integrity": "sha512-2ofRCjnnA9y+wk8b9IAREroeUP02KHp431N2mhKniy2yKIDKpbrHv9eXwm8cBeWQYcJmzv5qKCu65P47eCF7CQ==" }, "@babel/helper-validator-identifier": { - "version": "7.22.20", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", - "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==" + "version": "7.24.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.5.tgz", + "integrity": "sha512-3q93SSKX2TWCG30M2G2kwaKeTYgEUp5Snjuj8qm729SObL6nbtUldAi37qbxkD5gg3xnBio+f9nqpSepGZMvxA==" }, "@babel/highlight": { "version": "7.24.2", @@ -21048,12 +21115,12 @@ } }, "browserslist": { - "version": "4.22.3", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.22.3.tgz", - "integrity": "sha512-UAp55yfwNv0klWNapjs/ktHoguxuQNGnOzxYmfnXIS+8AsRDZkSDxg7R1AX3GKzn078SBI5dzwzj/Yx0Or0e3A==", + "version": "4.23.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.0.tgz", + "integrity": "sha512-QW8HiM1shhT2GuzkvklfjcKDiWFXHOeFCIA/huJPwHsslwcydgk7X+z2zXpEijP98UCY7HbubZt5J2Zgvf0CaQ==", "requires": { - "caniuse-lite": "^1.0.30001580", - "electron-to-chromium": "^1.4.648", + "caniuse-lite": "^1.0.30001587", + "electron-to-chromium": "^1.4.668", "node-releases": "^2.0.14", "update-browserslist-db": "^1.0.13" } @@ -21184,9 +21251,9 @@ "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==" }, "caniuse-lite": { - "version": "1.0.30001581", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001581.tgz", - "integrity": "sha512-whlTkwhqV2tUmP3oYhtNfaWGYHDdS3JYFQBKXxcUR9qqPWsRhFHhoISO2Xnl/g0xyKzht9mI1LZpiNWfMzHixQ==" + "version": "1.0.30001618", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001618.tgz", + "integrity": "sha512-p407+D1tIkDvsEAPS22lJxLQQaG8OTBEqo0KhzfABGk0TU4juBNDSfH0hyAp/HRyx+M8L17z/ltyhxh27FTfQg==" }, "chai": { "version": "4.4.1", @@ -21251,6 +21318,12 @@ "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz", "integrity": "sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg==" }, + "ci-info": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.0.0.tgz", + "integrity": "sha512-TdHqgGf9odd8SXNuxtUBVx8Nv+qZOejE6qyqiy5NtbYYQOeFa6zmHkxlPzmaLxWWHsU6nJmB7AETdVPi+2NBUg==", + "dev": true + }, "cjs-module-lexer": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.2.3.tgz", @@ -21534,12 +21607,12 @@ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" }, "core-js-compat": { - "version": "3.35.1", - "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.35.1.tgz", - "integrity": "sha512-sftHa5qUJY3rs9Zht1WEnmkvXputCyDBczPnr7QDgL8n3qrF3CMXY4VPSYtOLLiOUJcah2WNXREd48iOl6mQIw==", + "version": "3.37.1", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.37.1.tgz", + "integrity": "sha512-9TNiImhKvQqSUkOvk/mMRZzOANTiEVC7WaBNhHcKM7x+/5E1l5NvsysR19zuDQScE8k+kfQXWRN3AtS/eOSHpg==", "dev": true, "requires": { - "browserslist": "^4.22.2" + "browserslist": "^4.23.0" } }, "core-util-is": { @@ -21949,9 +22022,9 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" }, "electron-to-chromium": { - "version": "1.4.650", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.650.tgz", - "integrity": "sha512-sYSQhJCJa4aGA1wYol5cMQgekDBlbVfTRavlGZVr3WZpDdOPcp6a6xUnFfrt8TqZhsBYYbDxJZCjGfHuGupCRQ==" + "version": "1.4.769", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.769.tgz", + "integrity": "sha512-bZu7p623NEA2rHTc9K1vykl57ektSPQYFFqQir8BOYf6EKOB+yIsbFB9Kpm7Cgt6tsLr9sRkqfqSZUw7LP1XxQ==" }, "emoji-regex": { "version": "8.0.0", @@ -22196,17 +22269,17 @@ } }, "eslint-plugin-unicorn": { - "version": "52.0.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-unicorn/-/eslint-plugin-unicorn-52.0.0.tgz", - "integrity": "sha512-1Yzm7/m+0R4djH0tjDjfVei/ju2w3AzUGjG6q8JnuNIL5xIwsflyCooW5sfBvQp2pMYQFSWWCFONsjCax1EHng==", + "version": "53.0.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-unicorn/-/eslint-plugin-unicorn-53.0.0.tgz", + "integrity": "sha512-kuTcNo9IwwUCfyHGwQFOK/HjJAYzbODHN3wP0PgqbW+jbXqpNWxNVpVhj2tO9SixBwuAdmal8rVcWKBxwFnGuw==", "dev": true, "requires": { - "@babel/helper-validator-identifier": "^7.22.20", + "@babel/helper-validator-identifier": "^7.24.5", "@eslint-community/eslint-utils": "^4.4.0", - "@eslint/eslintrc": "^2.1.4", + "@eslint/eslintrc": "^3.0.2", "ci-info": "^4.0.0", "clean-regexp": "^1.0.0", - "core-js-compat": "^3.34.0", + "core-js-compat": "^3.37.0", "esquery": "^1.5.0", "indent-string": "^4.0.0", "is-builtin-module": "^3.2.1", @@ -22215,20 +22288,66 @@ "read-pkg-up": "^7.0.1", "regexp-tree": "^0.1.27", "regjsparser": "^0.10.0", - "semver": "^7.5.4", + "semver": "^7.6.1", "strip-indent": "^3.0.0" }, "dependencies": { - "ci-info": { + "@eslint/eslintrc": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.0.2.tgz", + "integrity": "sha512-wV19ZEGEMAC1eHgrS7UQPqsdEiCIbTKTasEfcXAigzoXICcqZSjBZEHlZwNVvKg6UBCjSlos84XiLqsRJnIcIg==", + "dev": true, + "requires": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + } + }, + "ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "requires": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + } + }, + "eslint-visitor-keys": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.0.0.tgz", - "integrity": "sha512-TdHqgGf9odd8SXNuxtUBVx8Nv+qZOejE6qyqiy5NtbYYQOeFa6zmHkxlPzmaLxWWHsU6nJmB7AETdVPi+2NBUg==", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.0.0.tgz", + "integrity": "sha512-OtIRv/2GyiF6o/d8K7MYKKbXrOUBIK6SfkIRM4Z0dY3w+LiQ0vy3F57m0Z71bjbyeiWFiHJ8brqnmE6H6/jEuw==", "dev": true }, - "jsesc": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", - "integrity": "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==", + "espree": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.0.1.tgz", + "integrity": "sha512-MWkrWZbJsL2UwnjxTX3gG8FneachS/Mwg7tdGXce011sJd5b0JG54vat5KHnfSBODZ3Wvzd2WnjxyzsRoVv+ww==", + "dev": true, + "requires": { + "acorn": "^8.11.3", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.0.0" + } + }, + "globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true + }, + "json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "dev": true } } @@ -23460,6 +23579,12 @@ "argparse": "^2.0.1" } }, + "jsesc": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", + "integrity": "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==", + "dev": true + }, "json-bigint": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", @@ -25815,27 +25940,9 @@ } }, "semver": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", - "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", - "requires": { - "lru-cache": "^6.0.0" - }, - "dependencies": { - "lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "requires": { - "yallist": "^4.0.0" - } - }, - "yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" - } - } + "version": "7.6.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", + "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==" }, "send": { "version": "0.18.0", diff --git a/server/package.json b/server/package.json index b59ce8fa76..ad73a99ea0 100644 --- a/server/package.json +++ b/server/package.json @@ -115,7 +115,7 @@ "eslint": "^8.56.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-prettier": "^5.1.3", - "eslint-plugin-unicorn": "^52.0.0", + "eslint-plugin-unicorn": "^53.0.0", "mock-fs": "^5.2.0", "prettier": "^3.0.2", "prettier-plugin-organize-imports": "^3.2.3", diff --git a/server/src/migrations/1704571051932-DefaultOnboardingForExistingInstallations.ts b/server/src/migrations/1704571051932-DefaultOnboardingForExistingInstallations.ts index cba248b5f7..cd737b2a62 100644 --- a/server/src/migrations/1704571051932-DefaultOnboardingForExistingInstallations.ts +++ b/server/src/migrations/1704571051932-DefaultOnboardingForExistingInstallations.ts @@ -6,7 +6,7 @@ export class DefaultOnboardingForExistingInstallations1704571051932 implements M if (adminCount[0].count > 0) { await queryRunner.query(`INSERT INTO system_metadata (key, value) VALUES ($1, $2)`, [ 'admin-onboarding', - '"{\\"isOnboarded\\":true}"', + String.raw`"{\"isOnboarded\":true}"`, ]); } } diff --git a/web/package-lock.json b/web/package-lock.json index fe53dd2cd0..cf9b815237 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -48,7 +48,7 @@ "eslint": "^8.57.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-svelte": "^2.35.1", - "eslint-plugin-unicorn": "^52.0.0", + "eslint-plugin-unicorn": "^53.0.0", "factory.ts": "^1.4.1", "postcss": "^8.4.35", "prettier": "^3.2.5", @@ -319,9 +319,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.22.20", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", - "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==", + "version": "7.24.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.5.tgz", + "integrity": "sha512-3q93SSKX2TWCG30M2G2kwaKeTYgEUp5Snjuj8qm729SObL6nbtUldAi37qbxkD5gg3xnBio+f9nqpSepGZMvxA==", "dev": true, "engines": { "node": ">=6.9.0" @@ -2833,9 +2833,9 @@ } }, "node_modules/acorn": { - "version": "8.10.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.10.0.tgz", - "integrity": "sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw==", + "version": "8.11.3", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", + "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", "bin": { "acorn": "bin/acorn" }, @@ -3536,12 +3536,12 @@ "integrity": "sha512-3VCXVl2IpFfOyD8drv9DozcNlwmqBqxOlsgkEGyVAzadjlPk1go8YNZyy8QmTnwHPxSFpeCR9OdsStEdVK7qDA==" }, "node_modules/core-js-compat": { - "version": "3.35.1", - "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.35.1.tgz", - "integrity": "sha512-sftHa5qUJY3rs9Zht1WEnmkvXputCyDBczPnr7QDgL8n3qrF3CMXY4VPSYtOLLiOUJcah2WNXREd48iOl6mQIw==", + "version": "3.37.1", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.37.1.tgz", + "integrity": "sha512-9TNiImhKvQqSUkOvk/mMRZzOANTiEVC7WaBNhHcKM7x+/5E1l5NvsysR19zuDQScE8k+kfQXWRN3AtS/eOSHpg==", "dev": true, "dependencies": { - "browserslist": "^4.22.2" + "browserslist": "^4.23.0" }, "funding": { "type": "opencollective", @@ -4208,17 +4208,17 @@ "dev": true }, "node_modules/eslint-plugin-unicorn": { - "version": "52.0.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-unicorn/-/eslint-plugin-unicorn-52.0.0.tgz", - "integrity": "sha512-1Yzm7/m+0R4djH0tjDjfVei/ju2w3AzUGjG6q8JnuNIL5xIwsflyCooW5sfBvQp2pMYQFSWWCFONsjCax1EHng==", + "version": "53.0.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-unicorn/-/eslint-plugin-unicorn-53.0.0.tgz", + "integrity": "sha512-kuTcNo9IwwUCfyHGwQFOK/HjJAYzbODHN3wP0PgqbW+jbXqpNWxNVpVhj2tO9SixBwuAdmal8rVcWKBxwFnGuw==", "dev": true, "dependencies": { - "@babel/helper-validator-identifier": "^7.22.20", + "@babel/helper-validator-identifier": "^7.24.5", "@eslint-community/eslint-utils": "^4.4.0", - "@eslint/eslintrc": "^2.1.4", + "@eslint/eslintrc": "^3.0.2", "ci-info": "^4.0.0", "clean-regexp": "^1.0.0", - "core-js-compat": "^3.34.0", + "core-js-compat": "^3.37.0", "esquery": "^1.5.0", "indent-string": "^4.0.0", "is-builtin-module": "^3.2.1", @@ -4227,11 +4227,11 @@ "read-pkg-up": "^7.0.1", "regexp-tree": "^0.1.27", "regjsparser": "^0.10.0", - "semver": "^7.5.4", + "semver": "^7.6.1", "strip-indent": "^3.0.0" }, "engines": { - "node": ">=16" + "node": ">=18.18" }, "funding": { "url": "https://github.com/sindresorhus/eslint-plugin-unicorn?sponsor=1" @@ -4240,6 +4240,70 @@ "eslint": ">=8.56.0" } }, + "node_modules/eslint-plugin-unicorn/node_modules/@eslint/eslintrc": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.0.2.tgz", + "integrity": "sha512-wV19ZEGEMAC1eHgrS7UQPqsdEiCIbTKTasEfcXAigzoXICcqZSjBZEHlZwNVvKg6UBCjSlos84XiLqsRJnIcIg==", + "dev": true, + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-plugin-unicorn/node_modules/eslint-visitor-keys": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.0.0.tgz", + "integrity": "sha512-OtIRv/2GyiF6o/d8K7MYKKbXrOUBIK6SfkIRM4Z0dY3w+LiQ0vy3F57m0Z71bjbyeiWFiHJ8brqnmE6H6/jEuw==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-plugin-unicorn/node_modules/espree": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.0.1.tgz", + "integrity": "sha512-MWkrWZbJsL2UwnjxTX3gG8FneachS/Mwg7tdGXce011sJd5b0JG54vat5KHnfSBODZ3Wvzd2WnjxyzsRoVv+ww==", + "dev": true, + "dependencies": { + "acorn": "^8.11.3", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-plugin-unicorn/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/eslint-plugin-unicorn/node_modules/jsesc": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", @@ -4252,26 +4316,11 @@ "node": ">=6" } }, - "node_modules/eslint-plugin-unicorn/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/eslint-plugin-unicorn/node_modules/semver": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", - "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", + "version": "7.6.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", + "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", "dev": true, - "dependencies": { - "lru-cache": "^6.0.0" - }, "bin": { "semver": "bin/semver.js" }, @@ -4279,12 +4328,6 @@ "node": ">=10" } }, - "node_modules/eslint-plugin-unicorn/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, "node_modules/eslint-scope": { "version": "7.2.2", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", diff --git a/web/package.json b/web/package.json index 01613c0cf3..980434ca67 100644 --- a/web/package.json +++ b/web/package.json @@ -43,7 +43,7 @@ "eslint": "^8.57.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-svelte": "^2.35.1", - "eslint-plugin-unicorn": "^52.0.0", + "eslint-plugin-unicorn": "^53.0.0", "factory.ts": "^1.4.1", "postcss": "^8.4.35", "prettier": "^3.2.5", From 984aa8fb414603b72bacb3b9714c025b7611b80f Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Wed, 15 May 2024 18:58:23 -0400 Subject: [PATCH 071/163] refactor(server): system config (#9517) --- docs/docs/guides/database-queries.md | 2 +- e2e/src/utils.ts | 1 - server/src/cores/storage.core.ts | 10 +- server/src/cores/system-config.core.ts | 98 ++---- server/src/entities/index.ts | 2 - server/src/entities/system-config.entity.ts | 145 -------- server/src/entities/system-metadata.entity.ts | 15 +- .../src/interfaces/system-config.interface.ts | 11 - .../interfaces/system-metadata.interface.ts | 2 + .../1715787369686-RemoveSystemConfigTable.ts | 31 ++ .../src/queries/system.config.repository.sql | 13 - server/src/repositories/index.ts | 3 - .../repositories/system-config.repository.ts | 50 --- .../system-metadata.repository.ts | 19 + server/src/services/asset.service.spec.ts | 10 +- server/src/services/asset.service.ts | 6 +- server/src/services/auth.service.spec.ts | 40 +-- server/src/services/auth.service.ts | 6 +- server/src/services/job.service.spec.ts | 14 +- server/src/services/job.service.ts | 6 +- server/src/services/library.service.spec.ts | 38 +- server/src/services/library.service.ts | 6 +- server/src/services/media.service.spec.ts | 332 +++++++----------- server/src/services/media.service.ts | 9 +- server/src/services/metadata.service.spec.ts | 15 +- server/src/services/metadata.service.ts | 8 +- server/src/services/notification.service.ts | 6 +- server/src/services/person.service.spec.ts | 44 +-- server/src/services/person.service.ts | 8 +- server/src/services/search.service.spec.ts | 10 +- server/src/services/search.service.ts | 6 +- .../src/services/server-info.service.spec.ts | 22 +- server/src/services/server-info.service.ts | 4 +- .../src/services/smart-info.service.spec.ts | 16 +- server/src/services/smart-info.service.ts | 6 +- .../services/storage-template.service.spec.ts | 25 +- .../src/services/storage-template.service.ts | 8 +- .../services/system-config.service.spec.ts | 76 ++-- server/src/services/system-config.service.ts | 4 +- server/src/services/user.service.spec.ts | 12 +- server/src/services/user.service.ts | 6 +- server/src/utils/misc.spec.ts | 52 +++ server/src/utils/misc.ts | 41 +++ server/test/fixtures/system-config.stub.ts | 105 ++++-- .../system-config.repository.mock.ts | 17 - .../system-metadata.repository.mock.ts | 9 +- 46 files changed, 599 insertions(+), 770 deletions(-) delete mode 100644 server/src/entities/system-config.entity.ts delete mode 100644 server/src/interfaces/system-config.interface.ts create mode 100644 server/src/migrations/1715787369686-RemoveSystemConfigTable.ts delete mode 100644 server/src/queries/system.config.repository.sql delete mode 100644 server/src/repositories/system-config.repository.ts create mode 100644 server/src/utils/misc.spec.ts delete mode 100644 server/test/repositories/system-config.repository.mock.ts diff --git a/docs/docs/guides/database-queries.md b/docs/docs/guides/database-queries.md index e20321e052..8baf9cf825 100644 --- a/docs/docs/guides/database-queries.md +++ b/docs/docs/guides/database-queries.md @@ -96,7 +96,7 @@ SELECT * FROM "users"; ## System Config ```sql title="Custom settings" -SELECT "key", "value" FROM "system_config"; +SELECT "key", "value" FROM "system_metadata" WHERE "key" = 'system-config'; ``` (Only used when not using the [config file](/docs/install/config-file)) diff --git a/e2e/src/utils.ts b/e2e/src/utils.ts index 908c87faed..12dbac2f0a 100644 --- a/e2e/src/utils.ts +++ b/e2e/src/utils.ts @@ -145,7 +145,6 @@ export const utils = { 'sessions', 'users', 'system_metadata', - 'system_config', ]; const sql: string[] = []; diff --git a/server/src/cores/storage.core.ts b/server/src/cores/storage.core.ts index eace24d5be..5861c31ff8 100644 --- a/server/src/cores/storage.core.ts +++ b/server/src/cores/storage.core.ts @@ -12,7 +12,7 @@ import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IMoveRepository } from 'src/interfaces/move.interface'; import { IPersonRepository } from 'src/interfaces/person.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; -import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; +import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; export enum StorageFolder { ENCODED_VIDEO = 'encoded-video', @@ -49,10 +49,10 @@ export class StorageCore { private moveRepository: IMoveRepository, private personRepository: IPersonRepository, private storageRepository: IStorageRepository, - systemConfigRepository: ISystemConfigRepository, + systemMetadataRepository: ISystemMetadataRepository, private logger: ILoggerRepository, ) { - this.configCore = SystemConfigCore.create(systemConfigRepository, this.logger); + this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); } static create( @@ -61,7 +61,7 @@ export class StorageCore { moveRepository: IMoveRepository, personRepository: IPersonRepository, storageRepository: IStorageRepository, - systemConfigRepository: ISystemConfigRepository, + systemMetadataRepository: ISystemMetadataRepository, logger: ILoggerRepository, ) { if (!instance) { @@ -71,7 +71,7 @@ export class StorageCore { moveRepository, personRepository, storageRepository, - systemConfigRepository, + systemMetadataRepository, logger, ); } diff --git a/server/src/cores/system-config.core.ts b/server/src/cores/system-config.core.ts index e5fda4d376..97d5fd82c6 100644 --- a/server/src/cores/system-config.core.ts +++ b/server/src/cores/system-config.core.ts @@ -7,10 +7,12 @@ import * as _ from 'lodash'; import { Subject } from 'rxjs'; import { SystemConfig, defaults } from 'src/config'; import { SystemConfigDto } from 'src/dtos/system-config.dto'; -import { SystemConfigEntity, SystemConfigKey, SystemConfigValue } from 'src/entities/system-config.entity'; +import { SystemMetadataKey } from 'src/entities/system-metadata.entity'; import { DatabaseLock } from 'src/interfaces/database.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; -import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; +import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +import { getKeysDeep, unsetDeep } from 'src/utils/misc'; +import { DeepPartial } from 'typeorm'; export type SystemConfigValidator = (config: SystemConfig, newConfig: SystemConfig) => void | Promise; @@ -25,11 +27,11 @@ export class SystemConfigCore { config$ = new Subject(); private constructor( - private repository: ISystemConfigRepository, + private repository: ISystemMetadataRepository, private logger: ILoggerRepository, ) {} - static create(repository: ISystemConfigRepository, logger: ILoggerRepository) { + static create(repository: ISystemMetadataRepository, logger: ILoggerRepository) { if (!instance) { instance = new SystemConfigCore(repository, logger); } @@ -55,41 +57,25 @@ export class SystemConfigCore { } async updateConfig(newConfig: SystemConfig): Promise { - const updates: SystemConfigEntity[] = []; - const deletes: SystemConfigEntity[] = []; + // get the difference between the new config and the default config + const partialConfig: DeepPartial = {}; + for (const property of getKeysDeep(defaults)) { + const newValue = _.get(newConfig, property); + const isEmpty = newValue === undefined || newValue === null || newValue === ''; + const defaultValue = _.get(defaults, property); + const isEqual = newValue === defaultValue || _.isEqual(newValue, defaultValue); - for (const key of Object.values(SystemConfigKey)) { - // get via dot notation - const item = { key, value: _.get(newConfig, key) as SystemConfigValue }; - const defaultValue = _.get(defaults, key); - const isMissing = !_.has(newConfig, key); - - if ( - isMissing || - item.value === null || - item.value === '' || - item.value === defaultValue || - _.isEqual(item.value, defaultValue) - ) { - deletes.push(item); + if (isEmpty || isEqual) { continue; } - updates.push(item); + _.set(partialConfig, property, newValue); } - if (updates.length > 0) { - await this.repository.saveAll(updates); - } - - if (deletes.length > 0) { - await this.repository.deleteKeys(deletes.map((item) => item.key)); - } + await this.repository.set(SystemMetadataKey.SYSTEM_CONFIG, partialConfig); const config = await this.getConfig(true); - this.config$.next(config); - return config; } @@ -103,16 +89,28 @@ export class SystemConfigCore { } private async buildConfig() { - const config = _.cloneDeep(defaults); - const overrides = this.isUsingConfigFile() + // load partial + const partial = this.isUsingConfigFile() ? await this.loadFromFile(process.env.IMMICH_CONFIG_FILE as string) - : await this.repository.load(); + : await this.repository.get(SystemMetadataKey.SYSTEM_CONFIG); - for (const { key, value } of overrides) { - // set via dot notation - _.set(config, key, value); + // merge with defaults + const config = _.cloneDeep(defaults); + for (const property of getKeysDeep(partial)) { + _.set(config, property, _.get(partial, property)); } + // check for extra properties + const unknownKeys = _.cloneDeep(config); + for (const property of getKeysDeep(defaults)) { + unsetDeep(unknownKeys, property); + } + + if (!_.isEmpty(unknownKeys)) { + this.logger.warn(`Unknown keys found: ${JSON.stringify(unknownKeys, null, 2)}`); + } + + // validate full config const errors = await validate(plainToInstance(SystemConfigDto, config)); if (errors.length > 0) { if (this.isUsingConfigFile()) { @@ -136,36 +134,10 @@ export class SystemConfigCore { private async loadFromFile(filepath: string) { try { const file = await this.repository.readFile(filepath); - const config = loadYaml(file.toString()) as any; - const overrides: SystemConfigEntity[] = []; - - for (const key of Object.values(SystemConfigKey)) { - const value = _.get(config, key); - this.unsetDeep(config, key); - if (value !== undefined) { - overrides.push({ key, value }); - } - } - - if (!_.isEmpty(config)) { - this.logger.warn(`Unknown keys found: ${JSON.stringify(config, null, 2)}`); - } - - return overrides; + return loadYaml(file.toString()) as unknown; } catch (error: Error | any) { this.logger.error(`Unable to load configuration file: ${filepath}`); throw error; } } - - private unsetDeep(object: object, key: string) { - _.unset(object, key); - const path = key.split('.'); - while (path.pop()) { - if (!_.isEmpty(_.get(object, path))) { - return; - } - _.unset(object, path); - } - } } diff --git a/server/src/entities/index.ts b/server/src/entities/index.ts index 0862dd48a2..abe67efdd5 100644 --- a/server/src/entities/index.ts +++ b/server/src/entities/index.ts @@ -18,7 +18,6 @@ import { SessionEntity } from 'src/entities/session.entity'; import { SharedLinkEntity } from 'src/entities/shared-link.entity'; import { SmartInfoEntity } from 'src/entities/smart-info.entity'; import { SmartSearchEntity } from 'src/entities/smart-search.entity'; -import { SystemConfigEntity } from 'src/entities/system-config.entity'; import { SystemMetadataEntity } from 'src/entities/system-metadata.entity'; import { TagEntity } from 'src/entities/tag.entity'; import { UserEntity } from 'src/entities/user.entity'; @@ -42,7 +41,6 @@ export const entities = [ SharedLinkEntity, SmartInfoEntity, SmartSearchEntity, - SystemConfigEntity, SystemMetadataEntity, TagEntity, UserEntity, diff --git a/server/src/entities/system-config.entity.ts b/server/src/entities/system-config.entity.ts deleted file mode 100644 index 64342cc195..0000000000 --- a/server/src/entities/system-config.entity.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { SystemConfig } from 'src/config'; -import { Column, Entity, PrimaryColumn } from 'typeorm'; - -export type SystemConfigValue = string | string[] | number | boolean; - -// https://stackoverflow.com/a/47058976 -// https://stackoverflow.com/a/70692231 -type PathsToStringProps = T extends SystemConfigValue - ? [] - : { - [K in keyof T]: [K, ...PathsToStringProps]; - }[keyof T]; - -type Join = T extends [] - ? never - : T extends [infer F] - ? F - : T extends [infer F, ...infer R] - ? F extends string - ? `${F}${D}${Join, D>}` - : never - : string; - -// dot notation matches path in `SystemConfig` -// TODO: migrate to key value per section -export const SystemConfigKey = { - FFMPEG_CRF: 'ffmpeg.crf', - FFMPEG_THREADS: 'ffmpeg.threads', - FFMPEG_PRESET: 'ffmpeg.preset', - FFMPEG_TARGET_VIDEO_CODEC: 'ffmpeg.targetVideoCodec', - FFMPEG_ACCEPTED_VIDEO_CODECS: 'ffmpeg.acceptedVideoCodecs', - FFMPEG_TARGET_AUDIO_CODEC: 'ffmpeg.targetAudioCodec', - FFMPEG_ACCEPTED_AUDIO_CODECS: 'ffmpeg.acceptedAudioCodecs', - FFMPEG_TARGET_RESOLUTION: 'ffmpeg.targetResolution', - FFMPEG_MAX_BITRATE: 'ffmpeg.maxBitrate', - FFMPEG_BFRAMES: 'ffmpeg.bframes', - FFMPEG_REFS: 'ffmpeg.refs', - FFMPEG_GOP_SIZE: 'ffmpeg.gopSize', - FFMPEG_NPL: 'ffmpeg.npl', - FFMPEG_TEMPORAL_AQ: 'ffmpeg.temporalAQ', - FFMPEG_CQ_MODE: 'ffmpeg.cqMode', - FFMPEG_TWO_PASS: 'ffmpeg.twoPass', - FFMPEG_PREFERRED_HW_DEVICE: 'ffmpeg.preferredHwDevice', - FFMPEG_TRANSCODE: 'ffmpeg.transcode', - FFMPEG_ACCEL: 'ffmpeg.accel', - FFMPEG_TONEMAP: 'ffmpeg.tonemap', - - JOB_THUMBNAIL_GENERATION_CONCURRENCY: 'job.thumbnailGeneration.concurrency', - JOB_METADATA_EXTRACTION_CONCURRENCY: 'job.metadataExtraction.concurrency', - JOB_VIDEO_CONVERSION_CONCURRENCY: 'job.videoConversion.concurrency', - JOB_FACE_DETECTION_CONCURRENCY: 'job.faceDetection.concurrency', - JOB_CLIP_ENCODING_CONCURRENCY: 'job.smartSearch.concurrency', - JOB_BACKGROUND_TASK_CONCURRENCY: 'job.backgroundTask.concurrency', - JOB_SEARCH_CONCURRENCY: 'job.search.concurrency', - JOB_SIDECAR_CONCURRENCY: 'job.sidecar.concurrency', - JOB_LIBRARY_CONCURRENCY: 'job.library.concurrency', - JOB_MIGRATION_CONCURRENCY: 'job.migration.concurrency', - - LIBRARY_SCAN_ENABLED: 'library.scan.enabled', - LIBRARY_SCAN_CRON_EXPRESSION: 'library.scan.cronExpression', - - LIBRARY_WATCH_ENABLED: 'library.watch.enabled', - - LOGGING_ENABLED: 'logging.enabled', - LOGGING_LEVEL: 'logging.level', - - MACHINE_LEARNING_ENABLED: 'machineLearning.enabled', - MACHINE_LEARNING_URL: 'machineLearning.url', - - MACHINE_LEARNING_CLIP_ENABLED: 'machineLearning.clip.enabled', - MACHINE_LEARNING_CLIP_MODEL_NAME: 'machineLearning.clip.modelName', - - MACHINE_LEARNING_FACIAL_RECOGNITION_ENABLED: 'machineLearning.facialRecognition.enabled', - MACHINE_LEARNING_FACIAL_RECOGNITION_MODEL_NAME: 'machineLearning.facialRecognition.modelName', - MACHINE_LEARNING_FACIAL_RECOGNITION_MIN_SCORE: 'machineLearning.facialRecognition.minScore', - MACHINE_LEARNING_FACIAL_RECOGNITION_MAX_DISTANCE: 'machineLearning.facialRecognition.maxDistance', - MACHINE_LEARNING_FACIAL_RECOGNITION_MIN_FACES: 'machineLearning.facialRecognition.minFaces', - - MAP_ENABLED: 'map.enabled', - MAP_LIGHT_STYLE: 'map.lightStyle', - MAP_DARK_STYLE: 'map.darkStyle', - - NOTIFICATIONS_SMTP_ENABLED: 'notifications.smtp.enabled', - NOTIFICATIONS_SMTP_FROM: 'notifications.smtp.from', - NOTIFICATIONS_SMTP_REPLY_TO: 'notifications.smtp.replyTo', - NOTIFICATIONS_SMTP_TRANSPORT_IGNORE_CERT: 'notifications.smtp.transport.ignoreCert', - NOTIFICATIONS_SMTP_TRANSPORT_HOST: 'notifications.smtp.transport.host', - NOTIFICATIONS_SMTP_TRANSPORT_PORT: 'notifications.smtp.transport.port', - NOTIFICATIONS_SMTP_TRANSPORT_USERNAME: 'notifications.smtp.transport.username', - NOTIFICATIONS_SMTP_TRANSPORT_PASSWORD: 'notifications.smtp.transport.password', - - REVERSE_GEOCODING_ENABLED: 'reverseGeocoding.enabled', - - NEW_VERSION_CHECK_ENABLED: 'newVersionCheck.enabled', - - OAUTH_AUTO_LAUNCH: 'oauth.autoLaunch', - OAUTH_AUTO_REGISTER: 'oauth.autoRegister', - OAUTH_BUTTON_TEXT: 'oauth.buttonText', - OAUTH_CLIENT_ID: 'oauth.clientId', - OAUTH_CLIENT_SECRET: 'oauth.clientSecret', - OAUTH_DEFAULT_STORAGE_QUOTA: 'oauth.defaultStorageQuota', - OAUTH_ENABLED: 'oauth.enabled', - OAUTH_ISSUER_URL: 'oauth.issuerUrl', - OAUTH_MOBILE_OVERRIDE_ENABLED: 'oauth.mobileOverrideEnabled', - OAUTH_MOBILE_REDIRECT_URI: 'oauth.mobileRedirectUri', - OAUTH_SCOPE: 'oauth.scope', - OAUTH_SIGNING_ALGORITHM: 'oauth.signingAlgorithm', - OAUTH_STORAGE_LABEL_CLAIM: 'oauth.storageLabelClaim', - OAUTH_STORAGE_QUOTA_CLAIM: 'oauth.storageQuotaClaim', - - PASSWORD_LOGIN_ENABLED: 'passwordLogin.enabled', - - SERVER_EXTERNAL_DOMAIN: 'server.externalDomain', - SERVER_LOGIN_PAGE_MESSAGE: 'server.loginPageMessage', - - STORAGE_TEMPLATE_ENABLED: 'storageTemplate.enabled', - STORAGE_TEMPLATE_HASH_VERIFICATION_ENABLED: 'storageTemplate.hashVerificationEnabled', - STORAGE_TEMPLATE: 'storageTemplate.template', - - IMAGE_THUMBNAIL_FORMAT: 'image.thumbnailFormat', - IMAGE_THUMBNAIL_SIZE: 'image.thumbnailSize', - IMAGE_PREVIEW_FORMAT: 'image.previewFormat', - IMAGE_PREVIEW_SIZE: 'image.previewSize', - IMAGE_QUALITY: 'image.quality', - IMAGE_COLORSPACE: 'image.colorspace', - IMAGE_EXTRACT_EMBEDDED: 'image.extractEmbedded', - - TRASH_ENABLED: 'trash.enabled', - TRASH_DAYS: 'trash.days', - - THEME_CUSTOM_CSS: 'theme.customCss', - - USER_DELETE_DELAY: 'user.deleteDelay', -} as const satisfies Record, '.'>>; - -export type SystemConfigKeyPaths = (typeof SystemConfigKey)[keyof typeof SystemConfigKey]; - -@Entity('system_config') -export class SystemConfigEntity { - @PrimaryColumn({ type: 'varchar' }) - key!: SystemConfigKeyPaths; - - @Column({ type: 'varchar', nullable: true, transformer: { to: JSON.stringify, from: JSON.parse } }) - value!: T; -} diff --git a/server/src/entities/system-metadata.entity.ts b/server/src/entities/system-metadata.entity.ts index 24e9f83c74..b702d2606d 100644 --- a/server/src/entities/system-metadata.entity.ts +++ b/server/src/entities/system-metadata.entity.ts @@ -1,20 +1,23 @@ -import { Column, Entity, PrimaryColumn } from 'typeorm'; +import { SystemConfig } from 'src/config'; +import { Column, DeepPartial, Entity, PrimaryColumn } from 'typeorm'; @Entity('system_metadata') -export class SystemMetadataEntity { - @PrimaryColumn() - key!: string; +export class SystemMetadataEntity { + @PrimaryColumn({ type: 'varchar' }) + key!: T; @Column({ type: 'jsonb', default: '{}', transformer: { to: JSON.stringify, from: JSON.parse } }) - value!: { [key: string]: unknown }; + value!: SystemMetadata[T]; } export enum SystemMetadataKey { REVERSE_GEOCODING_STATE = 'reverse-geocoding-state', ADMIN_ONBOARDING = 'admin-onboarding', + SYSTEM_CONFIG = 'system-config', } -export interface SystemMetadata extends Record { +export interface SystemMetadata extends Record> { [SystemMetadataKey.REVERSE_GEOCODING_STATE]: { lastUpdate?: string; lastImportFileName?: string }; [SystemMetadataKey.ADMIN_ONBOARDING]: { isOnboarded: boolean }; + [SystemMetadataKey.SYSTEM_CONFIG]: DeepPartial; } diff --git a/server/src/interfaces/system-config.interface.ts b/server/src/interfaces/system-config.interface.ts deleted file mode 100644 index f591a6671d..0000000000 --- a/server/src/interfaces/system-config.interface.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { SystemConfigEntity } from 'src/entities/system-config.entity'; - -export const ISystemConfigRepository = 'ISystemConfigRepository'; - -export interface ISystemConfigRepository { - fetchStyle(url: string): Promise; - load(): Promise; - readFile(filename: string): Promise; - saveAll(items: SystemConfigEntity[]): Promise; - deleteKeys(keys: string[]): Promise; -} diff --git a/server/src/interfaces/system-metadata.interface.ts b/server/src/interfaces/system-metadata.interface.ts index cbbce44e26..9bb9fd5077 100644 --- a/server/src/interfaces/system-metadata.interface.ts +++ b/server/src/interfaces/system-metadata.interface.ts @@ -5,4 +5,6 @@ export const ISystemMetadataRepository = 'ISystemMetadataRepository'; export interface ISystemMetadataRepository { get(key: T): Promise; set(key: T, value: SystemMetadata[T]): Promise; + fetchStyle(url: string): Promise; + readFile(filename: string): Promise; } diff --git a/server/src/migrations/1715787369686-RemoveSystemConfigTable.ts b/server/src/migrations/1715787369686-RemoveSystemConfigTable.ts new file mode 100644 index 0000000000..c16eec7160 --- /dev/null +++ b/server/src/migrations/1715787369686-RemoveSystemConfigTable.ts @@ -0,0 +1,31 @@ +import _ from 'lodash'; +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class RemoveSystemConfigTable1715787369686 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + const overrides = await queryRunner.query('SELECT "key", "value" FROM "system_config"'); + if (overrides.length === 0) { + return; + } + + const config = {}; + for (const { key, value } of overrides) { + _.set(config, key, JSON.parse(value)); + } + + await queryRunner.query(`INSERT INTO "system_metadata" ("key", "value") VALUES ($1, $2)`, [ + 'system-config', + // yup, we're double-stringifying it + JSON.stringify(JSON.stringify(config)), + ]); + + await queryRunner.query(`DROP TABLE "system_config"`); + } + + public async down(queryRunner: QueryRunner): Promise { + // no data restore, you just get the table back + await queryRunner.query( + `CREATE TABLE "system_config" ("key" character varying NOT NULL, "value" character varying, CONSTRAINT "PK_aab69295b445016f56731f4d535" PRIMARY KEY ("key"))`, + ); + } +} diff --git a/server/src/queries/system.config.repository.sql b/server/src/queries/system.config.repository.sql deleted file mode 100644 index 276cab20fe..0000000000 --- a/server/src/queries/system.config.repository.sql +++ /dev/null @@ -1,13 +0,0 @@ --- NOTE: This file is auto generated by ./sql-generator - --- SystemConfigRepository.load -SELECT - "SystemConfigEntity"."key" AS "SystemConfigEntity_key", - "SystemConfigEntity"."value" AS "SystemConfigEntity_value" -FROM - "system_config" "SystemConfigEntity" - --- SystemConfigRepository.deleteKeys -DELETE FROM "system_config" -WHERE - "key" IN ($1, $2, $3, $4, $5, $6, $7, $8, $9) diff --git a/server/src/repositories/index.ts b/server/src/repositories/index.ts index f21424e9d3..9ac9081c91 100644 --- a/server/src/repositories/index.ts +++ b/server/src/repositories/index.ts @@ -27,7 +27,6 @@ import { IServerInfoRepository } from 'src/interfaces/server-info.interface'; import { ISessionRepository } from 'src/interfaces/session.interface'; import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; -import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { ITagRepository } from 'src/interfaces/tag.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; @@ -60,7 +59,6 @@ import { ServerInfoRepository } from 'src/repositories/server-info.repository'; import { SessionRepository } from 'src/repositories/session.repository'; import { SharedLinkRepository } from 'src/repositories/shared-link.repository'; import { StorageRepository } from 'src/repositories/storage.repository'; -import { SystemConfigRepository } from 'src/repositories/system-config.repository'; import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository'; import { TagRepository } from 'src/repositories/tag.repository'; import { UserRepository } from 'src/repositories/user.repository'; @@ -94,7 +92,6 @@ export const repositories = [ { provide: ISearchRepository, useClass: SearchRepository }, { provide: ISessionRepository, useClass: SessionRepository }, { provide: IStorageRepository, useClass: StorageRepository }, - { provide: ISystemConfigRepository, useClass: SystemConfigRepository }, { provide: ISystemMetadataRepository, useClass: SystemMetadataRepository }, { provide: ITagRepository, useClass: TagRepository }, { provide: IMediaRepository, useClass: MediaRepository }, diff --git a/server/src/repositories/system-config.repository.ts b/server/src/repositories/system-config.repository.ts deleted file mode 100644 index 3d2dbecbc8..0000000000 --- a/server/src/repositories/system-config.repository.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { readFile } from 'node:fs/promises'; -import { Chunked, DummyValue, GenerateSql } from 'src/decorators'; -import { SystemConfigEntity } from 'src/entities/system-config.entity'; -import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; -import { Instrumentation } from 'src/utils/instrumentation'; -import { In, Repository } from 'typeorm'; - -@Instrumentation() -@Injectable() -export class SystemConfigRepository implements ISystemConfigRepository { - constructor( - @InjectRepository(SystemConfigEntity) - private repository: Repository, - ) {} - - async fetchStyle(url: string) { - try { - const response = await fetch(url); - - if (!response.ok) { - throw new Error(`Failed to fetch data from ${url} with status ${response.status}: ${await response.text()}`); - } - - return response.json(); - } catch (error) { - throw new Error(`Failed to fetch data from ${url}: ${error}`); - } - } - - @GenerateSql() - load(): Promise { - return this.repository.find(); - } - - readFile(filename: string): Promise { - return readFile(filename, { encoding: 'utf8' }); - } - - saveAll(items: SystemConfigEntity[]): Promise { - return this.repository.save(items); - } - - @GenerateSql({ params: [DummyValue.STRING] }) - @Chunked() - async deleteKeys(keys: string[]): Promise { - await this.repository.delete({ key: In(keys) }); - } -} diff --git a/server/src/repositories/system-metadata.repository.ts b/server/src/repositories/system-metadata.repository.ts index 91b887a176..c8bf9489cb 100644 --- a/server/src/repositories/system-metadata.repository.ts +++ b/server/src/repositories/system-metadata.repository.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; +import { readFile } from 'node:fs/promises'; import { SystemMetadata, SystemMetadataEntity } from 'src/entities/system-metadata.entity'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { Instrumentation } from 'src/utils/instrumentation'; @@ -24,4 +25,22 @@ export class SystemMetadataRepository implements ISystemMetadataRepository { async set(key: T, value: SystemMetadata[T]): Promise { await this.repository.upsert({ key, value }, { conflictPaths: { key: true } }); } + + async fetchStyle(url: string) { + try { + const response = await fetch(url); + + if (!response.ok) { + throw new Error(`Failed to fetch data from ${url} with status ${response.status}: ${await response.text()}`); + } + + return response.json(); + } catch (error) { + throw new Error(`Failed to fetch data from ${url}: ${error}`); + } + } + + readFile(filename: string): Promise { + return readFile(filename, { encoding: 'utf8' }); + } } diff --git a/server/src/services/asset.service.spec.ts b/server/src/services/asset.service.spec.ts index 2673e2436d..ca13adf31c 100755 --- a/server/src/services/asset.service.spec.ts +++ b/server/src/services/asset.service.spec.ts @@ -10,7 +10,7 @@ import { IJobRepository, JobName } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IPartnerRepository } from 'src/interfaces/partner.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; -import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; +import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; import { AssetService } from 'src/services/asset.service'; import { assetStackStub, assetStub } from 'test/fixtures/asset.stub'; @@ -27,7 +27,7 @@ import { newJobRepositoryMock } from 'test/repositories/job.repository.mock'; import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; import { newPartnerRepositoryMock } from 'test/repositories/partner.repository.mock'; import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock'; -import { newSystemConfigRepositoryMock } from 'test/repositories/system-config.repository.mock'; +import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; import { newUserRepositoryMock } from 'test/repositories/user.repository.mock'; import { Mocked, vitest } from 'vitest'; @@ -159,7 +159,7 @@ describe(AssetService.name, () => { let storageMock: Mocked; let userMock: Mocked; let eventMock: Mocked; - let configMock: Mocked; + let systemMock: Mocked; let partnerMock: Mocked; let assetStackMock: Mocked; let albumMock: Mocked; @@ -182,7 +182,7 @@ describe(AssetService.name, () => { jobMock = newJobRepositoryMock(); storageMock = newStorageRepositoryMock(); userMock = newUserRepositoryMock(); - configMock = newSystemConfigRepositoryMock(); + systemMock = newSystemMetadataRepositoryMock(); partnerMock = newPartnerRepositoryMock(); assetStackMock = newAssetStackRepositoryMock(); albumMock = newAlbumRepositoryMock(); @@ -192,7 +192,7 @@ describe(AssetService.name, () => { accessMock, assetMock, jobMock, - configMock, + systemMock, storageMock, userMock, eventMock, diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index 715b2bb4aa..d266b1ed2f 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -45,7 +45,7 @@ import { import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IPartnerRepository } from 'src/interfaces/partner.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; -import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; +import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; import { mimeTypes } from 'src/utils/mime-types'; import { usePagination } from 'src/utils/pagination'; @@ -73,7 +73,7 @@ export class AssetService { @Inject(IAccessRepository) accessRepository: IAccessRepository, @Inject(IAssetRepository) private assetRepository: IAssetRepository, @Inject(IJobRepository) private jobRepository: IJobRepository, - @Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository, + @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, @Inject(IStorageRepository) private storageRepository: IStorageRepository, @Inject(IUserRepository) private userRepository: IUserRepository, @Inject(IEventRepository) private eventRepository: IEventRepository, @@ -84,7 +84,7 @@ export class AssetService { ) { this.logger.setContext(AssetService.name); this.access = AccessCore.create(accessRepository); - this.configCore = SystemConfigCore.create(configRepository, this.logger); + this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); } async getUploadAssetIdByChecksum(auth: AuthDto, checksum?: string): Promise { diff --git a/server/src/services/auth.service.spec.ts b/server/src/services/auth.service.spec.ts index f00e10b13c..11339315db 100644 --- a/server/src/services/auth.service.spec.ts +++ b/server/src/services/auth.service.spec.ts @@ -11,7 +11,7 @@ import { ILibraryRepository } from 'src/interfaces/library.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ISessionRepository } from 'src/interfaces/session.interface'; import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface'; -import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; +import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; import { AuthService } from 'src/services/auth.service'; import { keyStub } from 'test/fixtures/api-key.stub'; @@ -27,7 +27,7 @@ import { newLibraryRepositoryMock } from 'test/repositories/library.repository.m import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; import { newSessionRepositoryMock } from 'test/repositories/session.repository.mock'; import { newSharedLinkRepositoryMock } from 'test/repositories/shared-link.repository.mock'; -import { newSystemConfigRepositoryMock } from 'test/repositories/system-config.repository.mock'; +import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; import { newUserRepositoryMock } from 'test/repositories/user.repository.mock'; import { Mock, Mocked, vitest } from 'vitest'; @@ -64,7 +64,7 @@ describe('AuthService', () => { let userMock: Mocked; let libraryMock: Mocked; let loggerMock: Mocked; - let configMock: Mocked; + let systemMock: Mocked; let sessionMock: Mocked; let shareMock: Mocked; let keyMock: Mocked; @@ -97,7 +97,7 @@ describe('AuthService', () => { userMock = newUserRepositoryMock(); libraryMock = newLibraryRepositoryMock(); loggerMock = newLoggerRepositoryMock(); - configMock = newSystemConfigRepositoryMock(); + systemMock = newSystemMetadataRepositoryMock(); sessionMock = newSessionRepositoryMock(); shareMock = newSharedLinkRepositoryMock(); keyMock = newKeyRepositoryMock(); @@ -105,7 +105,7 @@ describe('AuthService', () => { sut = new AuthService( accessMock, cryptoMock, - configMock, + systemMock, libraryMock, loggerMock, userMock, @@ -121,7 +121,7 @@ describe('AuthService', () => { describe('login', () => { it('should throw an error if password login is disabled', async () => { - configMock.load.mockResolvedValue(systemConfigStub.disabled); + systemMock.get.mockResolvedValue(systemConfigStub.disabled); await expect(sut.login(fixtures.login, loginDetails)).rejects.toBeInstanceOf(UnauthorizedException); }); @@ -199,7 +199,7 @@ describe('AuthService', () => { describe('logout', () => { it('should return the end session endpoint', async () => { - configMock.load.mockResolvedValue(systemConfigStub.enabled); + systemMock.get.mockResolvedValue(systemConfigStub.enabled); const auth = { user: { id: '123' } } as AuthDto; await expect(sut.logout(auth, AuthType.OAUTH)).resolves.toEqual({ successful: true, @@ -377,7 +377,7 @@ describe('AuthService', () => { }); it('should not allow auto registering', async () => { - configMock.load.mockResolvedValue(systemConfigStub.noAutoRegister); + systemMock.get.mockResolvedValue(systemConfigStub.noAutoRegister); userMock.getByEmail.mockResolvedValue(null); await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).rejects.toBeInstanceOf( BadRequestException, @@ -386,7 +386,7 @@ describe('AuthService', () => { }); it('should link an existing user', async () => { - configMock.load.mockResolvedValue(systemConfigStub.noAutoRegister); + systemMock.get.mockResolvedValue(systemConfigStub.noAutoRegister); userMock.getByEmail.mockResolvedValue(userStub.user1); userMock.update.mockResolvedValue(userStub.user1); sessionMock.create.mockResolvedValue(sessionStub.valid); @@ -400,7 +400,7 @@ describe('AuthService', () => { }); it('should allow auto registering by default', async () => { - configMock.load.mockResolvedValue(systemConfigStub.enabled); + systemMock.get.mockResolvedValue(systemConfigStub.enabled); userMock.getByEmail.mockResolvedValue(null); userMock.getAdmin.mockResolvedValue(userStub.user1); userMock.create.mockResolvedValue(userStub.user1); @@ -415,7 +415,7 @@ describe('AuthService', () => { }); it('should use the mobile redirect override', async () => { - configMock.load.mockResolvedValue(systemConfigStub.override); + systemMock.get.mockResolvedValue(systemConfigStub.override); userMock.getByOAuthId.mockResolvedValue(userStub.user1); sessionMock.create.mockResolvedValue(sessionStub.valid); @@ -425,7 +425,7 @@ describe('AuthService', () => { }); it('should use the mobile redirect override for ios urls with multiple slashes', async () => { - configMock.load.mockResolvedValue(systemConfigStub.override); + systemMock.get.mockResolvedValue(systemConfigStub.override); userMock.getByOAuthId.mockResolvedValue(userStub.user1); sessionMock.create.mockResolvedValue(sessionStub.valid); @@ -435,7 +435,7 @@ describe('AuthService', () => { }); it('should use the default quota', async () => { - configMock.load.mockResolvedValue(systemConfigStub.withDefaultStorageQuota); + systemMock.get.mockResolvedValue(systemConfigStub.withDefaultStorageQuota); userMock.getByEmail.mockResolvedValue(null); userMock.getAdmin.mockResolvedValue(userStub.user1); userMock.create.mockResolvedValue(userStub.user1); @@ -448,7 +448,7 @@ describe('AuthService', () => { }); it('should ignore an invalid storage quota', async () => { - configMock.load.mockResolvedValue(systemConfigStub.withDefaultStorageQuota); + systemMock.get.mockResolvedValue(systemConfigStub.withDefaultStorageQuota); userMock.getByEmail.mockResolvedValue(null); userMock.getAdmin.mockResolvedValue(userStub.user1); userMock.create.mockResolvedValue(userStub.user1); @@ -462,7 +462,7 @@ describe('AuthService', () => { }); it('should ignore a negative quota', async () => { - configMock.load.mockResolvedValue(systemConfigStub.withDefaultStorageQuota); + systemMock.get.mockResolvedValue(systemConfigStub.withDefaultStorageQuota); userMock.getByEmail.mockResolvedValue(null); userMock.getAdmin.mockResolvedValue(userStub.user1); userMock.create.mockResolvedValue(userStub.user1); @@ -476,7 +476,7 @@ describe('AuthService', () => { }); it('should not set quota for 0 quota', async () => { - configMock.load.mockResolvedValue(systemConfigStub.withDefaultStorageQuota); + systemMock.get.mockResolvedValue(systemConfigStub.withDefaultStorageQuota); userMock.getByEmail.mockResolvedValue(null); userMock.getAdmin.mockResolvedValue(userStub.user1); userMock.create.mockResolvedValue(userStub.user1); @@ -496,7 +496,7 @@ describe('AuthService', () => { }); it('should use a valid storage quota', async () => { - configMock.load.mockResolvedValue(systemConfigStub.withDefaultStorageQuota); + systemMock.get.mockResolvedValue(systemConfigStub.withDefaultStorageQuota); userMock.getByEmail.mockResolvedValue(null); userMock.getAdmin.mockResolvedValue(userStub.user1); userMock.create.mockResolvedValue(userStub.user1); @@ -518,7 +518,7 @@ describe('AuthService', () => { describe('link', () => { it('should link an account', async () => { - configMock.load.mockResolvedValue(systemConfigStub.enabled); + systemMock.get.mockResolvedValue(systemConfigStub.enabled); userMock.update.mockResolvedValue(userStub.user1); await sut.link(authStub.user1, { url: 'http://immich/user-settings?code=abc123' }); @@ -527,7 +527,7 @@ describe('AuthService', () => { }); it('should not link an already linked oauth.sub', async () => { - configMock.load.mockResolvedValue(systemConfigStub.enabled); + systemMock.get.mockResolvedValue(systemConfigStub.enabled); userMock.getByOAuthId.mockResolvedValue({ id: 'other-user' } as UserEntity); await expect(sut.link(authStub.user1, { url: 'http://immich/user-settings?code=abc123' })).rejects.toBeInstanceOf( @@ -540,7 +540,7 @@ describe('AuthService', () => { describe('unlink', () => { it('should unlink an account', async () => { - configMock.load.mockResolvedValue(systemConfigStub.enabled); + systemMock.get.mockResolvedValue(systemConfigStub.enabled); userMock.update.mockResolvedValue(userStub.user1); await sut.unlink(authStub.user1); diff --git a/server/src/services/auth.service.ts b/server/src/services/auth.service.ts index 7e7bbb6675..4c0efc4e6b 100644 --- a/server/src/services/auth.service.ts +++ b/server/src/services/auth.service.ts @@ -37,7 +37,7 @@ import { ILibraryRepository } from 'src/interfaces/library.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ISessionRepository } from 'src/interfaces/session.interface'; import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface'; -import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; +import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; import { HumanReadableSize } from 'src/utils/bytes'; @@ -67,7 +67,7 @@ export class AuthService { constructor( @Inject(IAccessRepository) accessRepository: IAccessRepository, @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository, - @Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository, + @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, @Inject(ILibraryRepository) libraryRepository: ILibraryRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository, @Inject(IUserRepository) private userRepository: IUserRepository, @@ -77,7 +77,7 @@ export class AuthService { ) { this.logger.setContext(AuthService.name); this.access = AccessCore.create(accessRepository); - this.configCore = SystemConfigCore.create(configRepository, logger); + this.configCore = SystemConfigCore.create(systemMetadataRepository, logger); this.userCore = UserCore.create(cryptoRepository, libraryRepository, userRepository); custom.setHttpOptionsDefaults({ timeout: 30_000 }); diff --git a/server/src/services/job.service.spec.ts b/server/src/services/job.service.spec.ts index 22ccf6766f..abbd41f7bf 100644 --- a/server/src/services/job.service.spec.ts +++ b/server/src/services/job.service.spec.ts @@ -15,7 +15,7 @@ import { import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IMetricRepository } from 'src/interfaces/metric.interface'; import { IPersonRepository } from 'src/interfaces/person.interface'; -import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; +import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { JobService } from 'src/services/job.service'; import { assetStub } from 'test/fixtures/asset.stub'; import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock'; @@ -24,7 +24,7 @@ import { newJobRepositoryMock } from 'test/repositories/job.repository.mock'; import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; import { newMetricRepositoryMock } from 'test/repositories/metric.repository.mock'; import { newPersonRepositoryMock } from 'test/repositories/person.repository.mock'; -import { newSystemConfigRepositoryMock } from 'test/repositories/system-config.repository.mock'; +import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; import { Mocked, vitest } from 'vitest'; const makeMockHandlers = (status: JobStatus) => { @@ -38,22 +38,22 @@ const makeMockHandlers = (status: JobStatus) => { describe(JobService.name, () => { let sut: JobService; let assetMock: Mocked; - let configMock: Mocked; let eventMock: Mocked; let jobMock: Mocked; let personMock: Mocked; let metricMock: Mocked; + let systemMock: Mocked; let loggerMock: Mocked; beforeEach(() => { assetMock = newAssetRepositoryMock(); - configMock = newSystemConfigRepositoryMock(); + systemMock = newSystemMetadataRepositoryMock(); eventMock = newEventRepositoryMock(); jobMock = newJobRepositoryMock(); personMock = newPersonRepositoryMock(); metricMock = newMetricRepositoryMock(); loggerMock = newLoggerRepositoryMock(); - sut = new JobService(assetMock, eventMock, jobMock, configMock, personMock, metricMock, loggerMock); + sut = new JobService(assetMock, eventMock, jobMock, systemMock, personMock, metricMock, loggerMock); }); it('should work', () => { @@ -234,14 +234,14 @@ describe(JobService.name, () => { describe('init', () => { it('should register a handler for each queue', async () => { await sut.init(makeMockHandlers(JobStatus.SUCCESS)); - expect(configMock.load).toHaveBeenCalled(); + expect(systemMock.get).toHaveBeenCalled(); expect(jobMock.addHandler).toHaveBeenCalledTimes(Object.keys(QueueName).length); }); it('should subscribe to config changes', async () => { await sut.init(makeMockHandlers(JobStatus.FAILED)); - SystemConfigCore.create(newSystemConfigRepositoryMock(false), newLoggerRepositoryMock()).config$.next({ + SystemConfigCore.create(newSystemMetadataRepositoryMock(false), newLoggerRepositoryMock()).config$.next({ job: { [QueueName.BACKGROUND_TASK]: { concurrency: 10 }, [QueueName.SMART_SEARCH]: { concurrency: 10 }, diff --git a/server/src/services/job.service.ts b/server/src/services/job.service.ts index aa625da1de..9e1bb78db1 100644 --- a/server/src/services/job.service.ts +++ b/server/src/services/job.service.ts @@ -20,7 +20,7 @@ import { import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IMetricRepository } from 'src/interfaces/metric.interface'; import { IPersonRepository } from 'src/interfaces/person.interface'; -import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; +import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; @Injectable() export class JobService { @@ -30,13 +30,13 @@ export class JobService { @Inject(IAssetRepository) private assetRepository: IAssetRepository, @Inject(IEventRepository) private eventRepository: IEventRepository, @Inject(IJobRepository) private jobRepository: IJobRepository, - @Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository, + @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, @Inject(IPersonRepository) private personRepository: IPersonRepository, @Inject(IMetricRepository) private metricRepository: IMetricRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository, ) { this.logger.setContext(JobService.name); - this.configCore = SystemConfigCore.create(configRepository, logger); + this.configCore = SystemConfigCore.create(systemMetadataRepository, logger); } async handleCommand(queueName: QueueName, dto: JobCommandDto): Promise { diff --git a/server/src/services/library.service.spec.ts b/server/src/services/library.service.spec.ts index fa45341784..9f2cb073c2 100644 --- a/server/src/services/library.service.spec.ts +++ b/server/src/services/library.service.spec.ts @@ -5,7 +5,6 @@ import { SystemConfigCore } from 'src/cores/system-config.core'; import { mapLibrary } from 'src/dtos/library.dto'; import { AssetType } from 'src/entities/asset.entity'; import { LibraryType } from 'src/entities/library.entity'; -import { SystemConfigKey } from 'src/entities/system-config.entity'; import { UserEntity } from 'src/entities/user.entity'; import { IAssetRepository } from 'src/interfaces/asset.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; @@ -14,7 +13,7 @@ import { IJobRepository, ILibraryFileJob, ILibraryRefreshJob, JobName, JobStatus import { ILibraryRepository } from 'src/interfaces/library.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; -import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; +import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { LibraryService } from 'src/services/library.service'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; @@ -28,14 +27,14 @@ import { newJobRepositoryMock } from 'test/repositories/job.repository.mock'; import { newLibraryRepositoryMock } from 'test/repositories/library.repository.mock'; import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; import { makeMockWatcher, newStorageRepositoryMock } from 'test/repositories/storage.repository.mock'; -import { newSystemConfigRepositoryMock } from 'test/repositories/system-config.repository.mock'; +import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; import { Mocked, vitest } from 'vitest'; describe(LibraryService.name, () => { let sut: LibraryService; let assetMock: Mocked; - let configMock: Mocked; + let systemMock: Mocked; let cryptoMock: Mocked; let jobMock: Mocked; let libraryMock: Mocked; @@ -44,7 +43,7 @@ describe(LibraryService.name, () => { let loggerMock: Mocked; beforeEach(() => { - configMock = newSystemConfigRepositoryMock(); + systemMock = newSystemMetadataRepositoryMock(); libraryMock = newLibraryRepositoryMock(); assetMock = newAssetRepositoryMock(); jobMock = newJobRepositoryMock(); @@ -55,7 +54,7 @@ describe(LibraryService.name, () => { sut = new LibraryService( assetMock, - configMock, + systemMock, cryptoMock, jobMock, libraryMock, @@ -73,16 +72,13 @@ describe(LibraryService.name, () => { describe('init', () => { it('should init cron job and subscribe to config changes', async () => { - configMock.load.mockResolvedValue([ - { key: SystemConfigKey.LIBRARY_SCAN_ENABLED, value: true }, - { key: SystemConfigKey.LIBRARY_SCAN_CRON_EXPRESSION, value: '0 0 * * *' }, - ]); + systemMock.get.mockResolvedValue(systemConfigStub.libraryScan); await sut.init(); - expect(configMock.load).toHaveBeenCalled(); + expect(systemMock.get).toHaveBeenCalled(); expect(jobMock.addCronJob).toHaveBeenCalled(); - SystemConfigCore.create(newSystemConfigRepositoryMock(false), newLoggerRepositoryMock()).config$.next({ + SystemConfigCore.create(newSystemMetadataRepositoryMock(false), newLoggerRepositoryMock()).config$.next({ library: { scan: { enabled: true, @@ -101,7 +97,7 @@ describe(LibraryService.name, () => { libraryStub.externalLibraryWithImportPaths2, ]); - configMock.load.mockResolvedValue(systemConfigStub.libraryWatchEnabled); + systemMock.get.mockResolvedValue(systemConfigStub.libraryWatchEnabled); libraryMock.get.mockImplementation((id) => Promise.resolve( [libraryStub.externalLibraryWithImportPaths1, libraryStub.externalLibraryWithImportPaths2].find( @@ -121,7 +117,7 @@ describe(LibraryService.name, () => { }); it('should not initialize watcher when watching is disabled', async () => { - configMock.load.mockResolvedValue(systemConfigStub.libraryWatchDisabled); + systemMock.get.mockResolvedValue(systemConfigStub.libraryWatchDisabled); await sut.init(); @@ -129,7 +125,7 @@ describe(LibraryService.name, () => { }); it('should not initialize watcher when lock is taken', async () => { - configMock.load.mockResolvedValue(systemConfigStub.libraryWatchEnabled); + systemMock.get.mockResolvedValue(systemConfigStub.libraryWatchEnabled); databaseMock.tryLock.mockResolvedValue(false); await sut.init(); @@ -757,7 +753,7 @@ describe(LibraryService.name, () => { libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); libraryMock.getAll.mockResolvedValue([libraryStub.externalLibraryWithImportPaths1]); - configMock.load.mockResolvedValue(systemConfigStub.libraryWatchEnabled); + systemMock.get.mockResolvedValue(systemConfigStub.libraryWatchEnabled); const mockClose = vitest.fn(); storageMock.watch.mockImplementation(makeMockWatcher({ close: mockClose })); @@ -897,7 +893,7 @@ describe(LibraryService.name, () => { }); it('should create watched with import paths', async () => { - configMock.load.mockResolvedValue(systemConfigStub.libraryWatchEnabled); + systemMock.get.mockResolvedValue(systemConfigStub.libraryWatchEnabled); libraryMock.create.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); libraryMock.getAll.mockResolvedValue([]); @@ -1041,7 +1037,7 @@ describe(LibraryService.name, () => { describe('update', () => { beforeEach(async () => { - configMock.load.mockResolvedValue(systemConfigStub.libraryWatchEnabled); + systemMock.get.mockResolvedValue(systemConfigStub.libraryWatchEnabled); libraryMock.getAll.mockResolvedValue([]); await sut.init(); @@ -1058,7 +1054,7 @@ describe(LibraryService.name, () => { describe('watchAll', () => { describe('watching disabled', () => { beforeEach(async () => { - configMock.load.mockResolvedValue(systemConfigStub.libraryWatchDisabled); + systemMock.get.mockResolvedValue(systemConfigStub.libraryWatchDisabled); await sut.init(); }); @@ -1074,7 +1070,7 @@ describe(LibraryService.name, () => { describe('watching enabled', () => { beforeEach(async () => { - configMock.load.mockResolvedValue(systemConfigStub.libraryWatchEnabled); + systemMock.get.mockResolvedValue(systemConfigStub.libraryWatchEnabled); libraryMock.getAll.mockResolvedValue([]); await sut.init(); }); @@ -1229,7 +1225,7 @@ describe(LibraryService.name, () => { libraryStub.externalLibraryWithImportPaths2, ]); - configMock.load.mockResolvedValue(systemConfigStub.libraryWatchEnabled); + systemMock.get.mockResolvedValue(systemConfigStub.libraryWatchEnabled); libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); libraryMock.get.mockImplementation((id) => diff --git a/server/src/services/library.service.ts b/server/src/services/library.service.ts index 3c6e26a315..fb6991d016 100644 --- a/server/src/services/library.service.ts +++ b/server/src/services/library.service.ts @@ -38,7 +38,7 @@ import { import { ILibraryRepository } from 'src/interfaces/library.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; -import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; +import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { mimeTypes } from 'src/utils/mime-types'; import { handlePromiseError } from 'src/utils/misc'; import { usePagination } from 'src/utils/pagination'; @@ -55,7 +55,7 @@ export class LibraryService { constructor( @Inject(IAssetRepository) private assetRepository: IAssetRepository, - @Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository, + @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository, @Inject(IJobRepository) private jobRepository: IJobRepository, @Inject(ILibraryRepository) private repository: ILibraryRepository, @@ -64,7 +64,7 @@ export class LibraryService { @Inject(ILoggerRepository) private logger: ILoggerRepository, ) { this.logger.setContext(LibraryService.name); - this.configCore = SystemConfigCore.create(configRepository, this.logger); + this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); } async init() { diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts index 4a7a6836af..271987c8f7 100644 --- a/server/src/services/media.service.spec.ts +++ b/server/src/services/media.service.spec.ts @@ -10,7 +10,6 @@ import { } from 'src/config'; import { AssetType } from 'src/entities/asset.entity'; import { ExifEntity } from 'src/entities/exif.entity'; -import { SystemConfigKey } from 'src/entities/system-config.entity'; import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; @@ -19,7 +18,7 @@ import { IMediaRepository } 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'; -import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; +import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { MediaService } from 'src/services/media.service'; import { assetStub } from 'test/fixtures/asset.stub'; import { faceStub } from 'test/fixtures/face.stub'; @@ -33,24 +32,24 @@ import { newMediaRepositoryMock } from 'test/repositories/media.repository.mock' import { newMoveRepositoryMock } from 'test/repositories/move.repository.mock'; import { newPersonRepositoryMock } from 'test/repositories/person.repository.mock'; import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock'; -import { newSystemConfigRepositoryMock } from 'test/repositories/system-config.repository.mock'; +import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; import { Mocked } from 'vitest'; describe(MediaService.name, () => { let sut: MediaService; let assetMock: Mocked; - let configMock: Mocked; let jobMock: Mocked; let mediaMock: Mocked; let moveMock: Mocked; let personMock: Mocked; let storageMock: Mocked; + let systemMock: Mocked; let cryptoMock: Mocked; let loggerMock: Mocked; beforeEach(() => { assetMock = newAssetRepositoryMock(); - configMock = newSystemConfigRepositoryMock(); + systemMock = newSystemMetadataRepositoryMock(); jobMock = newJobRepositoryMock(); mediaMock = newMediaRepositoryMock(); moveMock = newMoveRepositoryMock(); @@ -65,7 +64,7 @@ describe(MediaService.name, () => { jobMock, mediaMock, storageMock, - configMock, + systemMock, moveMock, cryptoMock, loggerMock, @@ -235,7 +234,7 @@ describe(MediaService.name, () => { }); it.each(Object.values(ImageFormat))('should generate a %s preview for an image when specified', async (format) => { - configMock.load.mockResolvedValue([{ key: SystemConfigKey.IMAGE_PREVIEW_FORMAT, value: format }]); + systemMock.get.mockResolvedValue({ image: { previewFormat: format } }); assetMock.getByIds.mockResolvedValue([assetStub.image]); const previewPath = `upload/thumbs/user-id/as/se/asset-id-preview.${format}`; @@ -254,7 +253,7 @@ describe(MediaService.name, () => { it('should delete previous preview if different path', async () => { const previousPreviewPath = assetStub.image.previewPath; - configMock.load.mockResolvedValue([{ key: SystemConfigKey.IMAGE_THUMBNAIL_FORMAT, value: ImageFormat.WEBP }]); + systemMock.get.mockResolvedValue({ image: { thumbnailFormat: ImageFormat.WEBP } }); assetMock.getByIds.mockResolvedValue([assetStub.image]); await sut.handleGeneratePreview({ id: assetStub.image.id }); @@ -337,10 +336,9 @@ describe(MediaService.name, () => { it('should always generate video thumbnail in one pass', async () => { mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); - configMock.load.mockResolvedValue([ - { key: SystemConfigKey.FFMPEG_TWO_PASS, value: true }, - { key: SystemConfigKey.FFMPEG_MAX_BITRATE, value: '5000k' }, - ]); + systemMock.get.mockResolvedValue({ + ffmpeg: { twoPass: true, maxBitrate: '5000k' }, + }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleGeneratePreview({ id: assetStub.video.id }); @@ -385,7 +383,7 @@ describe(MediaService.name, () => { it.each(Object.values(ImageFormat))( 'should generate a %s thumbnail for an image when specified', async (format) => { - configMock.load.mockResolvedValue([{ key: SystemConfigKey.IMAGE_THUMBNAIL_FORMAT, value: format }]); + systemMock.get.mockResolvedValue({ image: { thumbnailFormat: format } }); assetMock.getByIds.mockResolvedValue([assetStub.image]); const thumbnailPath = `upload/thumbs/user-id/as/se/asset-id-thumbnail.${format}`; @@ -405,7 +403,7 @@ describe(MediaService.name, () => { it('should delete previous thumbnail if different path', async () => { const previousThumbnailPath = assetStub.image.thumbnailPath; - configMock.load.mockResolvedValue([{ key: SystemConfigKey.IMAGE_THUMBNAIL_FORMAT, value: ImageFormat.WEBP }]); + systemMock.get.mockResolvedValue({ image: { thumbnailFormat: ImageFormat.WEBP } }); assetMock.getByIds.mockResolvedValue([assetStub.image]); await sut.handleGenerateThumbnail({ id: assetStub.image.id }); @@ -438,7 +436,7 @@ describe(MediaService.name, () => { it('should extract embedded image if enabled and available', async () => { mediaMock.extract.mockResolvedValue(true); mediaMock.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 }); - configMock.load.mockResolvedValue([{ key: SystemConfigKey.IMAGE_EXTRACT_EMBEDDED, value: true }]); + systemMock.get.mockResolvedValue({ image: { extractEmbedded: true } }); assetMock.getByIds.mockResolvedValue([assetStub.imageDng]); await sut.handleGenerateThumbnail({ id: assetStub.image.id }); @@ -463,7 +461,7 @@ describe(MediaService.name, () => { it('should resize original image if embedded image is too small', async () => { mediaMock.extract.mockResolvedValue(true); mediaMock.getImageDimensions.mockResolvedValue({ width: 1000, height: 1000 }); - configMock.load.mockResolvedValue([{ key: SystemConfigKey.IMAGE_EXTRACT_EMBEDDED, value: true }]); + systemMock.get.mockResolvedValue({ image: { extractEmbedded: true } }); assetMock.getByIds.mockResolvedValue([assetStub.imageDng]); await sut.handleGenerateThumbnail({ id: assetStub.image.id }); @@ -486,7 +484,7 @@ describe(MediaService.name, () => { }); it('should resize original image if embedded image not found', async () => { - configMock.load.mockResolvedValue([{ key: SystemConfigKey.IMAGE_EXTRACT_EMBEDDED, value: true }]); + systemMock.get.mockResolvedValue({ image: { extractEmbedded: true } }); assetMock.getByIds.mockResolvedValue([assetStub.imageDng]); await sut.handleGenerateThumbnail({ id: assetStub.image.id }); @@ -505,7 +503,7 @@ describe(MediaService.name, () => { }); it('should resize original image if embedded image extraction is not enabled', async () => { - configMock.load.mockResolvedValue([{ key: SystemConfigKey.IMAGE_EXTRACT_EMBEDDED, value: false }]); + systemMock.get.mockResolvedValue({ image: { extractEmbedded: false } }); assetMock.getByIds.mockResolvedValue([assetStub.imageDng]); await sut.handleGenerateThumbnail({ id: assetStub.image.id }); @@ -626,7 +624,7 @@ describe(MediaService.name, () => { await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.probe).toHaveBeenCalledWith('/original/path.ext'); - expect(configMock.load).toHaveBeenCalled(); + expect(systemMock.get).toHaveBeenCalled(); expect(storageMock.mkdirSync).toHaveBeenCalled(); expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -655,7 +653,7 @@ describe(MediaService.name, () => { it('should transcode when set to all', async () => { mediaMock.probe.mockResolvedValue(probeStub.multipleVideoStreams); - configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: TranscodePolicy.ALL }]); + systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.ALL } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -671,7 +669,7 @@ describe(MediaService.name, () => { it('should transcode when optimal and too big', async () => { mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p); - configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: TranscodePolicy.OPTIMAL }]); + systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.OPTIMAL } }); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -686,10 +684,7 @@ describe(MediaService.name, () => { it('should transcode when policy Bitrate and bitrate higher than max bitrate', async () => { mediaMock.probe.mockResolvedValue(probeStub.videoStream40Mbps); - configMock.load.mockResolvedValue([ - { key: SystemConfigKey.FFMPEG_TRANSCODE, value: TranscodePolicy.BITRATE }, - { key: SystemConfigKey.FFMPEG_MAX_BITRATE, value: '30M' }, - ]); + systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.BITRATE, maxBitrate: '30M' } }); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -704,10 +699,7 @@ describe(MediaService.name, () => { it('should not scale resolution if no target resolution', async () => { mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p); - configMock.load.mockResolvedValue([ - { key: SystemConfigKey.FFMPEG_TRANSCODE, value: TranscodePolicy.ALL }, - { key: SystemConfigKey.FFMPEG_TARGET_RESOLUTION, value: 'original' }, - ]); + systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.ALL, targetResolution: 'original' } }); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -722,7 +714,7 @@ describe(MediaService.name, () => { it('should scale horizontally when video is horizontal', async () => { mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p); - configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: TranscodePolicy.OPTIMAL }]); + systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.OPTIMAL } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -738,7 +730,7 @@ describe(MediaService.name, () => { it('should scale vertically when video is vertical', async () => { mediaMock.probe.mockResolvedValue(probeStub.videoStreamVertical2160p); - configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: TranscodePolicy.OPTIMAL }]); + systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.OPTIMAL } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -754,10 +746,7 @@ describe(MediaService.name, () => { it('should always scale video if height is uneven', async () => { mediaMock.probe.mockResolvedValue(probeStub.videoStreamOddHeight); - configMock.load.mockResolvedValue([ - { key: SystemConfigKey.FFMPEG_TRANSCODE, value: TranscodePolicy.ALL }, - { key: SystemConfigKey.FFMPEG_TARGET_RESOLUTION, value: 'original' }, - ]); + systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.ALL, targetResolution: 'original' } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -773,10 +762,7 @@ describe(MediaService.name, () => { it('should always scale video if width is uneven', async () => { mediaMock.probe.mockResolvedValue(probeStub.videoStreamOddWidth); - configMock.load.mockResolvedValue([ - { key: SystemConfigKey.FFMPEG_TRANSCODE, value: TranscodePolicy.ALL }, - { key: SystemConfigKey.FFMPEG_TARGET_RESOLUTION, value: 'original' }, - ]); + systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.ALL, targetResolution: 'original' } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -792,10 +778,9 @@ describe(MediaService.name, () => { it('should copy video stream when video matches target', async () => { mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - configMock.load.mockResolvedValue([ - { key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.HEVC }, - { key: SystemConfigKey.FFMPEG_ACCEPTED_AUDIO_CODECS, value: [AudioCodec.AAC] }, - ]); + systemMock.get.mockResolvedValue({ + ffmpeg: { targetVideoCodec: VideoCodec.HEVC, acceptedAudioCodecs: [AudioCodec.AAC] }, + }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -811,11 +796,13 @@ describe(MediaService.name, () => { it('should not include hevc tag when target is hevc and video stream is copied from a different codec', async () => { mediaMock.probe.mockResolvedValue(probeStub.videoStreamH264); - configMock.load.mockResolvedValue([ - { key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.HEVC }, - { key: SystemConfigKey.FFMPEG_ACCEPTED_VIDEO_CODECS, value: [VideoCodec.H264, VideoCodec.HEVC] }, - { key: SystemConfigKey.FFMPEG_ACCEPTED_AUDIO_CODECS, value: [AudioCodec.AAC] }, - ]); + systemMock.get.mockResolvedValue({ + ffmpeg: { + targetVideoCodec: VideoCodec.HEVC, + acceptedVideoCodecs: [VideoCodec.H264, VideoCodec.HEVC], + acceptedAudioCodecs: [AudioCodec.AAC], + }, + }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -831,11 +818,13 @@ describe(MediaService.name, () => { it('should include hevc tag when target is hevc and copying hevc video stream', async () => { mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - configMock.load.mockResolvedValue([ - { key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.HEVC }, - { key: SystemConfigKey.FFMPEG_ACCEPTED_VIDEO_CODECS, value: [VideoCodec.H264, VideoCodec.HEVC] }, - { key: SystemConfigKey.FFMPEG_ACCEPTED_AUDIO_CODECS, value: [AudioCodec.AAC] }, - ]); + systemMock.get.mockResolvedValue({ + ffmpeg: { + targetVideoCodec: VideoCodec.HEVC, + acceptedVideoCodecs: [VideoCodec.H264, VideoCodec.HEVC], + acceptedAudioCodecs: [AudioCodec.AAC], + }, + }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -851,7 +840,7 @@ describe(MediaService.name, () => { it('should copy audio stream when audio matches target', async () => { mediaMock.probe.mockResolvedValue(probeStub.audioStreamAac); - configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: TranscodePolicy.OPTIMAL }]); + systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.OPTIMAL } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -867,7 +856,7 @@ describe(MediaService.name, () => { it('should throw an exception if transcode value is invalid', async () => { mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p); - configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: 'invalid' }]); + systemMock.get.mockResolvedValue({ ffmpeg: { transcode: 'invalid' as any } }); await expect(sut.handleVideoConversion({ id: assetStub.video.id })).rejects.toThrow(); expect(mediaMock.transcode).not.toHaveBeenCalled(); @@ -875,7 +864,7 @@ describe(MediaService.name, () => { it('should not transcode if transcoding is disabled', async () => { mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p); - configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: TranscodePolicy.DISABLED }]); + systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.DISABLED } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).not.toHaveBeenCalled(); @@ -883,7 +872,7 @@ describe(MediaService.name, () => { it('should not transcode if target codec is invalid', async () => { mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p); - configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: 'invalid' }]); + systemMock.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: 'invalid' as any } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).not.toHaveBeenCalled(); @@ -892,7 +881,7 @@ describe(MediaService.name, () => { it('should delete existing transcode if current policy does not require transcoding', async () => { const asset = assetStub.hasEncodedVideo; mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p); - configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: TranscodePolicy.DISABLED }]); + systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.DISABLED } }); assetMock.getByIds.mockResolvedValue([asset]); await sut.handleVideoConversion({ id: asset.id }); @@ -906,7 +895,7 @@ describe(MediaService.name, () => { it('should set max bitrate if above 0', async () => { mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_MAX_BITRATE, value: '4500k' }]); + systemMock.get.mockResolvedValue({ ffmpeg: { maxBitrate: '4500k' } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -922,10 +911,7 @@ describe(MediaService.name, () => { it('should transcode in two passes for h264/h265 when enabled and max bitrate is above 0', async () => { mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - configMock.load.mockResolvedValue([ - { key: SystemConfigKey.FFMPEG_MAX_BITRATE, value: '4500k' }, - { key: SystemConfigKey.FFMPEG_TWO_PASS, value: true }, - ]); + systemMock.get.mockResolvedValue({ ffmpeg: { twoPass: true, maxBitrate: '4500k' } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -941,7 +927,7 @@ describe(MediaService.name, () => { it('should fallback to one pass for h264/h265 if two-pass is enabled but no max bitrate is set', async () => { mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TWO_PASS, value: true }]); + systemMock.get.mockResolvedValue({ ffmpeg: { twoPass: true } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -957,11 +943,13 @@ describe(MediaService.name, () => { it('should transcode by bitrate in two passes for vp9 when two pass mode and max bitrate are enabled', async () => { mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - configMock.load.mockResolvedValue([ - { key: SystemConfigKey.FFMPEG_MAX_BITRATE, value: '4500k' }, - { key: SystemConfigKey.FFMPEG_TWO_PASS, value: true }, - { key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.VP9 }, - ]); + systemMock.get.mockResolvedValue({ + ffmpeg: { + maxBitrate: '4500k', + twoPass: true, + targetVideoCodec: VideoCodec.VP9, + }, + }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -977,11 +965,13 @@ describe(MediaService.name, () => { it('should transcode by crf in two passes for vp9 when two pass mode is enabled and max bitrate is disabled', async () => { mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - configMock.load.mockResolvedValue([ - { key: SystemConfigKey.FFMPEG_MAX_BITRATE, value: '0' }, - { key: SystemConfigKey.FFMPEG_TWO_PASS, value: true }, - { key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.VP9 }, - ]); + systemMock.get.mockResolvedValue({ + ffmpeg: { + maxBitrate: '0', + twoPass: true, + targetVideoCodec: VideoCodec.VP9, + }, + }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -997,10 +987,7 @@ describe(MediaService.name, () => { it('should configure preset for vp9', async () => { mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - configMock.load.mockResolvedValue([ - { key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.VP9 }, - { key: SystemConfigKey.FFMPEG_PRESET, value: 'slow' }, - ]); + systemMock.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.VP9, preset: 'slow' } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -1016,10 +1003,7 @@ describe(MediaService.name, () => { it('should not configure preset for vp9 if invalid', async () => { mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - configMock.load.mockResolvedValue([ - { key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.VP9 }, - { key: SystemConfigKey.FFMPEG_PRESET, value: 'invalid' }, - ]); + systemMock.get.mockResolvedValue({ ffmpeg: { preset: 'invalid', targetVideoCodec: VideoCodec.VP9 } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -1035,10 +1019,7 @@ describe(MediaService.name, () => { it('should configure threads if above 0', async () => { mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - configMock.load.mockResolvedValue([ - { key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.VP9 }, - { key: SystemConfigKey.FFMPEG_THREADS, value: 2 }, - ]); + systemMock.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.VP9, threads: 2 } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -1054,7 +1035,7 @@ describe(MediaService.name, () => { it('should disable thread pooling for h264 if thread limit is 1', async () => { mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_THREADS, value: 1 }]); + systemMock.get.mockResolvedValue({ ffmpeg: { threads: 1 } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -1070,7 +1051,7 @@ describe(MediaService.name, () => { it('should omit thread flags for h264 if thread limit is at or below 0', async () => { mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_THREADS, value: 0 }]); + systemMock.get.mockResolvedValue({ ffmpeg: { threads: 0 } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -1086,10 +1067,7 @@ describe(MediaService.name, () => { it('should disable thread pooling for hevc if thread limit is 1', async () => { mediaMock.probe.mockResolvedValue(probeStub.videoStreamVp9); - configMock.load.mockResolvedValue([ - { key: SystemConfigKey.FFMPEG_THREADS, value: 1 }, - { key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.HEVC }, - ]); + systemMock.get.mockResolvedValue({ ffmpeg: { threads: 1, targetVideoCodec: VideoCodec.HEVC } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -1105,10 +1083,7 @@ describe(MediaService.name, () => { it('should omit thread flags for hevc if thread limit is at or below 0', async () => { mediaMock.probe.mockResolvedValue(probeStub.videoStreamVp9); - configMock.load.mockResolvedValue([ - { key: SystemConfigKey.FFMPEG_THREADS, value: 0 }, - { key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.HEVC }, - ]); + systemMock.get.mockResolvedValue({ ffmpeg: { threads: 0, targetVideoCodec: VideoCodec.HEVC } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -1124,7 +1099,7 @@ describe(MediaService.name, () => { it('should use av1 if specified', async () => { mediaMock.probe.mockResolvedValue(probeStub.videoStreamVp9); - configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.AV1 }]); + systemMock.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.AV1 } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -1150,10 +1125,7 @@ describe(MediaService.name, () => { it('should map `veryslow` preset to 4 for av1', async () => { mediaMock.probe.mockResolvedValue(probeStub.videoStreamVp9); - configMock.load.mockResolvedValue([ - { key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.AV1 }, - { key: SystemConfigKey.FFMPEG_PRESET, value: 'veryslow' }, - ]); + systemMock.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.AV1, preset: 'veryslow' } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -1169,10 +1141,7 @@ describe(MediaService.name, () => { it('should set max bitrate for av1 if specified', async () => { mediaMock.probe.mockResolvedValue(probeStub.videoStreamVp9); - configMock.load.mockResolvedValue([ - { key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.AV1 }, - { key: SystemConfigKey.FFMPEG_MAX_BITRATE, value: '2M' }, - ]); + systemMock.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.AV1, maxBitrate: '2M' } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -1188,10 +1157,7 @@ describe(MediaService.name, () => { it('should set threads for av1 if specified', async () => { mediaMock.probe.mockResolvedValue(probeStub.videoStreamVp9); - configMock.load.mockResolvedValue([ - { key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.AV1 }, - { key: SystemConfigKey.FFMPEG_THREADS, value: 4 }, - ]); + systemMock.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.AV1, threads: 4 } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -1207,11 +1173,7 @@ describe(MediaService.name, () => { it('should set both bitrate and threads for av1 if specified', async () => { mediaMock.probe.mockResolvedValue(probeStub.videoStreamVp9); - configMock.load.mockResolvedValue([ - { key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.AV1 }, - { key: SystemConfigKey.FFMPEG_THREADS, value: 4 }, - { key: SystemConfigKey.FFMPEG_MAX_BITRATE, value: '2M' }, - ]); + systemMock.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.AV1, threads: 4, maxBitrate: '2M' } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -1227,11 +1189,13 @@ describe(MediaService.name, () => { it('should skip transcoding for audioless videos with optimal policy if video codec is correct', async () => { mediaMock.probe.mockResolvedValue(probeStub.noAudioStreams); - configMock.load.mockResolvedValue([ - { key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.HEVC }, - { key: SystemConfigKey.FFMPEG_TRANSCODE, value: TranscodePolicy.OPTIMAL }, - { key: SystemConfigKey.FFMPEG_TARGET_RESOLUTION, value: '1080p' }, - ]); + systemMock.get.mockResolvedValue({ + ffmpeg: { + targetVideoCodec: VideoCodec.HEVC, + transcode: TranscodePolicy.OPTIMAL, + targetResolution: '1080p', + }, + }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).not.toHaveBeenCalled(); @@ -1239,10 +1203,7 @@ describe(MediaService.name, () => { it('should fail if hwaccel is enabled for an unsupported codec', async () => { mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - configMock.load.mockResolvedValue([ - { key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.NVENC }, - { key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.VP9 }, - ]); + systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.NVENC, targetVideoCodec: VideoCodec.VP9 } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await expect(sut.handleVideoConversion({ id: assetStub.video.id })).resolves.toBe(JobStatus.FAILED); expect(mediaMock.transcode).not.toHaveBeenCalled(); @@ -1250,7 +1211,7 @@ describe(MediaService.name, () => { it('should fail if hwaccel option is invalid', async () => { mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_ACCEL, value: 'invalid' }]); + systemMock.get.mockResolvedValue({ ffmpeg: { accel: 'invalid' as any } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await expect(sut.handleVideoConversion({ id: assetStub.video.id })).resolves.toBe(JobStatus.FAILED); expect(mediaMock.transcode).not.toHaveBeenCalled(); @@ -1258,7 +1219,7 @@ describe(MediaService.name, () => { it('should set options for nvenc', async () => { mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.NVENC }]); + systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.NVENC } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -1290,11 +1251,13 @@ describe(MediaService.name, () => { it('should set two pass options for nvenc when enabled', async () => { mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - configMock.load.mockResolvedValue([ - { key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.NVENC }, - { key: SystemConfigKey.FFMPEG_MAX_BITRATE, value: '10000k' }, - { key: SystemConfigKey.FFMPEG_TWO_PASS, value: true }, - ]); + systemMock.get.mockResolvedValue({ + ffmpeg: { + accel: TranscodeHWAccel.NVENC, + maxBitrate: '10000k', + twoPass: true, + }, + }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -1310,10 +1273,7 @@ describe(MediaService.name, () => { it('should set vbr options for nvenc when max bitrate is enabled', async () => { mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - configMock.load.mockResolvedValue([ - { key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.NVENC }, - { key: SystemConfigKey.FFMPEG_MAX_BITRATE, value: '10000k' }, - ]); + systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.NVENC, maxBitrate: '10000k' } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -1329,10 +1289,7 @@ describe(MediaService.name, () => { it('should set cq options for nvenc when max bitrate is disabled', async () => { mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - configMock.load.mockResolvedValue([ - { key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.NVENC }, - { key: SystemConfigKey.FFMPEG_MAX_BITRATE, value: '10000k' }, - ]); + systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.NVENC, maxBitrate: '10000k' } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -1348,10 +1305,7 @@ describe(MediaService.name, () => { it('should omit preset for nvenc if invalid', async () => { mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - configMock.load.mockResolvedValue([ - { key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.NVENC }, - { key: SystemConfigKey.FFMPEG_PRESET, value: 'invalid' }, - ]); + systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.NVENC, preset: 'invalid' } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -1367,7 +1321,7 @@ describe(MediaService.name, () => { it('should ignore two pass for nvenc if max bitrate is disabled', async () => { mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.NVENC }]); + systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.NVENC } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -1384,10 +1338,7 @@ describe(MediaService.name, () => { it('should set options for qsv', async () => { storageMock.readdir.mockResolvedValue(['renderD128']); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - configMock.load.mockResolvedValue([ - { key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.QSV }, - { key: SystemConfigKey.FFMPEG_MAX_BITRATE, value: '10000k' }, - ]); + systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV, maxBitrate: '10000k' } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -1420,11 +1371,13 @@ describe(MediaService.name, () => { it('should set options for qsv with custom dri node', async () => { storageMock.readdir.mockResolvedValue(['renderD128']); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - configMock.load.mockResolvedValue([ - { key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.QSV }, - { key: SystemConfigKey.FFMPEG_MAX_BITRATE, value: '10000k' }, - { key: SystemConfigKey.FFMPEG_PREFERRED_HW_DEVICE, value: '/dev/dri/renderD128' }, - ]); + systemMock.get.mockResolvedValue({ + ffmpeg: { + accel: TranscodeHWAccel.QSV, + maxBitrate: '10000k', + preferredHwDevice: '/dev/dri/renderD128', + }, + }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -1444,10 +1397,7 @@ describe(MediaService.name, () => { it('should omit preset for qsv if invalid', async () => { storageMock.readdir.mockResolvedValue(['renderD128']); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - configMock.load.mockResolvedValue([ - { key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.QSV }, - { key: SystemConfigKey.FFMPEG_PRESET, value: 'invalid' }, - ]); + systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV, preset: 'invalid' } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -1464,10 +1414,7 @@ describe(MediaService.name, () => { it('should set low power mode for qsv if target video codec is vp9', async () => { storageMock.readdir.mockResolvedValue(['renderD128']); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - configMock.load.mockResolvedValue([ - { key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.QSV }, - { key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.VP9 }, - ]); + systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV, targetVideoCodec: VideoCodec.VP9 } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -1484,7 +1431,7 @@ describe(MediaService.name, () => { it('should fail for qsv if no hw devices', async () => { storageMock.readdir.mockResolvedValue([]); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.QSV }]); + systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await expect(sut.handleVideoConversion({ id: assetStub.video.id })).resolves.toBe(JobStatus.FAILED); expect(mediaMock.transcode).not.toHaveBeenCalled(); @@ -1493,7 +1440,7 @@ describe(MediaService.name, () => { it('should set options for vaapi', async () => { storageMock.readdir.mockResolvedValue(['renderD128']); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.VAAPI }]); + systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -1525,10 +1472,7 @@ describe(MediaService.name, () => { it('should set vbr options for vaapi when max bitrate is enabled', async () => { storageMock.readdir.mockResolvedValue(['renderD128']); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - configMock.load.mockResolvedValue([ - { key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.VAAPI }, - { key: SystemConfigKey.FFMPEG_MAX_BITRATE, value: '10000k' }, - ]); + systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI, maxBitrate: '10000k' } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -1554,7 +1498,7 @@ describe(MediaService.name, () => { it('should set cq options for vaapi when max bitrate is disabled', async () => { storageMock.readdir.mockResolvedValue(['renderD128']); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.VAAPI }]); + systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -1580,10 +1524,7 @@ describe(MediaService.name, () => { it('should omit preset for vaapi if invalid', async () => { storageMock.readdir.mockResolvedValue(['renderD128']); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - configMock.load.mockResolvedValue([ - { key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.VAAPI }, - { key: SystemConfigKey.FFMPEG_PRESET, value: 'invalid' }, - ]); + systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI, preset: 'invalid' } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -1603,7 +1544,7 @@ describe(MediaService.name, () => { it('should prefer gpu for vaapi if available', async () => { storageMock.readdir.mockResolvedValue(['renderD129', 'card1', 'card0', 'renderD128']); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.VAAPI }]); + systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -1623,7 +1564,7 @@ describe(MediaService.name, () => { it('should prefer higher index gpu node', async () => { storageMock.readdir.mockResolvedValue(['renderD129', 'renderD130', 'renderD128']); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.VAAPI }]); + systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -1643,10 +1584,9 @@ describe(MediaService.name, () => { it('should select specific gpu node if selected', async () => { storageMock.readdir.mockResolvedValue(['renderD129', 'card1', 'card0', 'renderD128']); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - configMock.load.mockResolvedValue([ - { key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.VAAPI }, - { key: SystemConfigKey.FFMPEG_PREFERRED_HW_DEVICE, value: '/dev/dri/renderD128' }, - ]); + systemMock.get.mockResolvedValue({ + ffmpeg: { accel: TranscodeHWAccel.VAAPI, preferredHwDevice: '/dev/dri/renderD128' }, + }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -1666,7 +1606,7 @@ describe(MediaService.name, () => { it('should fallback to sw transcoding if hw transcoding fails', async () => { storageMock.readdir.mockResolvedValue(['renderD128']); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.VAAPI }]); + systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); mediaMock.transcode.mockRejectedValueOnce(new Error('error')); await sut.handleVideoConversion({ id: assetStub.video.id }); @@ -1685,7 +1625,7 @@ describe(MediaService.name, () => { it('should fail for vaapi if no hw devices', async () => { storageMock.readdir.mockResolvedValue([]); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.VAAPI }]); + systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await expect(sut.handleVideoConversion({ id: assetStub.video.id })).resolves.toBe(JobStatus.FAILED); expect(mediaMock.transcode).not.toHaveBeenCalled(); @@ -1694,7 +1634,7 @@ describe(MediaService.name, () => { it('should set options for rkmpp', async () => { storageMock.readdir.mockResolvedValue(['renderD128']); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.RKMPP }]); + systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.RKMPP } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -1724,11 +1664,13 @@ describe(MediaService.name, () => { it('should set vbr options for rkmpp when max bitrate is enabled', async () => { storageMock.readdir.mockResolvedValue(['renderD128']); mediaMock.probe.mockResolvedValue(probeStub.videoStreamVp9); - configMock.load.mockResolvedValue([ - { key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.RKMPP }, - { key: SystemConfigKey.FFMPEG_MAX_BITRATE, value: '10000k' }, - { key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.HEVC }, - ]); + systemMock.get.mockResolvedValue({ + ffmpeg: { + accel: TranscodeHWAccel.RKMPP, + maxBitrate: '10000k', + targetVideoCodec: VideoCodec.HEVC, + }, + }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -1745,11 +1687,9 @@ describe(MediaService.name, () => { it('should set cqp options for rkmpp when max bitrate is disabled', async () => { storageMock.readdir.mockResolvedValue(['renderD128']); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - configMock.load.mockResolvedValue([ - { key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.RKMPP }, - { key: SystemConfigKey.FFMPEG_CRF, value: 30 }, - { key: SystemConfigKey.FFMPEG_MAX_BITRATE, value: '0' }, - ]); + systemMock.get.mockResolvedValue({ + ffmpeg: { accel: TranscodeHWAccel.RKMPP, crf: 30, maxBitrate: '0' }, + }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -1767,11 +1707,7 @@ describe(MediaService.name, () => { storageMock.readdir.mockResolvedValue(['renderD128']); storageMock.stat.mockResolvedValue({ ...new Stats(), isFile: () => true, isCharacterDevice: () => true }); mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); - configMock.load.mockResolvedValue([ - { key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.RKMPP }, - { key: SystemConfigKey.FFMPEG_CRF, value: 30 }, - { key: SystemConfigKey.FFMPEG_MAX_BITRATE, value: '0' }, - ]); + systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.RKMPP, crf: 30, maxBitrate: '0' } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -1792,7 +1728,7 @@ describe(MediaService.name, () => { it('should tonemap when policy is required and video is hdr', async () => { mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); - configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: TranscodePolicy.REQUIRED }]); + systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.REQUIRED } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -1812,7 +1748,7 @@ describe(MediaService.name, () => { it('should tonemap when policy is optimal and video is hdr', async () => { mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); - configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: TranscodePolicy.OPTIMAL }]); + systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.OPTIMAL } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -1832,7 +1768,7 @@ describe(MediaService.name, () => { it('should set npl to 250 for reinhard and mobius tone-mapping algorithms', async () => { mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); - configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TONEMAP, value: ToneMapping.MOBIUS }]); + systemMock.get.mockResolvedValue({ ffmpeg: { tonemap: ToneMapping.MOBIUS } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index e493b22f95..86fe8252ad 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -31,7 +31,7 @@ import { AudioStreamInfo, IMediaRepository, VideoCodecHWConfig, VideoStreamInfo import { IMoveRepository } from 'src/interfaces/move.interface'; import { IPersonRepository } from 'src/interfaces/person.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; -import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; +import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { AV1Config, H264Config, @@ -59,20 +59,20 @@ export class MediaService { @Inject(IJobRepository) private jobRepository: IJobRepository, @Inject(IMediaRepository) private mediaRepository: IMediaRepository, @Inject(IStorageRepository) private storageRepository: IStorageRepository, - @Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository, + @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, @Inject(IMoveRepository) moveRepository: IMoveRepository, @Inject(ICryptoRepository) cryptoRepository: ICryptoRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository, ) { this.logger.setContext(MediaService.name); - this.configCore = SystemConfigCore.create(configRepository, this.logger); + this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); this.storageCore = StorageCore.create( assetRepository, cryptoRepository, moveRepository, personRepository, storageRepository, - configRepository, + systemMetadataRepository, this.logger, ); } @@ -329,7 +329,6 @@ export class MediaService { } const { ffmpeg: config } = await this.configCore.getConfig(); - const target = this.getTranscodeTarget(config, mainVideoStream, mainAudioStream); if (target === TranscodeTarget.NONE) { if (asset.encodedVideoPath) { diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index fefd40becb..da90b83794 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -4,7 +4,6 @@ import { Stats } from 'node:fs'; import { constants } from 'node:fs/promises'; import { AssetType } from 'src/entities/asset.entity'; import { ExifEntity } from 'src/entities/exif.entity'; -import { SystemConfigKey } from 'src/entities/system-config.entity'; import { IAlbumRepository } from 'src/interfaces/album.interface'; import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; @@ -17,7 +16,7 @@ import { IMetadataRepository, ImmichTags } from 'src/interfaces/metadata.interfa import { IMoveRepository } from 'src/interfaces/move.interface'; import { IPersonRepository } from 'src/interfaces/person.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; -import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; +import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; import { MetadataService, Orientation } from 'src/services/metadata.service'; import { assetStub } from 'test/fixtures/asset.stub'; @@ -35,14 +34,14 @@ import { newMetadataRepositoryMock } from 'test/repositories/metadata.repository import { newMoveRepositoryMock } from 'test/repositories/move.repository.mock'; import { newPersonRepositoryMock } from 'test/repositories/person.repository.mock'; import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock'; -import { newSystemConfigRepositoryMock } from 'test/repositories/system-config.repository.mock'; +import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; import { newUserRepositoryMock } from 'test/repositories/user.repository.mock'; import { Mocked } from 'vitest'; describe(MetadataService.name, () => { let albumMock: Mocked; let assetMock: Mocked; - let configMock: Mocked; + let systemMock: Mocked; let cryptoRepository: Mocked; let jobMock: Mocked; let metadataMock: Mocked; @@ -59,7 +58,6 @@ describe(MetadataService.name, () => { beforeEach(() => { albumMock = newAlbumRepositoryMock(); assetMock = newAssetRepositoryMock(); - configMock = newSystemConfigRepositoryMock(); cryptoRepository = newCryptoRepositoryMock(); jobMock = newJobRepositoryMock(); metadataMock = newMetadataRepositoryMock(); @@ -67,6 +65,7 @@ describe(MetadataService.name, () => { personMock = newPersonRepositoryMock(); eventMock = newEventRepositoryMock(); storageMock = newStorageRepositoryMock(); + systemMock = newSystemMetadataRepositoryMock(); mediaMock = newMediaRepositoryMock(); databaseMock = newDatabaseRepositoryMock(); userMock = newUserRepositoryMock(); @@ -84,7 +83,7 @@ describe(MetadataService.name, () => { moveMock, personMock, storageMock, - configMock, + systemMock, userMock, loggerMock, ); @@ -108,7 +107,7 @@ describe(MetadataService.name, () => { }); it('should return if reverse geocoding is disabled', async () => { - configMock.load.mockResolvedValue([{ key: SystemConfigKey.REVERSE_GEOCODING_ENABLED, value: false }]); + systemMock.get.mockResolvedValue({ reverseGeocoding: { enabled: false } }); await sut.init(); @@ -297,7 +296,7 @@ describe(MetadataService.name, () => { it('should apply reverse geocoding', async () => { assetMock.getByIds.mockResolvedValue([assetStub.withLocation]); - configMock.load.mockResolvedValue([{ key: SystemConfigKey.REVERSE_GEOCODING_ENABLED, value: true }]); + systemMock.get.mockResolvedValue({ reverseGeocoding: { enabled: true } }); metadataMock.reverseGeocode.mockResolvedValue({ city: 'City', state: 'State', country: 'Country' }); metadataMock.readTags.mockResolvedValue({ GPSLatitude: assetStub.withLocation.exifInfo!.latitude!, diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index 2d9333d1e4..cca6d9eb19 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -31,7 +31,7 @@ import { IMetadataRepository, ImmichTags } from 'src/interfaces/metadata.interfa import { IMoveRepository } from 'src/interfaces/move.interface'; import { IPersonRepository } from 'src/interfaces/person.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; -import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; +import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; import { handlePromiseError } from 'src/utils/misc'; import { usePagination } from 'src/utils/pagination'; @@ -113,19 +113,19 @@ export class MetadataService { @Inject(IMoveRepository) moveRepository: IMoveRepository, @Inject(IPersonRepository) personRepository: IPersonRepository, @Inject(IStorageRepository) private storageRepository: IStorageRepository, - @Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository, + @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, @Inject(IUserRepository) private userRepository: IUserRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository, ) { this.logger.setContext(MetadataService.name); - this.configCore = SystemConfigCore.create(configRepository, this.logger); + this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); this.storageCore = StorageCore.create( assetRepository, cryptoRepository, moveRepository, personRepository, storageRepository, - configRepository, + systemMetadataRepository, this.logger, ); } diff --git a/server/src/services/notification.service.ts b/server/src/services/notification.service.ts index dc5d89056b..503fe4afdd 100644 --- a/server/src/services/notification.service.ts +++ b/server/src/services/notification.service.ts @@ -5,7 +5,7 @@ import { ServerAsyncEvent, ServerAsyncEventMap } from 'src/interfaces/event.inte import { IEmailJob, IJobRepository, INotifySignupJob, JobName, JobStatus } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { EmailTemplate, INotificationRepository } from 'src/interfaces/notification.interface'; -import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; +import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; @Injectable() @@ -13,14 +13,14 @@ export class NotificationService { private configCore: SystemConfigCore; constructor( - @Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository, + @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, @Inject(INotificationRepository) private notificationRepository: INotificationRepository, @Inject(IUserRepository) private userRepository: IUserRepository, @Inject(IJobRepository) private jobRepository: IJobRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository, ) { this.logger.setContext(NotificationService.name); - this.configCore = SystemConfigCore.create(configRepository, logger); + this.configCore = SystemConfigCore.create(systemMetadataRepository, logger); } init() { diff --git a/server/src/services/person.service.spec.ts b/server/src/services/person.service.spec.ts index 934a204246..1644c0c896 100644 --- a/server/src/services/person.service.spec.ts +++ b/server/src/services/person.service.spec.ts @@ -3,7 +3,6 @@ import { Colorspace } from 'src/config'; 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 { SystemConfigKey } from 'src/entities/system-config.entity'; import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; @@ -14,13 +13,14 @@ import { IMoveRepository } from 'src/interfaces/move.interface'; import { IPersonRepository } from 'src/interfaces/person.interface'; import { FaceSearchResult, ISearchRepository } from 'src/interfaces/search.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; -import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; +import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { PersonService } from 'src/services/person.service'; import { CacheControl, ImmichFileResponse } from 'src/utils/file'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; import { faceStub } from 'test/fixtures/face.stub'; import { personStub } from 'test/fixtures/person.stub'; +import { systemConfigStub } from 'test/fixtures/system-config.stub'; import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock'; import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock'; import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock'; @@ -32,7 +32,7 @@ import { newMoveRepositoryMock } from 'test/repositories/move.repository.mock'; import { newPersonRepositoryMock } from 'test/repositories/person.repository.mock'; import { newSearchRepositoryMock } from 'test/repositories/search.repository.mock'; import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock'; -import { newSystemConfigRepositoryMock } from 'test/repositories/system-config.repository.mock'; +import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; import { IsNull } from 'typeorm'; import { Mocked } from 'vitest'; @@ -64,7 +64,7 @@ const detectFaceMock = { describe(PersonService.name, () => { let accessMock: IAccessRepositoryMock; let assetMock: Mocked; - let configMock: Mocked; + let systemMock: Mocked; let jobMock: Mocked; let machineLearningMock: Mocked; let mediaMock: Mocked; @@ -79,7 +79,7 @@ describe(PersonService.name, () => { beforeEach(() => { accessMock = newAccessRepositoryMock(); assetMock = newAssetRepositoryMock(); - configMock = newSystemConfigRepositoryMock(); + systemMock = newSystemMetadataRepositoryMock(); jobMock = newJobRepositoryMock(); machineLearningMock = newMachineLearningRepositoryMock(); moveMock = newMoveRepositoryMock(); @@ -96,7 +96,7 @@ describe(PersonService.name, () => { moveMock, mediaMock, personMock, - configMock, + systemMock, storageMock, jobMock, searchMock, @@ -451,12 +451,12 @@ describe(PersonService.name, () => { describe('handleQueueDetectFaces', () => { it('should skip if machine learning is disabled', async () => { - configMock.load.mockResolvedValue([{ key: SystemConfigKey.MACHINE_LEARNING_ENABLED, value: false }]); + systemMock.get.mockResolvedValue(systemConfigStub.machineLearningDisabled); await expect(sut.handleQueueDetectFaces({})).resolves.toBe(JobStatus.SKIPPED); expect(jobMock.queue).not.toHaveBeenCalled(); expect(jobMock.queueAll).not.toHaveBeenCalled(); - expect(configMock.load).toHaveBeenCalled(); + expect(systemMock.get).toHaveBeenCalled(); }); it('should queue missing assets', async () => { @@ -528,11 +528,11 @@ describe(PersonService.name, () => { describe('handleQueueRecognizeFaces', () => { it('should skip if machine learning is disabled', async () => { jobMock.getJobCounts.mockResolvedValue({ active: 1, waiting: 0, paused: 0, completed: 0, failed: 0, delayed: 0 }); - configMock.load.mockResolvedValue([{ key: SystemConfigKey.MACHINE_LEARNING_ENABLED, value: false }]); + systemMock.get.mockResolvedValue(systemConfigStub.machineLearningDisabled); await expect(sut.handleQueueRecognizeFaces({})).resolves.toBe(JobStatus.SKIPPED); expect(jobMock.queueAll).not.toHaveBeenCalled(); - expect(configMock.load).toHaveBeenCalled(); + expect(systemMock.get).toHaveBeenCalled(); }); it('should skip if recognition jobs are already queued', async () => { @@ -609,11 +609,11 @@ describe(PersonService.name, () => { describe('handleDetectFaces', () => { it('should skip if machine learning is disabled', async () => { - configMock.load.mockResolvedValue([{ key: SystemConfigKey.MACHINE_LEARNING_ENABLED, value: false }]); + systemMock.get.mockResolvedValue(systemConfigStub.machineLearningDisabled); await expect(sut.handleDetectFaces({ id: 'foo' })).resolves.toBe(JobStatus.SKIPPED); expect(assetMock.getByIds).not.toHaveBeenCalled(); - expect(configMock.load).toHaveBeenCalled(); + expect(systemMock.get).toHaveBeenCalled(); }); it('should skip when no resize path', async () => { @@ -740,9 +740,7 @@ describe(PersonService.name, () => { { face: faceStub.face1, distance: 0.4 }, ] as FaceSearchResult[]; - configMock.load.mockResolvedValue([ - { key: SystemConfigKey.MACHINE_LEARNING_FACIAL_RECOGNITION_MIN_FACES, value: 1 }, - ]); + systemMock.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } }); searchMock.searchFaces.mockResolvedValue(faces); personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1); personMock.create.mockResolvedValue(faceStub.primaryFace1.person); @@ -767,9 +765,7 @@ describe(PersonService.name, () => { { face: faceStub.noPerson2, distance: 0.3 }, ] as FaceSearchResult[]; - configMock.load.mockResolvedValue([ - { key: SystemConfigKey.MACHINE_LEARNING_FACIAL_RECOGNITION_MIN_FACES, value: 1 }, - ]); + systemMock.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } }); searchMock.searchFaces.mockResolvedValue(faces); personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1); personMock.create.mockResolvedValue(personStub.withName); @@ -807,9 +803,7 @@ describe(PersonService.name, () => { { face: faceStub.noPerson2, distance: 0.4 }, ] as FaceSearchResult[]; - configMock.load.mockResolvedValue([ - { key: SystemConfigKey.MACHINE_LEARNING_FACIAL_RECOGNITION_MIN_FACES, value: 3 }, - ]); + systemMock.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 3 } } }); searchMock.searchFaces.mockResolvedValue(faces); personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1); personMock.create.mockResolvedValue(personStub.withName); @@ -831,9 +825,7 @@ describe(PersonService.name, () => { { face: faceStub.noPerson2, distance: 0.4 }, ] as FaceSearchResult[]; - configMock.load.mockResolvedValue([ - { key: SystemConfigKey.MACHINE_LEARNING_FACIAL_RECOGNITION_MIN_FACES, value: 3 }, - ]); + systemMock.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 3 } } }); searchMock.searchFaces.mockResolvedValueOnce(faces).mockResolvedValueOnce([]); personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1); personMock.create.mockResolvedValue(personStub.withName); @@ -849,11 +841,11 @@ describe(PersonService.name, () => { describe('handleGeneratePersonThumbnail', () => { it('should skip if machine learning is disabled', async () => { - configMock.load.mockResolvedValue([{ key: SystemConfigKey.MACHINE_LEARNING_ENABLED, value: false }]); + systemMock.get.mockResolvedValue(systemConfigStub.machineLearningDisabled); await expect(sut.handleGeneratePersonThumbnail({ id: 'person-1' })).resolves.toBe(JobStatus.SKIPPED); expect(assetMock.getByIds).not.toHaveBeenCalled(); - expect(configMock.load).toHaveBeenCalled(); + expect(systemMock.get).toHaveBeenCalled(); }); it('should skip a person not found', async () => { diff --git a/server/src/services/person.service.ts b/server/src/services/person.service.ts index 3e9f3a4bb6..de0c191667 100644 --- a/server/src/services/person.service.ts +++ b/server/src/services/person.service.ts @@ -45,7 +45,7 @@ import { IMoveRepository } from 'src/interfaces/move.interface'; import { IPersonRepository, UpdateFacesData } from 'src/interfaces/person.interface'; import { ISearchRepository } from 'src/interfaces/search.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; -import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; +import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { Orientation } from 'src/services/metadata.service'; import { CacheControl, ImmichFileResponse } from 'src/utils/file'; import { mimeTypes } from 'src/utils/mime-types'; @@ -66,7 +66,7 @@ export class PersonService { @Inject(IMoveRepository) moveRepository: IMoveRepository, @Inject(IMediaRepository) private mediaRepository: IMediaRepository, @Inject(IPersonRepository) private repository: IPersonRepository, - @Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository, + @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, @Inject(IStorageRepository) private storageRepository: IStorageRepository, @Inject(IJobRepository) private jobRepository: IJobRepository, @Inject(ISearchRepository) private smartInfoRepository: ISearchRepository, @@ -75,14 +75,14 @@ export class PersonService { ) { this.access = AccessCore.create(accessRepository); this.logger.setContext(PersonService.name); - this.configCore = SystemConfigCore.create(configRepository, this.logger); + this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); this.storageCore = StorageCore.create( assetRepository, cryptoRepository, moveRepository, repository, storageRepository, - configRepository, + systemMetadataRepository, this.logger, ); } diff --git a/server/src/services/search.service.spec.ts b/server/src/services/search.service.spec.ts index bf4cd7c679..321e495fdc 100644 --- a/server/src/services/search.service.spec.ts +++ b/server/src/services/search.service.spec.ts @@ -6,7 +6,7 @@ import { IMetadataRepository } from 'src/interfaces/metadata.interface'; import { IPartnerRepository } from 'src/interfaces/partner.interface'; import { IPersonRepository } from 'src/interfaces/person.interface'; import { ISearchRepository } from 'src/interfaces/search.interface'; -import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; +import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { SearchService } from 'src/services/search.service'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; @@ -18,7 +18,7 @@ import { newMetadataRepositoryMock } from 'test/repositories/metadata.repository import { newPartnerRepositoryMock } from 'test/repositories/partner.repository.mock'; import { newPersonRepositoryMock } from 'test/repositories/person.repository.mock'; import { newSearchRepositoryMock } from 'test/repositories/search.repository.mock'; -import { newSystemConfigRepositoryMock } from 'test/repositories/system-config.repository.mock'; +import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; import { Mocked, vitest } from 'vitest'; vitest.useFakeTimers(); @@ -26,7 +26,7 @@ vitest.useFakeTimers(); describe(SearchService.name, () => { let sut: SearchService; let assetMock: Mocked; - let configMock: Mocked; + let systemMock: Mocked; let machineMock: Mocked; let personMock: Mocked; let searchMock: Mocked; @@ -36,7 +36,7 @@ describe(SearchService.name, () => { beforeEach(() => { assetMock = newAssetRepositoryMock(); - configMock = newSystemConfigRepositoryMock(); + systemMock = newSystemMetadataRepositoryMock(); machineMock = newMachineLearningRepositoryMock(); personMock = newPersonRepositoryMock(); searchMock = newSearchRepositoryMock(); @@ -45,7 +45,7 @@ describe(SearchService.name, () => { loggerMock = newLoggerRepositoryMock(); sut = new SearchService( - configMock, + systemMock, machineMock, personMock, searchMock, diff --git a/server/src/services/search.service.ts b/server/src/services/search.service.ts index e528142e62..10a2ccda2a 100644 --- a/server/src/services/search.service.ts +++ b/server/src/services/search.service.ts @@ -23,7 +23,7 @@ import { IMetadataRepository } from 'src/interfaces/metadata.interface'; import { IPartnerRepository } from 'src/interfaces/partner.interface'; import { IPersonRepository } from 'src/interfaces/person.interface'; import { ISearchRepository, SearchExploreItem } from 'src/interfaces/search.interface'; -import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; +import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { isSmartSearchEnabled } from 'src/utils/misc'; @Injectable() @@ -31,7 +31,7 @@ export class SearchService { private configCore: SystemConfigCore; constructor( - @Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository, + @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, @Inject(IMachineLearningRepository) private machineLearning: IMachineLearningRepository, @Inject(IPersonRepository) private personRepository: IPersonRepository, @Inject(ISearchRepository) private searchRepository: ISearchRepository, @@ -41,7 +41,7 @@ export class SearchService { @Inject(ILoggerRepository) private logger: ILoggerRepository, ) { this.logger.setContext(SearchService.name); - this.configCore = SystemConfigCore.create(configRepository, logger); + this.configCore = SystemConfigCore.create(systemMetadataRepository, logger); } async searchPerson(auth: AuthDto, dto: SearchPeopleDto): Promise { diff --git a/server/src/services/server-info.service.spec.ts b/server/src/services/server-info.service.spec.ts index a007498b40..273582b1cf 100644 --- a/server/src/services/server-info.service.spec.ts +++ b/server/src/services/server-info.service.spec.ts @@ -3,14 +3,12 @@ import { IEventRepository } from 'src/interfaces/event.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IServerInfoRepository } from 'src/interfaces/server-info.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; -import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; import { ServerInfoService } from 'src/services/server-info.service'; import { newEventRepositoryMock } from 'test/repositories/event.repository.mock'; import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock'; -import { newSystemConfigRepositoryMock } from 'test/repositories/system-config.repository.mock'; import { newServerInfoRepositoryMock } from 'test/repositories/system-info.repository.mock'; import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; import { newUserRepositoryMock } from 'test/repositories/user.repository.mock'; @@ -19,31 +17,21 @@ import { Mocked } from 'vitest'; describe(ServerInfoService.name, () => { let sut: ServerInfoService; let eventMock: Mocked; - let configMock: Mocked; let serverInfoMock: Mocked; let storageMock: Mocked; let userMock: Mocked; - let systemMetadataMock: Mocked; + let systemMock: Mocked; let loggerMock: Mocked; beforeEach(() => { - configMock = newSystemConfigRepositoryMock(); eventMock = newEventRepositoryMock(); serverInfoMock = newServerInfoRepositoryMock(); storageMock = newStorageRepositoryMock(); userMock = newUserRepositoryMock(); - systemMetadataMock = newSystemMetadataRepositoryMock(); + systemMock = newSystemMetadataRepositoryMock(); loggerMock = newLoggerRepositoryMock(); - sut = new ServerInfoService( - eventMock, - configMock, - userMock, - serverInfoMock, - storageMock, - systemMetadataMock, - loggerMock, - ); + sut = new ServerInfoService(eventMock, userMock, serverInfoMock, storageMock, systemMock, loggerMock); }); it('should work', () => { @@ -188,7 +176,7 @@ describe(ServerInfoService.name, () => { trash: true, email: false, }); - expect(configMock.load).toHaveBeenCalled(); + expect(systemMock.get).toHaveBeenCalled(); }); }); @@ -203,7 +191,7 @@ describe(ServerInfoService.name, () => { isOnboarded: false, externalDomain: '', }); - expect(configMock.load).toHaveBeenCalled(); + expect(systemMock.get).toHaveBeenCalled(); }); }); diff --git a/server/src/services/server-info.service.ts b/server/src/services/server-info.service.ts index 198e71dea6..c8ca3069b3 100644 --- a/server/src/services/server-info.service.ts +++ b/server/src/services/server-info.service.ts @@ -18,7 +18,6 @@ import { ClientEvent, IEventRepository, ServerEvent, ServerEventMap } from 'src/ import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IServerInfoRepository } from 'src/interfaces/server-info.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; -import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { IUserRepository, UserStatsQueryResponse } from 'src/interfaces/user.interface'; import { asHumanReadable } from 'src/utils/bytes'; @@ -34,7 +33,6 @@ export class ServerInfoService { constructor( @Inject(IEventRepository) private eventRepository: IEventRepository, - @Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository, @Inject(IUserRepository) private userRepository: IUserRepository, @Inject(IServerInfoRepository) private repository: IServerInfoRepository, @Inject(IStorageRepository) private storageRepository: IStorageRepository, @@ -42,7 +40,7 @@ export class ServerInfoService { @Inject(ILoggerRepository) private logger: ILoggerRepository, ) { this.logger.setContext(ServerInfoService.name); - this.configCore = SystemConfigCore.create(configRepository, this.logger); + this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); } onConnect() {} diff --git a/server/src/services/smart-info.service.spec.ts b/server/src/services/smart-info.service.spec.ts index 8dedcb5c5f..7ac6dac414 100644 --- a/server/src/services/smart-info.service.spec.ts +++ b/server/src/services/smart-info.service.spec.ts @@ -1,27 +1,27 @@ -import { SystemConfigKey } from 'src/entities/system-config.entity'; import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; import { IDatabaseRepository } from 'src/interfaces/database.interface'; import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IMachineLearningRepository } from 'src/interfaces/machine-learning.interface'; import { ISearchRepository } from 'src/interfaces/search.interface'; -import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; +import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { SmartInfoService } from 'src/services/smart-info.service'; import { getCLIPModelInfo } from 'src/utils/misc'; import { assetStub } from 'test/fixtures/asset.stub'; +import { systemConfigStub } from 'test/fixtures/system-config.stub'; import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock'; import { newDatabaseRepositoryMock } from 'test/repositories/database.repository.mock'; import { newJobRepositoryMock } from 'test/repositories/job.repository.mock'; import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; import { newMachineLearningRepositoryMock } from 'test/repositories/machine-learning.repository.mock'; import { newSearchRepositoryMock } from 'test/repositories/search.repository.mock'; -import { newSystemConfigRepositoryMock } from 'test/repositories/system-config.repository.mock'; +import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; import { Mocked } from 'vitest'; describe(SmartInfoService.name, () => { let sut: SmartInfoService; let assetMock: Mocked; - let configMock: Mocked; + let systemMock: Mocked; let jobMock: Mocked; let searchMock: Mocked; let machineMock: Mocked; @@ -30,13 +30,13 @@ describe(SmartInfoService.name, () => { beforeEach(() => { assetMock = newAssetRepositoryMock(); - configMock = newSystemConfigRepositoryMock(); + systemMock = newSystemMetadataRepositoryMock(); searchMock = newSearchRepositoryMock(); jobMock = newJobRepositoryMock(); machineMock = newMachineLearningRepositoryMock(); databaseMock = newDatabaseRepositoryMock(); loggerMock = newLoggerRepositoryMock(); - sut = new SmartInfoService(assetMock, databaseMock, jobMock, machineMock, searchMock, configMock, loggerMock); + sut = new SmartInfoService(assetMock, databaseMock, jobMock, machineMock, searchMock, systemMock, loggerMock); assetMock.getByIds.mockResolvedValue([assetStub.image]); }); @@ -47,7 +47,7 @@ describe(SmartInfoService.name, () => { describe('handleQueueEncodeClip', () => { it('should do nothing if machine learning is disabled', async () => { - configMock.load.mockResolvedValue([{ key: SystemConfigKey.MACHINE_LEARNING_ENABLED, value: false }]); + systemMock.get.mockResolvedValue(systemConfigStub.machineLearningDisabled); await sut.handleQueueEncodeClip({}); @@ -84,7 +84,7 @@ describe(SmartInfoService.name, () => { describe('handleEncodeClip', () => { it('should do nothing if machine learning is disabled', async () => { - configMock.load.mockResolvedValue([{ key: SystemConfigKey.MACHINE_LEARNING_ENABLED, value: false }]); + systemMock.get.mockResolvedValue(systemConfigStub.machineLearningDisabled); expect(await sut.handleEncodeClip({ id: '123' })).toEqual(JobStatus.SKIPPED); diff --git a/server/src/services/smart-info.service.ts b/server/src/services/smart-info.service.ts index ef2c379770..f902aa7e57 100644 --- a/server/src/services/smart-info.service.ts +++ b/server/src/services/smart-info.service.ts @@ -14,7 +14,7 @@ import { import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IMachineLearningRepository } from 'src/interfaces/machine-learning.interface'; import { ISearchRepository } from 'src/interfaces/search.interface'; -import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; +import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { isSmartSearchEnabled } from 'src/utils/misc'; import { usePagination } from 'src/utils/pagination'; @@ -28,11 +28,11 @@ export class SmartInfoService { @Inject(IJobRepository) private jobRepository: IJobRepository, @Inject(IMachineLearningRepository) private machineLearning: IMachineLearningRepository, @Inject(ISearchRepository) private repository: ISearchRepository, - @Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository, + @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository, ) { this.logger.setContext(SmartInfoService.name); - this.configCore = SystemConfigCore.create(configRepository, this.logger); + this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); } async init() { diff --git a/server/src/services/storage-template.service.spec.ts b/server/src/services/storage-template.service.spec.ts index 26263cafbd..c1a47cdcf0 100644 --- a/server/src/services/storage-template.service.spec.ts +++ b/server/src/services/storage-template.service.spec.ts @@ -3,7 +3,6 @@ import { SystemConfig, defaults } from 'src/config'; import { SystemConfigCore } from 'src/cores/system-config.core'; import { AssetEntity } from 'src/entities/asset.entity'; import { AssetPathType } from 'src/entities/move.entity'; -import { SystemConfigKey } from 'src/entities/system-config.entity'; import { IAlbumRepository } from 'src/interfaces/album.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; @@ -13,7 +12,7 @@ import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IMoveRepository } from 'src/interfaces/move.interface'; import { IPersonRepository } from 'src/interfaces/person.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; -import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; +import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; import { StorageTemplateService } from 'src/services/storage-template.service'; import { assetStub } from 'test/fixtures/asset.stub'; @@ -26,7 +25,7 @@ import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.moc import { newMoveRepositoryMock } from 'test/repositories/move.repository.mock'; import { newPersonRepositoryMock } from 'test/repositories/person.repository.mock'; import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock'; -import { newSystemConfigRepositoryMock } from 'test/repositories/system-config.repository.mock'; +import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; import { newUserRepositoryMock } from 'test/repositories/user.repository.mock'; import { Mocked } from 'vitest'; @@ -34,13 +33,13 @@ describe(StorageTemplateService.name, () => { let sut: StorageTemplateService; let albumMock: Mocked; let assetMock: Mocked; - let configMock: Mocked; + let cryptoMock: Mocked; + let databaseMock: Mocked; let moveMock: Mocked; let personMock: Mocked; let storageMock: Mocked; + let systemMock: Mocked; let userMock: Mocked; - let cryptoMock: Mocked; - let databaseMock: Mocked; let loggerMock: Mocked; it('should work', () => { @@ -48,23 +47,23 @@ describe(StorageTemplateService.name, () => { }); beforeEach(() => { - configMock = newSystemConfigRepositoryMock(); assetMock = newAssetRepositoryMock(); albumMock = newAlbumRepositoryMock(); + cryptoMock = newCryptoRepositoryMock(); + databaseMock = newDatabaseRepositoryMock(); moveMock = newMoveRepositoryMock(); personMock = newPersonRepositoryMock(); storageMock = newStorageRepositoryMock(); + systemMock = newSystemMetadataRepositoryMock(); userMock = newUserRepositoryMock(); - cryptoMock = newCryptoRepositoryMock(); - databaseMock = newDatabaseRepositoryMock(); loggerMock = newLoggerRepositoryMock(); - configMock.load.mockResolvedValue([{ key: SystemConfigKey.STORAGE_TEMPLATE_ENABLED, value: true }]); + systemMock.get.mockResolvedValue({ storageTemplate: { enabled: true } }); sut = new StorageTemplateService( albumMock, assetMock, - configMock, + systemMock, moveMock, personMock, storageMock, @@ -74,7 +73,7 @@ describe(StorageTemplateService.name, () => { loggerMock, ); - SystemConfigCore.create(configMock, loggerMock).config$.next(defaults); + SystemConfigCore.create(systemMock, loggerMock).config$.next(defaults); }); describe('onValidateConfig', () => { @@ -108,7 +107,7 @@ describe(StorageTemplateService.name, () => { describe('handleMigrationSingle', () => { it('should skip when storage template is disabled', async () => { - configMock.load.mockResolvedValue([{ key: SystemConfigKey.STORAGE_TEMPLATE_ENABLED, value: false }]); + systemMock.get.mockResolvedValue({ storageTemplate: { enabled: false } }); await expect(sut.handleMigrationSingle({ id: assetStub.image.id })).resolves.toBe(JobStatus.SKIPPED); expect(assetMock.getByIds).not.toHaveBeenCalled(); expect(storageMock.checkFileExists).not.toHaveBeenCalled(); diff --git a/server/src/services/storage-template.service.ts b/server/src/services/storage-template.service.ts index f7d25054af..945b6f4500 100644 --- a/server/src/services/storage-template.service.ts +++ b/server/src/services/storage-template.service.ts @@ -28,7 +28,7 @@ import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IMoveRepository } from 'src/interfaces/move.interface'; import { IPersonRepository } from 'src/interfaces/person.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; -import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; +import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; import { getLivePhotoMotionFilename } from 'src/utils/file'; import { usePagination } from 'src/utils/pagination'; @@ -65,7 +65,7 @@ export class StorageTemplateService { constructor( @Inject(IAlbumRepository) private albumRepository: IAlbumRepository, @Inject(IAssetRepository) private assetRepository: IAssetRepository, - @Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository, + @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, @Inject(IMoveRepository) moveRepository: IMoveRepository, @Inject(IPersonRepository) personRepository: IPersonRepository, @Inject(IStorageRepository) private storageRepository: IStorageRepository, @@ -75,7 +75,7 @@ export class StorageTemplateService { @Inject(ILoggerRepository) private logger: ILoggerRepository, ) { this.logger.setContext(StorageTemplateService.name); - this.configCore = SystemConfigCore.create(configRepository, this.logger); + this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); this.configCore.config$.subscribe((config) => this.onConfig(config)); this.storageCore = StorageCore.create( assetRepository, @@ -83,7 +83,7 @@ export class StorageTemplateService { moveRepository, personRepository, storageRepository, - configRepository, + systemMetadataRepository, this.logger, ); } diff --git a/server/src/services/system-config.service.spec.ts b/server/src/services/system-config.service.spec.ts index d345d55df6..e349b2fc11 100644 --- a/server/src/services/system-config.service.spec.ts +++ b/server/src/services/system-config.service.spec.ts @@ -12,24 +12,25 @@ import { VideoCodec, defaults, } from 'src/config'; -import { SystemConfigEntity, SystemConfigKey } from 'src/entities/system-config.entity'; +import { SystemMetadataKey } from 'src/entities/system-metadata.entity'; import { IEventRepository, ServerEvent } from 'src/interfaces/event.interface'; import { QueueName } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ISearchRepository } from 'src/interfaces/search.interface'; -import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; +import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { SystemConfigService } from 'src/services/system-config.service'; import { newEventRepositoryMock } from 'test/repositories/event.repository.mock'; import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; -import { newSystemConfigRepositoryMock } from 'test/repositories/system-config.repository.mock'; +import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; +import { DeepPartial } from 'typeorm'; import { Mocked } from 'vitest'; -const updates: SystemConfigEntity[] = [ - { key: SystemConfigKey.FFMPEG_CRF, value: 30 }, - { key: SystemConfigKey.OAUTH_AUTO_LAUNCH, value: true }, - { key: SystemConfigKey.TRASH_DAYS, value: 10 }, - { key: SystemConfigKey.USER_DELETE_DELAY, value: 15 }, -]; +const partialConfig = { + ffmpeg: { crf: 30 }, + oauth: { autoLaunch: true }, + trash: { days: 10 }, + user: { deleteDelay: 15 }, +} satisfies DeepPartial; const updatedConfig = Object.freeze({ job: { @@ -171,17 +172,17 @@ const updatedConfig = Object.freeze({ describe(SystemConfigService.name, () => { let sut: SystemConfigService; - let configMock: Mocked; + let systemMock: Mocked; let eventMock: Mocked; let loggerMock: Mocked; let smartInfoMock: Mocked; beforeEach(() => { delete process.env.IMMICH_CONFIG_FILE; - configMock = newSystemConfigRepositoryMock(); + systemMock = newSystemMetadataRepositoryMock(); eventMock = newEventRepositoryMock(); loggerMock = newLoggerRepositoryMock(); - sut = new SystemConfigService(configMock, eventMock, loggerMock, smartInfoMock); + sut = new SystemConfigService(systemMock, eventMock, loggerMock, smartInfoMock); }); it('should work', () => { @@ -190,44 +191,39 @@ describe(SystemConfigService.name, () => { describe('getDefaults', () => { it('should return the default config', () => { - configMock.load.mockResolvedValue(updates); + systemMock.get.mockResolvedValue(partialConfig); expect(sut.getDefaults()).toEqual(defaults); - expect(configMock.load).not.toHaveBeenCalled(); + expect(systemMock.get).not.toHaveBeenCalled(); }); }); describe('getConfig', () => { it('should return the default config', async () => { - configMock.load.mockResolvedValue([]); + systemMock.get.mockResolvedValue({}); await expect(sut.getConfig()).resolves.toEqual(defaults); }); it('should merge the overrides', async () => { - configMock.load.mockResolvedValue([ - { key: SystemConfigKey.FFMPEG_CRF, value: 30 }, - { key: SystemConfigKey.OAUTH_AUTO_LAUNCH, value: true }, - { key: SystemConfigKey.TRASH_DAYS, value: 10 }, - { key: SystemConfigKey.USER_DELETE_DELAY, value: 15 }, - ]); + systemMock.get.mockResolvedValue({ + ffmpeg: { crf: 30 }, + oauth: { autoLaunch: true }, + trash: { days: 10 }, + user: { deleteDelay: 15 }, + }); await expect(sut.getConfig()).resolves.toEqual(updatedConfig); }); it('should load the config from a json file', async () => { process.env.IMMICH_CONFIG_FILE = 'immich-config.json'; - const partialConfig = { - ffmpeg: { crf: 30 }, - oauth: { autoLaunch: true }, - trash: { days: 10 }, - user: { deleteDelay: 15 }, - }; - configMock.readFile.mockResolvedValue(JSON.stringify(partialConfig)); + + systemMock.readFile.mockResolvedValue(JSON.stringify(partialConfig)); await expect(sut.getConfig()).resolves.toEqual(updatedConfig); - expect(configMock.readFile).toHaveBeenCalledWith('immich-config.json'); + expect(systemMock.readFile).toHaveBeenCalledWith('immich-config.json'); }); it('should load the config from a yaml file', async () => { @@ -242,26 +238,26 @@ describe(SystemConfigService.name, () => { user: deleteDelay: 15 `; - configMock.readFile.mockResolvedValue(partialConfig); + systemMock.readFile.mockResolvedValue(partialConfig); await expect(sut.getConfig()).resolves.toEqual(updatedConfig); - expect(configMock.readFile).toHaveBeenCalledWith('immich-config.yaml'); + expect(systemMock.readFile).toHaveBeenCalledWith('immich-config.yaml'); }); it('should accept an empty configuration file', async () => { process.env.IMMICH_CONFIG_FILE = 'immich-config.json'; - configMock.readFile.mockResolvedValue(JSON.stringify({})); + systemMock.readFile.mockResolvedValue(JSON.stringify({})); await expect(sut.getConfig()).resolves.toEqual(defaults); - expect(configMock.readFile).toHaveBeenCalledWith('immich-config.json'); + expect(systemMock.readFile).toHaveBeenCalledWith('immich-config.json'); }); it('should allow underscores in the machine learning url', async () => { process.env.IMMICH_CONFIG_FILE = 'immich-config.json'; const partialConfig = { machineLearning: { url: 'immich_machine_learning' } }; - configMock.readFile.mockResolvedValue(JSON.stringify(partialConfig)); + systemMock.readFile.mockResolvedValue(JSON.stringify(partialConfig)); const config = await sut.getConfig(); expect(config.machineLearning.url).toEqual('immich_machine_learning'); @@ -272,7 +268,7 @@ describe(SystemConfigService.name, () => { const partialConfig = ` unknownOption: true `; - configMock.readFile.mockResolvedValue(partialConfig); + systemMock.readFile.mockResolvedValue(partialConfig); await sut.getConfig(); expect(loggerMock.warn).toHaveBeenCalled(); @@ -290,7 +286,7 @@ describe(SystemConfigService.name, () => { for (const test of tests) { it(`should ${test.should}`, async () => { process.env.IMMICH_CONFIG_FILE = 'immich-config.json'; - configMock.readFile.mockResolvedValue(JSON.stringify(test.config)); + systemMock.readFile.mockResolvedValue(JSON.stringify(test.config)); if (test.warn) { await sut.getConfig(); @@ -338,20 +334,20 @@ describe(SystemConfigService.name, () => { describe('updateConfig', () => { it('should update the config and emit client and server events', async () => { - configMock.load.mockResolvedValue(updates); + systemMock.get.mockResolvedValue(partialConfig); await expect(sut.updateConfig(updatedConfig)).resolves.toEqual(updatedConfig); expect(eventMock.clientBroadcast).toHaveBeenCalled(); expect(eventMock.serverSend).toHaveBeenCalledWith(ServerEvent.CONFIG_UPDATE, null); - expect(configMock.saveAll).toHaveBeenCalledWith(updates); + expect(systemMock.set).toHaveBeenCalledWith(SystemMetadataKey.SYSTEM_CONFIG, partialConfig); }); it('should throw an error if a config file is in use', async () => { process.env.IMMICH_CONFIG_FILE = 'immich-config.json'; - configMock.readFile.mockResolvedValue(JSON.stringify({})); + systemMock.readFile.mockResolvedValue(JSON.stringify({})); await expect(sut.updateConfig(defaults)).rejects.toBeInstanceOf(BadRequestException); - expect(configMock.saveAll).not.toHaveBeenCalled(); + expect(systemMock.set).not.toHaveBeenCalled(); }); }); diff --git a/server/src/services/system-config.service.ts b/server/src/services/system-config.service.ts index 2e4ae8cb5f..3474771875 100644 --- a/server/src/services/system-config.service.ts +++ b/server/src/services/system-config.service.ts @@ -24,14 +24,14 @@ import { } from 'src/interfaces/event.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ISearchRepository } from 'src/interfaces/search.interface'; -import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; +import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; @Injectable() export class SystemConfigService { private core: SystemConfigCore; constructor( - @Inject(ISystemConfigRepository) private repository: ISystemConfigRepository, + @Inject(ISystemMetadataRepository) private repository: ISystemMetadataRepository, @Inject(IEventRepository) private eventRepository: IEventRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository, @Inject(ISearchRepository) private smartInfoRepository: ISearchRepository, diff --git a/server/src/services/user.service.spec.ts b/server/src/services/user.service.spec.ts index 1bf4fc1012..da9c375131 100644 --- a/server/src/services/user.service.spec.ts +++ b/server/src/services/user.service.spec.ts @@ -12,7 +12,7 @@ import { IJobRepository, JobName } from 'src/interfaces/job.interface'; import { ILibraryRepository } from 'src/interfaces/library.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; -import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; +import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; import { UserService } from 'src/services/user.service'; import { CacheControl, ImmichFileResponse } from 'src/utils/file'; @@ -25,7 +25,7 @@ import { newJobRepositoryMock } from 'test/repositories/job.repository.mock'; import { newLibraryRepositoryMock } from 'test/repositories/library.repository.mock'; import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock'; -import { newSystemConfigRepositoryMock } from 'test/repositories/system-config.repository.mock'; +import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; import { newUserRepositoryMock } from 'test/repositories/user.repository.mock'; import { Mocked, vitest } from 'vitest'; @@ -44,12 +44,12 @@ describe(UserService.name, () => { let jobMock: Mocked; let libraryMock: Mocked; let storageMock: Mocked; - let configMock: Mocked; + let systemMock: Mocked; let loggerMock: Mocked; beforeEach(() => { albumMock = newAlbumRepositoryMock(); - configMock = newSystemConfigRepositoryMock(); + systemMock = newSystemMetadataRepositoryMock(); cryptoRepositoryMock = newCryptoRepositoryMock(); jobMock = newJobRepositoryMock(); libraryMock = newLibraryRepositoryMock(); @@ -63,7 +63,7 @@ describe(UserService.name, () => { jobMock, libraryMock, storageMock, - configMock, + systemMock, userMock, loggerMock, ); @@ -486,7 +486,7 @@ describe(UserService.name, () => { }); it('should skip users not ready for deletion - deleteDelay30', async () => { - configMock.load.mockResolvedValue(systemConfigStub.deleteDelay30); + systemMock.get.mockResolvedValue(systemConfigStub.deleteDelay30); userMock.getDeletedUsers.mockResolvedValue([ {}, { deletedAt: undefined }, diff --git a/server/src/services/user.service.ts b/server/src/services/user.service.ts index c367e3985f..0df2ecae94 100644 --- a/server/src/services/user.service.ts +++ b/server/src/services/user.service.ts @@ -13,7 +13,7 @@ import { IEntityJob, IJobRepository, JobName, JobStatus } from 'src/interfaces/j import { ILibraryRepository } from 'src/interfaces/library.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; -import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; +import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { IUserRepository, UserFindOptions } from 'src/interfaces/user.interface'; import { CacheControl, ImmichFileResponse } from 'src/utils/file'; @@ -28,13 +28,13 @@ export class UserService { @Inject(IJobRepository) private jobRepository: IJobRepository, @Inject(ILibraryRepository) libraryRepository: ILibraryRepository, @Inject(IStorageRepository) private storageRepository: IStorageRepository, - @Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository, + @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, @Inject(IUserRepository) private userRepository: IUserRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository, ) { this.userCore = UserCore.create(cryptoRepository, libraryRepository, userRepository); this.logger.setContext(UserService.name); - this.configCore = SystemConfigCore.create(configRepository, this.logger); + this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); } async listUsers(): Promise { diff --git a/server/src/utils/misc.spec.ts b/server/src/utils/misc.spec.ts new file mode 100644 index 0000000000..c36772ad43 --- /dev/null +++ b/server/src/utils/misc.spec.ts @@ -0,0 +1,52 @@ +import { getKeysDeep, unsetDeep } from 'src/utils/misc'; +import { describe, expect, it } from 'vitest'; + +describe('getKeysDeep', () => { + it('should handle an empty object', () => { + expect(getKeysDeep({})).toEqual([]); + }); + + it('should list properties', () => { + expect( + getKeysDeep({ + foo: 'bar', + flag: true, + count: 42, + }), + ).toEqual(['foo', 'flag', 'count']); + }); + + it('should skip undefined properties', () => { + expect(getKeysDeep({ foo: 'bar', hello: undefined })).toEqual(['foo']); + }); + + it('should skip array indices', () => { + expect(getKeysDeep({ foo: 'bar', hello: ['foo', 'bar'] })).toEqual(['foo', 'hello']); + expect(getKeysDeep({ foo: 'bar', nested: { hello: ['foo', 'bar'] } })).toEqual(['foo', 'nested.hello']); + }); + + it('should list nested properties', () => { + expect(getKeysDeep({ foo: 'bar', hello: { world: true } })).toEqual(['foo', 'hello.world']); + }); +}); + +describe('unsetDeep', () => { + it('should remove a property', () => { + expect(unsetDeep({ hello: 'world', foo: 'bar' }, 'foo')).toEqual({ hello: 'world' }); + }); + + it('should remove the last property', () => { + expect(unsetDeep({ foo: 'bar' }, 'foo')).toBeUndefined(); + }); + + it('should remove a nested property', () => { + expect(unsetDeep({ foo: 'bar', nested: { enabled: true, count: 42 } }, 'nested.enabled')).toEqual({ + foo: 'bar', + nested: { count: 42 }, + }); + }); + + it('should clean up an empty property', () => { + expect(unsetDeep({ foo: 'bar', nested: { enabled: true } }, 'nested.enabled')).toEqual({ foo: 'bar' }); + }); +}); diff --git a/server/src/utils/misc.ts b/server/src/utils/misc.ts index 7a98fea139..ce0a0df4b7 100644 --- a/server/src/utils/misc.ts +++ b/server/src/utils/misc.ts @@ -16,6 +16,47 @@ import { ImmichCookie, ImmichHeader } from 'src/dtos/auth.dto'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { Metadata } from 'src/middleware/auth.guard'; +/** + * @returns a list of strings representing the keys of the object in dot notation + */ +export const getKeysDeep = (target: unknown, path: string[] = []) => { + if (!target || typeof target !== 'object') { + return []; + } + + const obj = target as object; + + const properties: string[] = []; + for (const key of Object.keys(obj as object)) { + const value = obj[key as keyof object]; + if (value === undefined) { + continue; + } + + if (_.isObject(value) && !_.isArray(value)) { + properties.push(...getKeysDeep(value, [...path, key])); + continue; + } + + properties.push([...path, key].join('.')); + } + + return properties; +}; + +export const unsetDeep = (object: unknown, key: string) => { + const parts = key.split('.'); + while (parts.length > 0) { + _.unset(object, parts); + parts.pop(); + if (!_.isEmpty(_.get(object, parts))) { + break; + } + } + + return _.isEmpty(object) ? undefined : object; +}; + const isMachineLearningEnabled = (machineLearning: SystemConfig['machineLearning']) => machineLearning.enabled; export const isSmartSearchEnabled = (machineLearning: SystemConfig['machineLearning']) => isMachineLearningEnabled(machineLearning) && machineLearning.clip.enabled; diff --git a/server/test/fixtures/system-config.stub.ts b/server/test/fixtures/system-config.stub.ts index b557644efa..c01fd212ec 100644 --- a/server/test/fixtures/system-config.stub.ts +++ b/server/test/fixtures/system-config.stub.ts @@ -1,33 +1,74 @@ -import { SystemConfigEntity, SystemConfigKey } from 'src/entities/system-config.entity'; +import { SystemConfig } from 'src/config'; +import { DeepPartial } from 'typeorm'; -export const systemConfigStub: Record = { - defaults: [], - enabled: [ - { key: SystemConfigKey.OAUTH_ENABLED, value: true }, - { key: SystemConfigKey.OAUTH_AUTO_REGISTER, value: true }, - { key: SystemConfigKey.OAUTH_AUTO_LAUNCH, value: false }, - { key: SystemConfigKey.OAUTH_BUTTON_TEXT, value: 'OAuth' }, - ], - disabled: [{ key: SystemConfigKey.PASSWORD_LOGIN_ENABLED, value: false }], - noAutoRegister: [ - { key: SystemConfigKey.OAUTH_ENABLED, value: true }, - { key: SystemConfigKey.OAUTH_AUTO_LAUNCH, value: false }, - { key: SystemConfigKey.OAUTH_AUTO_REGISTER, value: false }, - { key: SystemConfigKey.OAUTH_BUTTON_TEXT, value: 'OAuth' }, - ], - override: [ - { key: SystemConfigKey.OAUTH_ENABLED, value: true }, - { key: SystemConfigKey.OAUTH_AUTO_REGISTER, value: true }, - { key: SystemConfigKey.OAUTH_MOBILE_OVERRIDE_ENABLED, value: true }, - { key: SystemConfigKey.OAUTH_MOBILE_REDIRECT_URI, value: 'http://mobile-redirect' }, - { key: SystemConfigKey.OAUTH_BUTTON_TEXT, value: 'OAuth' }, - ], - withDefaultStorageQuota: [ - { key: SystemConfigKey.OAUTH_ENABLED, value: true }, - { key: SystemConfigKey.OAUTH_AUTO_REGISTER, value: true }, - { key: SystemConfigKey.OAUTH_DEFAULT_STORAGE_QUOTA, value: 1 }, - ], - deleteDelay30: [{ key: SystemConfigKey.USER_DELETE_DELAY, value: 30 }], - libraryWatchEnabled: [{ key: SystemConfigKey.LIBRARY_WATCH_ENABLED, value: true }], - libraryWatchDisabled: [{ key: SystemConfigKey.LIBRARY_WATCH_ENABLED, value: false }], -}; +export const systemConfigStub = { + enabled: { + oauth: { + enabled: true, + autoRegister: true, + autoLaunch: false, + buttonText: 'OAuth', + }, + }, + disabled: { + passwordLogin: { + enabled: false, + }, + }, + noAutoRegister: { + oauth: { + enabled: true, + autoRegister: false, + autoLaunch: false, + buttonText: 'OAuth', + }, + }, + override: { + oauth: { + enabled: true, + autoRegister: true, + mobileOverrideEnabled: true, + mobileRedirectUri: 'http://mobile-redirect', + buttonText: 'OAuth', + }, + }, + withDefaultStorageQuota: { + oauth: { + enabled: true, + autoRegister: true, + defaultStorageQuota: 1, + }, + }, + deleteDelay30: { + user: { + deleteDelay: 30, + }, + }, + libraryWatchEnabled: { + library: { + watch: { + enabled: true, + }, + }, + }, + libraryWatchDisabled: { + library: { + watch: { + enabled: false, + }, + }, + }, + libraryScan: { + library: { + scan: { + enabled: true, + cronExpression: '0 0 * * *', + }, + }, + }, + machineLearningDisabled: { + machineLearning: { + enabled: false, + }, + }, +} satisfies Record>; diff --git a/server/test/repositories/system-config.repository.mock.ts b/server/test/repositories/system-config.repository.mock.ts deleted file mode 100644 index 41135b7d74..0000000000 --- a/server/test/repositories/system-config.repository.mock.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { SystemConfigCore } from 'src/cores/system-config.core'; -import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; -import { Mocked, vitest } from 'vitest'; - -export const newSystemConfigRepositoryMock = (reset = true): Mocked => { - if (reset) { - SystemConfigCore.reset(); - } - - return { - fetchStyle: vitest.fn(), - load: vitest.fn().mockResolvedValue([]), - readFile: vitest.fn(), - saveAll: vitest.fn().mockResolvedValue([]), - deleteKeys: vitest.fn(), - }; -}; diff --git a/server/test/repositories/system-metadata.repository.mock.ts b/server/test/repositories/system-metadata.repository.mock.ts index 1044076ea8..d0cf4fe2e5 100644 --- a/server/test/repositories/system-metadata.repository.mock.ts +++ b/server/test/repositories/system-metadata.repository.mock.ts @@ -1,9 +1,16 @@ +import { SystemConfigCore } from 'src/cores/system-config.core'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { Mocked, vitest } from 'vitest'; -export const newSystemMetadataRepositoryMock = (): Mocked => { +export const newSystemMetadataRepositoryMock = (reset = true): Mocked => { + if (reset) { + SystemConfigCore.reset(); + } + return { get: vitest.fn() as any, set: vitest.fn(), + readFile: vitest.fn(), + fetchStyle: vitest.fn(), }; }; From 673e97e71dde4d111f2c3cd7ea44a43b6ca2d526 Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 16 May 2024 10:58:02 -0500 Subject: [PATCH 072/163] chore(mobile): upgrade flutter to 3.22 (#9518) * chore(mobile): upgrade flutter sdk * gha * update kotlin * refactor * ios build * remove patch files * not touching openapi pubpsec file --- .github/workflows/build-mobile.yml | 2 +- .github/workflows/static_analysis.yml | 4 +-- .github/workflows/test.yml | 2 +- mobile/.vscode/settings.json | 2 +- mobile/android/build.gradle | 2 ++ mobile/ios/Podfile.lock | 2 +- mobile/lib/utils/immich_app_theme.dart | 8 ++--- .../lib/widgets/common/date_time_picker.dart | 4 +-- .../search/search_filter/camera_picker.dart | 2 +- .../search/search_filter/location_picker.dart | 2 +- .../widgets/settings/language_settings.dart | 4 +-- mobile/pubspec.lock | 34 +++++++++---------- mobile/pubspec.yaml | 2 +- mobile/test/widget_tester_extensions.dart | 4 +-- 14 files changed, 38 insertions(+), 36 deletions(-) diff --git a/.github/workflows/build-mobile.yml b/.github/workflows/build-mobile.yml index e522854059..a362dc3438 100644 --- a/.github/workflows/build-mobile.yml +++ b/.github/workflows/build-mobile.yml @@ -45,7 +45,7 @@ jobs: uses: subosito/flutter-action@v2 with: channel: 'stable' - flutter-version: '3.19.3' + flutter-version: '3.22.0' cache: true - name: Create the Keystore diff --git a/.github/workflows/static_analysis.yml b/.github/workflows/static_analysis.yml index 1fb9cb55f1..6ebbb46f5e 100644 --- a/.github/workflows/static_analysis.yml +++ b/.github/workflows/static_analysis.yml @@ -22,8 +22,8 @@ jobs: - name: Setup Flutter SDK uses: subosito/flutter-action@v2 with: - channel: "stable" - flutter-version: "3.19.3" + channel: 'stable' + flutter-version: '3.22.0' - name: Install dependencies run: dart pub get diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 184d5a5165..7603c6fb7c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -208,7 +208,7 @@ jobs: uses: subosito/flutter-action@v2 with: channel: 'stable' - flutter-version: '3.19.3' + flutter-version: '3.22.0' - name: Run tests working-directory: ./mobile run: flutter test -j 1 diff --git a/mobile/.vscode/settings.json b/mobile/.vscode/settings.json index 2959ff4e5e..c487d1fe87 100644 --- a/mobile/.vscode/settings.json +++ b/mobile/.vscode/settings.json @@ -1,5 +1,5 @@ { - "dart.flutterSdkPath": ".fvm/versions/3.19.3", + "dart.flutterSdkPath": ".fvm/versions/3.22.0", "search.exclude": { "**/.fvm": true }, diff --git a/mobile/android/build.gradle b/mobile/android/build.gradle index 5e374c9f64..9b757fbc36 100644 --- a/mobile/android/build.gradle +++ b/mobile/android/build.gradle @@ -1,4 +1,6 @@ allprojects { + ext.kotlin_version = '1.9.24' + repositories { google() mavenCentral() diff --git a/mobile/ios/Podfile.lock b/mobile/ios/Podfile.lock index 5493fc2840..bc79594ee9 100644 --- a/mobile/ios/Podfile.lock +++ b/mobile/ios/Podfile.lock @@ -159,7 +159,7 @@ SPEC CHECKSUMS: FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a geolocator_apple: 9157311f654584b9bb72686c55fc02a97b73f461 image_picker_ios: 4a8aadfbb6dc30ad5141a2ce3832af9214a705b5 - integration_test: 13825b8a9334a850581300559b8839134b124670 + integration_test: ce0a3ffa1de96d1a89ca0ac26fca7ea18a749ef4 isar_flutter_libs: b69f437aeab9c521821c3f376198c4371fa21073 MapLibre: 620fc933c1d6029b33738c905c1490d024e5d4ef maplibre_gl: a2efec727dd340e4c65e26d2b03b584f14881fd9 diff --git a/mobile/lib/utils/immich_app_theme.dart b/mobile/lib/utils/immich_app_theme.dart index 3d550ca17a..32a26439d5 100644 --- a/mobile/lib/utils/immich_app_theme.dart +++ b/mobile/lib/utils/immich_app_theme.dart @@ -121,12 +121,12 @@ final ThemeData immichLightTheme = ThemeData( ), navigationBarTheme: NavigationBarThemeData( indicatorColor: Colors.indigo.withOpacity(0.15), - iconTheme: MaterialStatePropertyAll( + iconTheme: WidgetStatePropertyAll( IconThemeData(color: Colors.grey[700]), ), backgroundColor: immichBackgroundColor, surfaceTintColor: Colors.transparent, - labelTextStyle: MaterialStatePropertyAll( + labelTextStyle: WidgetStatePropertyAll( TextStyle( fontSize: 13, fontWeight: FontWeight.w500, @@ -249,12 +249,12 @@ final ThemeData immichDarkTheme = ThemeData( ), navigationBarTheme: NavigationBarThemeData( indicatorColor: immichDarkThemePrimaryColor.withOpacity(0.4), - iconTheme: MaterialStatePropertyAll( + iconTheme: WidgetStatePropertyAll( IconThemeData(color: Colors.grey[500]), ), backgroundColor: Colors.grey[900], surfaceTintColor: Colors.transparent, - labelTextStyle: MaterialStatePropertyAll( + labelTextStyle: WidgetStatePropertyAll( TextStyle( fontSize: 13, fontWeight: FontWeight.w500, diff --git a/mobile/lib/widgets/common/date_time_picker.dart b/mobile/lib/widgets/common/date_time_picker.dart index adc2092f87..746917d3fb 100644 --- a/mobile/lib/widgets/common/date_time_picker.dart +++ b/mobile/lib/widgets/common/date_time_picker.dart @@ -164,7 +164,7 @@ class _DateTimePicker extends HookWidget { color: context.primaryColor, ), menuStyle: const MenuStyle( - fixedSize: MaterialStatePropertyAll(Size.fromWidth(350)), + fixedSize: WidgetStatePropertyAll(Size.fromWidth(350)), alignment: Alignment(-1.25, 0.5), ), onSelected: (value) => tzOffset.value = value!, @@ -175,7 +175,7 @@ class _DateTimePicker extends HookWidget { value: t, label: t.display, style: ButtonStyle( - textStyle: MaterialStatePropertyAll( + textStyle: WidgetStatePropertyAll( context.textTheme.bodyMedium, ), ), diff --git a/mobile/lib/widgets/search/search_filter/camera_picker.dart b/mobile/lib/widgets/search/search_filter/camera_picker.dart index fc7f1090d7..ea347141a7 100644 --- a/mobile/lib/widgets/search/search_filter/camera_picker.dart +++ b/mobile/lib/widgets/search/search_filter/camera_picker.dart @@ -40,7 +40,7 @@ class CameraPicker extends HookConsumerWidget { ); final menuStyle = MenuStyle( - shape: MaterialStatePropertyAll( + shape: WidgetStatePropertyAll( RoundedRectangleBorder( borderRadius: BorderRadius.circular(15), ), diff --git a/mobile/lib/widgets/search/search_filter/location_picker.dart b/mobile/lib/widgets/search/search_filter/location_picker.dart index 05b55a7403..3aee57c3ca 100644 --- a/mobile/lib/widgets/search/search_filter/location_picker.dart +++ b/mobile/lib/widgets/search/search_filter/location_picker.dart @@ -56,7 +56,7 @@ class LocationPicker extends HookConsumerWidget { ); final menuStyle = MenuStyle( - shape: MaterialStatePropertyAll( + shape: WidgetStatePropertyAll( RoundedRectangleBorder( borderRadius: BorderRadius.circular(15), ), diff --git a/mobile/lib/widgets/settings/language_settings.dart b/mobile/lib/widgets/settings/language_settings.dart index 4569888541..378d32085e 100644 --- a/mobile/lib/widgets/settings/language_settings.dart +++ b/mobile/lib/widgets/settings/language_settings.dart @@ -34,12 +34,12 @@ class LanguageSettings extends HookConsumerWidget { contentPadding: const EdgeInsets.only(left: 16), ), menuStyle: MenuStyle( - shape: MaterialStatePropertyAll( + shape: WidgetStatePropertyAll( RoundedRectangleBorder( borderRadius: BorderRadius.circular(15), ), ), - backgroundColor: MaterialStatePropertyAll( + backgroundColor: WidgetStatePropertyAll( context.isDarkTheme ? Colors.grey[900]! : context.scaffoldBackgroundColor, diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 280d1d8e11..b1c1db2e1e 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -381,10 +381,10 @@ packages: dependency: "direct main" description: name: easy_localization - sha256: de63e3b422adfc97f256cbb3f8cf12739b6a4993d390f3cadb3f51837afaefe5 + sha256: fa59bcdbbb911a764aa6acf96bbb6fa7a5cf8234354fc45ec1a43a0349ef0201 url: "https://pub.dev" source: hosted - version: "3.0.3" + version: "3.0.7" easy_logger: dependency: transitive description: @@ -699,10 +699,10 @@ packages: dependency: transitive description: name: hotreloader - sha256: "94ee21a60ea2836500799f3af035dc3212b1562027f1e0031c14e087f0231449" + sha256: ed56fdc1f3a8ac924e717257621d09e9ec20e308ab6352a73a50a1d7a4d9158e url: "https://pub.dev" source: hosted - version: "4.1.0" + version: "4.2.0" html: dependency: transitive description: @@ -816,10 +816,10 @@ packages: dependency: "direct main" description: name: intl - sha256: "3bc132a9dbce73a7e4a21a17d06e1878839ffbf975568bc875c60537824b0c4d" + sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf url: "https://pub.dev" source: hosted - version: "0.18.1" + version: "0.19.0" io: dependency: transitive description: @@ -872,26 +872,26 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "78eb209deea09858f5269f5a5b02be4049535f568c07b275096836f01ea323fa" + sha256: "7f0df31977cb2c0b88585095d168e689669a2cc9b97c309665e3386f3e9d341a" url: "https://pub.dev" source: hosted - version: "10.0.0" + version: "10.0.4" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: b46c5e37c19120a8a01918cfaf293547f47269f7cb4b0058f21531c2465d6ef0 + sha256: "06e98f569d004c1315b991ded39924b21af84cf14cc94791b8aea337d25b57f8" url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "3.0.3" leak_tracker_testing: dependency: transitive description: name: leak_tracker_testing - sha256: a597f72a664dbd293f3bfc51f9ba69816f84dcd403cdac7066cb3f6003f3ab47 + sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "3.0.1" lints: dependency: transitive description: @@ -1503,10 +1503,10 @@ packages: dependency: transitive description: name: test_api - sha256: "5c2f730018264d276c20e4f1503fd1308dfbbae39ec8ee63c5236311ac06954b" + sha256: "9955ae474176f7ac8ee4e989dadfb411a58c30415bcfb648fa04b2b8a03afa7f" url: "https://pub.dev" source: hosted - version: "0.6.1" + version: "0.7.0" thumbhash: dependency: "direct main" description: @@ -1703,10 +1703,10 @@ packages: dependency: transitive description: name: vm_service - sha256: b3d56ff4341b8f182b96aceb2fa20e3dcb336b9f867bc0eafc0de10f1048e957 + sha256: "3923c89304b715fb1eb6423f017651664a03bf5f4b29983627c4da791f74a4ec" url: "https://pub.dev" source: hosted - version: "13.0.0" + version: "14.2.1" wakelock_plus: dependency: "direct main" description: @@ -1805,4 +1805,4 @@ packages: version: "3.1.2" sdks: dart: ">=3.3.0 <4.0.0" - flutter: ">=3.16.0" + flutter: ">=3.18.0-18.0.pre.54" diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 8ee159651c..ed6afd7ff7 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -21,7 +21,7 @@ dependencies: riverpod_annotation: ^2.3.3 cached_network_image: ^3.3.1 flutter_cache_manager: ^3.3.1 - intl: ^0.18.0 + intl: ^0.19.0 auto_route: ^8.0.2 fluttertoast: ^8.2.4 video_player: ^2.8.2 diff --git a/mobile/test/widget_tester_extensions.dart b/mobile/test/widget_tester_extensions.dart index c054a32501..7d5b266224 100644 --- a/mobile/test/widget_tester_extensions.dart +++ b/mobile/test/widget_tester_extensions.dart @@ -23,8 +23,8 @@ extension PumpConsumerWidget on WidgetTester { home: Material(child: widget), ), ), - duration, - phase, + duration: duration, + phase: phase, ); } } From 64636c0618dec26dde3230c40c279693e4596f5c Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Thu, 16 May 2024 13:08:37 -0400 Subject: [PATCH 073/163] feat(server): near-duplicate detection (#8228) * duplicate detection job, entity, config * queueing * job panel, update api * use embedding in db instead of fetching * disable concurrency * only queue visible assets * handle multiple duplicateIds * update concurrent queue check * add provider * add web placeholder, server endpoint, migration, various fixes * update sql * select embedding by default * rename variable * simplify * remove separate entity, handle re-running with different threshold, set default back to 0.02 * fix tests * add tests * add index to entity * formatting * update asset mock * fix `upsertJobStatus` signature * update sql * formatting * default to 0.03 * optimize clustering * use asset's `duplicateId` if present * update sql * update tests * expose admin setting * refactor * formatting * skip if ml is disabled * debug trash e2e * remove from web * remove from sidebar * test if ml is disabled * update sql * separate duplicate detection from clip in config, disable by default for now * fix doc * lower minimum `maxDistance` * update api * Add and Use Duplicate Detection Feature Flag (#9364) * Add Duplicate Detection Flag * Use Duplicate Detection Flag * Attempt Fixes for Failing Checks * lower minimum `maxDistance` * fix tests --------- Co-authored-by: mertalev <101130780+mertalev@users.noreply.github.com> * chore: fixes and additions after rebase * chore: update api (remove new Role enum) * fix: left join smart search so getAll works without machine learning * test: trash e2e go back to checking length of assets is zero * chore: regen api after rebase * test: fix tests after rebase * redundant join --------- Co-authored-by: Nicholas Flamy <30300649+NicholasFlamy@users.noreply.github.com> Co-authored-by: Zack Pollard Co-authored-by: Zack Pollard --- docs/docs/install/config-file.md | 4 + e2e/src/api/specs/server-info.e2e-spec.ts | 1 + e2e/src/api/specs/trash.e2e-spec.ts | 11 +- mobile/openapi/.openapi-generator/FILES | 3 + mobile/openapi/README.md | 2 + mobile/openapi/doc/AllJobStatusResponseDto.md | 1 + mobile/openapi/doc/AssetApi.md | 38 +++ .../openapi/doc/DuplicateDetectionConfig.md | 16 ++ mobile/openapi/doc/ServerFeaturesDto.md | 1 + .../doc/SystemConfigMachineLearningDto.md | 1 + mobile/openapi/lib/api.dart | 1 + mobile/openapi/lib/api/asset_api.dart | 44 +++ mobile/openapi/lib/api_client.dart | 2 + .../model/all_job_status_response_dto.dart | 10 +- .../lib/model/duplicate_detection_config.dart | 108 ++++++++ mobile/openapi/lib/model/job_name.dart | 3 + .../lib/model/server_features_dto.dart | 10 +- .../system_config_machine_learning_dto.dart | 10 +- .../all_job_status_response_dto_test.dart | 5 + mobile/openapi/test/asset_api_test.dart | 5 + .../test/duplicate_detection_config_test.dart | 32 +++ .../test/server_features_dto_test.dart | 5 + ...stem_config_machine_learning_dto_test.dart | 5 + open-api/immich-openapi-specs.json | 55 ++++ open-api/typescript-sdk/src/fetch-client.ts | 16 ++ server/src/config.ts | 8 + server/src/controllers/asset.controller.ts | 5 + server/src/dtos/job.dto.ts | 3 + server/src/dtos/model-config.dto.ts | 13 +- server/src/dtos/server-info.dto.ts | 1 + server/src/dtos/system-config.dto.ts | 7 +- .../src/entities/asset-job-status.entity.ts | 3 + server/src/entities/asset.entity.ts | 4 + server/src/entities/smart-search.entity.ts | 6 +- server/src/interfaces/asset.interface.ts | 12 +- server/src/interfaces/job.interface.ts | 11 +- server/src/interfaces/search.interface.ts | 14 + .../1711989989911-AddAssetDuplicateColumns.ts | 14 + server/src/queries/asset.repository.sql | 151 ++++++++++- server/src/queries/person.repository.sql | 7 +- server/src/queries/search.repository.sql | 38 ++- server/src/queries/shared.link.repository.sql | 3 + server/src/repositories/asset.repository.ts | 75 ++++-- server/src/repositories/job.repository.ts | 4 + server/src/repositories/search.repository.ts | 40 +++ server/src/services/asset.service.ts | 5 + server/src/services/job.service.spec.ts | 1 + server/src/services/job.service.ts | 17 +- server/src/services/microservices.service.ts | 4 + server/src/services/search.service.spec.ts | 250 +++++++++++++++++- server/src/services/search.service.ts | 109 +++++++- .../src/services/server-info.service.spec.ts | 1 + server/src/services/server-info.service.ts | 3 +- .../services/system-config.service.spec.ts | 4 + server/src/utils/misc.ts | 2 + server/test/fixtures/asset.stub.ts | 105 ++++++++ server/test/fixtures/shared-link.stub.ts | 1 + .../repositories/asset.repository.mock.ts | 2 + .../repositories/search.repository.mock.ts | 1 + web/src/lib/stores/server-config.store.ts | 1 + web/src/lib/utils.ts | 1 + 61 files changed, 1254 insertions(+), 61 deletions(-) create mode 100644 mobile/openapi/doc/DuplicateDetectionConfig.md create mode 100644 mobile/openapi/lib/model/duplicate_detection_config.dart create mode 100644 mobile/openapi/test/duplicate_detection_config_test.dart create mode 100644 server/src/migrations/1711989989911-AddAssetDuplicateColumns.ts diff --git a/docs/docs/install/config-file.md b/docs/docs/install/config-file.md index ad354e2c93..6ebb461a6f 100644 --- a/docs/docs/install/config-file.md +++ b/docs/docs/install/config-file.md @@ -77,6 +77,10 @@ The default configuration looks like this: "enabled": true, "modelName": "ViT-B-32__openai" }, + "duplicateDetection": { + "enabled": false, + "maxDistance": 0.03 + }, "facialRecognition": { "enabled": true, "modelName": "buffalo_l", diff --git a/e2e/src/api/specs/server-info.e2e-spec.ts b/e2e/src/api/specs/server-info.e2e-spec.ts index b900a59235..34af159a6c 100644 --- a/e2e/src/api/specs/server-info.e2e-spec.ts +++ b/e2e/src/api/specs/server-info.e2e-spec.ts @@ -66,6 +66,7 @@ describe('/server-info', () => { expect(body).toEqual({ smartSearch: false, configFile: false, + duplicateDetection: false, facialRecognition: false, map: true, reverseGeocoding: true, diff --git a/e2e/src/api/specs/trash.e2e-spec.ts b/e2e/src/api/specs/trash.e2e-spec.ts index dc2cadc498..e86f6d497a 100644 --- a/e2e/src/api/specs/trash.e2e-spec.ts +++ b/e2e/src/api/specs/trash.e2e-spec.ts @@ -32,8 +32,7 @@ describe('/trash', () => { await utils.deleteAssets(admin.accessToken, [assetId]); const before = await getAllAssets({}, { headers: asBearerAuth(admin.accessToken) }); - - expect(before.length).toBeGreaterThanOrEqual(1); + expect(before).toStrictEqual([expect.objectContaining({ id: assetId, isTrashed: true })]); const { status } = await request(app).post('/trash/empty').set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(204); @@ -57,14 +56,14 @@ describe('/trash', () => { const { id: assetId } = await utils.createAsset(admin.accessToken); await utils.deleteAssets(admin.accessToken, [assetId]); - const before = await utils.getAssetInfo(admin.accessToken, assetId); - expect(before.isTrashed).toBe(true); + const before = await getAllAssets({}, { headers: asBearerAuth(admin.accessToken) }); + expect(before).toStrictEqual([expect.objectContaining({ id: assetId, isTrashed: true })]); const { status } = await request(app).post('/trash/restore').set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(204); - const after = await utils.getAssetInfo(admin.accessToken, assetId); - expect(after.isTrashed).toBe(false); + const after = await getAllAssets({}, { headers: asBearerAuth(admin.accessToken) }); + expect(after).toStrictEqual([expect.objectContaining({ id: assetId, isTrashed: false })]); }); }); diff --git a/mobile/openapi/.openapi-generator/FILES b/mobile/openapi/.openapi-generator/FILES index 570132ada5..222e6c1111 100644 --- a/mobile/openapi/.openapi-generator/FILES +++ b/mobile/openapi/.openapi-generator/FILES @@ -68,6 +68,7 @@ doc/DownloadApi.md doc/DownloadArchiveInfo.md doc/DownloadInfoDto.md doc/DownloadResponseDto.md +doc/DuplicateDetectionConfig.md doc/EntityType.md doc/ExifResponseDto.md doc/FaceApi.md @@ -308,6 +309,7 @@ lib/model/delete_user_dto.dart lib/model/download_archive_info.dart lib/model/download_info_dto.dart lib/model/download_response_dto.dart +lib/model/duplicate_detection_config.dart lib/model/entity_type.dart lib/model/exif_response_dto.dart lib/model/face_dto.dart @@ -501,6 +503,7 @@ test/download_api_test.dart test/download_archive_info_test.dart test/download_info_dto_test.dart test/download_response_dto_test.dart +test/duplicate_detection_config_test.dart test/entity_type_test.dart test/exif_response_dto_test.dart test/face_api_test.dart diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index b9077d9350..4afeb179a4 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -98,6 +98,7 @@ Class | Method | HTTP request | Description *AssetApi* | [**deleteAssets**](doc//AssetApi.md#deleteassets) | **DELETE** /asset | *AssetApi* | [**getAllAssets**](doc//AssetApi.md#getallassets) | **GET** /asset | *AssetApi* | [**getAllUserAssetsByDeviceId**](doc//AssetApi.md#getalluserassetsbydeviceid) | **GET** /asset/device/{deviceId} | +*AssetApi* | [**getAssetDuplicates**](doc//AssetApi.md#getassetduplicates) | **GET** /asset/duplicates | *AssetApi* | [**getAssetInfo**](doc//AssetApi.md#getassetinfo) | **GET** /asset/{id} | *AssetApi* | [**getAssetStatistics**](doc//AssetApi.md#getassetstatistics) | **GET** /asset/statistics | *AssetApi* | [**getAssetThumbnail**](doc//AssetApi.md#getassetthumbnail) | **GET** /asset/thumbnail/{id} | @@ -282,6 +283,7 @@ Class | Method | HTTP request | Description - [DownloadArchiveInfo](doc//DownloadArchiveInfo.md) - [DownloadInfoDto](doc//DownloadInfoDto.md) - [DownloadResponseDto](doc//DownloadResponseDto.md) + - [DuplicateDetectionConfig](doc//DuplicateDetectionConfig.md) - [EntityType](doc//EntityType.md) - [ExifResponseDto](doc//ExifResponseDto.md) - [FaceDto](doc//FaceDto.md) diff --git a/mobile/openapi/doc/AllJobStatusResponseDto.md b/mobile/openapi/doc/AllJobStatusResponseDto.md index fe2f536595..ad74aad4fc 100644 --- a/mobile/openapi/doc/AllJobStatusResponseDto.md +++ b/mobile/openapi/doc/AllJobStatusResponseDto.md @@ -9,6 +9,7 @@ import 'package:openapi/api.dart'; Name | Type | Description | Notes ------------ | ------------- | ------------- | ------------- **backgroundTask** | [**JobStatusDto**](JobStatusDto.md) | | +**duplicateDetection** | [**JobStatusDto**](JobStatusDto.md) | | **faceDetection** | [**JobStatusDto**](JobStatusDto.md) | | **facialRecognition** | [**JobStatusDto**](JobStatusDto.md) | | **library_** | [**JobStatusDto**](JobStatusDto.md) | | diff --git a/mobile/openapi/doc/AssetApi.md b/mobile/openapi/doc/AssetApi.md index a1491c79a2..da070ccfc4 100644 --- a/mobile/openapi/doc/AssetApi.md +++ b/mobile/openapi/doc/AssetApi.md @@ -14,6 +14,7 @@ Method | HTTP request | Description [**deleteAssets**](AssetApi.md#deleteassets) | **DELETE** /asset | [**getAllAssets**](AssetApi.md#getallassets) | **GET** /asset | [**getAllUserAssetsByDeviceId**](AssetApi.md#getalluserassetsbydeviceid) | **GET** /asset/device/{deviceId} | +[**getAssetDuplicates**](AssetApi.md#getassetduplicates) | **GET** /asset/duplicates | [**getAssetInfo**](AssetApi.md#getassetinfo) | **GET** /asset/{id} | [**getAssetStatistics**](AssetApi.md#getassetstatistics) | **GET** /asset/statistics | [**getAssetThumbnail**](AssetApi.md#getassetthumbnail) | **GET** /asset/thumbnail/{id} | @@ -324,6 +325,43 @@ Name | Type | Description | Notes [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) +# **getAssetDuplicates** +> List getAssetDuplicates() + + + +### Example +```dart +import 'package:openapi/api.dart'; + +final api_instance = AssetApi(); + +try { + final result = api_instance.getAssetDuplicates(); + print(result); +} catch (e) { + print('Exception when calling AssetApi->getAssetDuplicates: $e\n'); +} +``` + +### Parameters +This endpoint does not need any parameter. + +### Return type + +[**List**](AssetResponseDto.md) + +### Authorization + +No authorization required + +### HTTP request headers + + - **Content-Type**: Not defined + - **Accept**: application/json + +[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) + # **getAssetInfo** > AssetResponseDto getAssetInfo(id, key) diff --git a/mobile/openapi/doc/DuplicateDetectionConfig.md b/mobile/openapi/doc/DuplicateDetectionConfig.md new file mode 100644 index 0000000000..9691270d4b --- /dev/null +++ b/mobile/openapi/doc/DuplicateDetectionConfig.md @@ -0,0 +1,16 @@ +# openapi.model.DuplicateDetectionConfig + +## Load the model package +```dart +import 'package:openapi/api.dart'; +``` + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**enabled** | **bool** | | +**maxDistance** | **double** | | + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/mobile/openapi/doc/ServerFeaturesDto.md b/mobile/openapi/doc/ServerFeaturesDto.md index 6c32b1265c..86ecfe845d 100644 --- a/mobile/openapi/doc/ServerFeaturesDto.md +++ b/mobile/openapi/doc/ServerFeaturesDto.md @@ -9,6 +9,7 @@ import 'package:openapi/api.dart'; Name | Type | Description | Notes ------------ | ------------- | ------------- | ------------- **configFile** | **bool** | | +**duplicateDetection** | **bool** | | **email** | **bool** | | **facialRecognition** | **bool** | | **map** | **bool** | | diff --git a/mobile/openapi/doc/SystemConfigMachineLearningDto.md b/mobile/openapi/doc/SystemConfigMachineLearningDto.md index 7cb61d9601..1a24172f7d 100644 --- a/mobile/openapi/doc/SystemConfigMachineLearningDto.md +++ b/mobile/openapi/doc/SystemConfigMachineLearningDto.md @@ -9,6 +9,7 @@ import 'package:openapi/api.dart'; Name | Type | Description | Notes ------------ | ------------- | ------------- | ------------- **clip** | [**CLIPConfig**](CLIPConfig.md) | | +**duplicateDetection** | [**DuplicateDetectionConfig**](DuplicateDetectionConfig.md) | | **enabled** | **bool** | | **facialRecognition** | [**RecognitionConfig**](RecognitionConfig.md) | | **url** | **String** | | diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 1629dbb33c..917959d84b 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -114,6 +114,7 @@ part 'model/delete_user_dto.dart'; part 'model/download_archive_info.dart'; part 'model/download_info_dto.dart'; part 'model/download_response_dto.dart'; +part 'model/duplicate_detection_config.dart'; part 'model/entity_type.dart'; part 'model/exif_response_dto.dart'; part 'model/face_dto.dart'; diff --git a/mobile/openapi/lib/api/asset_api.dart b/mobile/openapi/lib/api/asset_api.dart index dba33fc181..5c81b89c58 100644 --- a/mobile/openapi/lib/api/asset_api.dart +++ b/mobile/openapi/lib/api/asset_api.dart @@ -326,6 +326,50 @@ class AssetApi { return null; } + /// Performs an HTTP 'GET /asset/duplicates' operation and returns the [Response]. + Future getAssetDuplicatesWithHttpInfo() async { + // ignore: prefer_const_declarations + final path = r'/asset/duplicates'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + path, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + Future?> getAssetDuplicates() async { + final response = await getAssetDuplicatesWithHttpInfo(); + 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; + } + /// Performs an HTTP 'GET /asset/{id}' operation and returns the [Response]. /// Parameters: /// diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index ed6cec3a09..537d63db33 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -298,6 +298,8 @@ class ApiClient { return DownloadInfoDto.fromJson(value); case 'DownloadResponseDto': return DownloadResponseDto.fromJson(value); + case 'DuplicateDetectionConfig': + return DuplicateDetectionConfig.fromJson(value); case 'EntityType': return EntityTypeTypeTransformer().decode(value); case 'ExifResponseDto': 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 679740658f..1ee5253c38 100644 --- a/mobile/openapi/lib/model/all_job_status_response_dto.dart +++ b/mobile/openapi/lib/model/all_job_status_response_dto.dart @@ -14,6 +14,7 @@ class AllJobStatusResponseDto { /// Returns a new [AllJobStatusResponseDto] instance. AllJobStatusResponseDto({ required this.backgroundTask, + required this.duplicateDetection, required this.faceDetection, required this.facialRecognition, required this.library_, @@ -30,6 +31,8 @@ class AllJobStatusResponseDto { JobStatusDto backgroundTask; + JobStatusDto duplicateDetection; + JobStatusDto faceDetection; JobStatusDto facialRecognition; @@ -57,6 +60,7 @@ class AllJobStatusResponseDto { @override bool operator ==(Object other) => identical(this, other) || other is AllJobStatusResponseDto && other.backgroundTask == backgroundTask && + other.duplicateDetection == duplicateDetection && other.faceDetection == faceDetection && other.facialRecognition == facialRecognition && other.library_ == library_ && @@ -74,6 +78,7 @@ class AllJobStatusResponseDto { int get hashCode => // ignore: unnecessary_parenthesis (backgroundTask.hashCode) + + (duplicateDetection.hashCode) + (faceDetection.hashCode) + (facialRecognition.hashCode) + (library_.hashCode) + @@ -88,11 +93,12 @@ class AllJobStatusResponseDto { (videoConversion.hashCode); @override - String toString() => 'AllJobStatusResponseDto[backgroundTask=$backgroundTask, faceDetection=$faceDetection, facialRecognition=$facialRecognition, library_=$library_, metadataExtraction=$metadataExtraction, migration=$migration, notifications=$notifications, search=$search, sidecar=$sidecar, smartSearch=$smartSearch, storageTemplateMigration=$storageTemplateMigration, thumbnailGeneration=$thumbnailGeneration, videoConversion=$videoConversion]'; + String toString() => 'AllJobStatusResponseDto[backgroundTask=$backgroundTask, duplicateDetection=$duplicateDetection, faceDetection=$faceDetection, facialRecognition=$facialRecognition, library_=$library_, metadataExtraction=$metadataExtraction, migration=$migration, notifications=$notifications, search=$search, sidecar=$sidecar, smartSearch=$smartSearch, storageTemplateMigration=$storageTemplateMigration, thumbnailGeneration=$thumbnailGeneration, videoConversion=$videoConversion]'; Map toJson() { final json = {}; json[r'backgroundTask'] = this.backgroundTask; + json[r'duplicateDetection'] = this.duplicateDetection; json[r'faceDetection'] = this.faceDetection; json[r'facialRecognition'] = this.facialRecognition; json[r'library'] = this.library_; @@ -117,6 +123,7 @@ class AllJobStatusResponseDto { return AllJobStatusResponseDto( backgroundTask: JobStatusDto.fromJson(json[r'backgroundTask'])!, + duplicateDetection: JobStatusDto.fromJson(json[r'duplicateDetection'])!, faceDetection: JobStatusDto.fromJson(json[r'faceDetection'])!, facialRecognition: JobStatusDto.fromJson(json[r'facialRecognition'])!, library_: JobStatusDto.fromJson(json[r'library'])!, @@ -177,6 +184,7 @@ class AllJobStatusResponseDto { /// The list of required keys that must be present in a JSON. static const requiredKeys = { 'backgroundTask', + 'duplicateDetection', 'faceDetection', 'facialRecognition', 'library', diff --git a/mobile/openapi/lib/model/duplicate_detection_config.dart b/mobile/openapi/lib/model/duplicate_detection_config.dart new file mode 100644 index 0000000000..4565a80c0e --- /dev/null +++ b/mobile/openapi/lib/model/duplicate_detection_config.dart @@ -0,0 +1,108 @@ +// +// 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 DuplicateDetectionConfig { + /// Returns a new [DuplicateDetectionConfig] instance. + DuplicateDetectionConfig({ + required this.enabled, + required this.maxDistance, + }); + + bool enabled; + + /// Minimum value: 0.001 + /// Maximum value: 0.1 + double maxDistance; + + @override + bool operator ==(Object other) => identical(this, other) || other is DuplicateDetectionConfig && + other.enabled == enabled && + other.maxDistance == maxDistance; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (enabled.hashCode) + + (maxDistance.hashCode); + + @override + String toString() => 'DuplicateDetectionConfig[enabled=$enabled, maxDistance=$maxDistance]'; + + Map toJson() { + final json = {}; + json[r'enabled'] = this.enabled; + json[r'maxDistance'] = this.maxDistance; + return json; + } + + /// Returns a new [DuplicateDetectionConfig] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static DuplicateDetectionConfig? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return DuplicateDetectionConfig( + enabled: mapValueOfType(json, r'enabled')!, + maxDistance: mapValueOfType(json, r'maxDistance')!, + ); + } + 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 = DuplicateDetectionConfig.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 = DuplicateDetectionConfig.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of DuplicateDetectionConfig-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] = DuplicateDetectionConfig.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'enabled', + 'maxDistance', + }; +} + diff --git a/mobile/openapi/lib/model/job_name.dart b/mobile/openapi/lib/model/job_name.dart index f4b53d3c22..072da76d4c 100644 --- a/mobile/openapi/lib/model/job_name.dart +++ b/mobile/openapi/lib/model/job_name.dart @@ -29,6 +29,7 @@ class JobName { static const faceDetection = JobName._(r'faceDetection'); static const facialRecognition = JobName._(r'facialRecognition'); static const smartSearch = JobName._(r'smartSearch'); + static const duplicateDetection = JobName._(r'duplicateDetection'); static const backgroundTask = JobName._(r'backgroundTask'); static const storageTemplateMigration = JobName._(r'storageTemplateMigration'); static const migration = JobName._(r'migration'); @@ -45,6 +46,7 @@ class JobName { faceDetection, facialRecognition, smartSearch, + duplicateDetection, backgroundTask, storageTemplateMigration, migration, @@ -96,6 +98,7 @@ class JobNameTypeTransformer { case r'faceDetection': return JobName.faceDetection; case r'facialRecognition': return JobName.facialRecognition; case r'smartSearch': return JobName.smartSearch; + case r'duplicateDetection': return JobName.duplicateDetection; case r'backgroundTask': return JobName.backgroundTask; case r'storageTemplateMigration': return JobName.storageTemplateMigration; case r'migration': return JobName.migration; diff --git a/mobile/openapi/lib/model/server_features_dto.dart b/mobile/openapi/lib/model/server_features_dto.dart index 8c51a70793..3e5466237a 100644 --- a/mobile/openapi/lib/model/server_features_dto.dart +++ b/mobile/openapi/lib/model/server_features_dto.dart @@ -14,6 +14,7 @@ class ServerFeaturesDto { /// Returns a new [ServerFeaturesDto] instance. ServerFeaturesDto({ required this.configFile, + required this.duplicateDetection, required this.email, required this.facialRecognition, required this.map, @@ -29,6 +30,8 @@ class ServerFeaturesDto { bool configFile; + bool duplicateDetection; + bool email; bool facialRecognition; @@ -54,6 +57,7 @@ class ServerFeaturesDto { @override bool operator ==(Object other) => identical(this, other) || other is ServerFeaturesDto && other.configFile == configFile && + other.duplicateDetection == duplicateDetection && other.email == email && other.facialRecognition == facialRecognition && other.map == map && @@ -70,6 +74,7 @@ class ServerFeaturesDto { int get hashCode => // ignore: unnecessary_parenthesis (configFile.hashCode) + + (duplicateDetection.hashCode) + (email.hashCode) + (facialRecognition.hashCode) + (map.hashCode) + @@ -83,11 +88,12 @@ class ServerFeaturesDto { (trash.hashCode); @override - String toString() => 'ServerFeaturesDto[configFile=$configFile, email=$email, facialRecognition=$facialRecognition, map=$map, oauth=$oauth, oauthAutoLaunch=$oauthAutoLaunch, passwordLogin=$passwordLogin, reverseGeocoding=$reverseGeocoding, search=$search, sidecar=$sidecar, smartSearch=$smartSearch, trash=$trash]'; + String toString() => 'ServerFeaturesDto[configFile=$configFile, duplicateDetection=$duplicateDetection, email=$email, facialRecognition=$facialRecognition, map=$map, oauth=$oauth, oauthAutoLaunch=$oauthAutoLaunch, passwordLogin=$passwordLogin, reverseGeocoding=$reverseGeocoding, search=$search, sidecar=$sidecar, smartSearch=$smartSearch, trash=$trash]'; Map toJson() { final json = {}; json[r'configFile'] = this.configFile; + json[r'duplicateDetection'] = this.duplicateDetection; json[r'email'] = this.email; json[r'facialRecognition'] = this.facialRecognition; json[r'map'] = this.map; @@ -111,6 +117,7 @@ class ServerFeaturesDto { return ServerFeaturesDto( configFile: mapValueOfType(json, r'configFile')!, + duplicateDetection: mapValueOfType(json, r'duplicateDetection')!, email: mapValueOfType(json, r'email')!, facialRecognition: mapValueOfType(json, r'facialRecognition')!, map: mapValueOfType(json, r'map')!, @@ -170,6 +177,7 @@ class ServerFeaturesDto { /// The list of required keys that must be present in a JSON. static const requiredKeys = { 'configFile', + 'duplicateDetection', 'email', 'facialRecognition', 'map', 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 5d7e6afd76..fc3aae1804 100644 --- a/mobile/openapi/lib/model/system_config_machine_learning_dto.dart +++ b/mobile/openapi/lib/model/system_config_machine_learning_dto.dart @@ -14,6 +14,7 @@ class SystemConfigMachineLearningDto { /// Returns a new [SystemConfigMachineLearningDto] instance. SystemConfigMachineLearningDto({ required this.clip, + required this.duplicateDetection, required this.enabled, required this.facialRecognition, required this.url, @@ -21,6 +22,8 @@ class SystemConfigMachineLearningDto { CLIPConfig clip; + DuplicateDetectionConfig duplicateDetection; + bool enabled; RecognitionConfig facialRecognition; @@ -30,6 +33,7 @@ class SystemConfigMachineLearningDto { @override bool operator ==(Object other) => identical(this, other) || other is SystemConfigMachineLearningDto && other.clip == clip && + other.duplicateDetection == duplicateDetection && other.enabled == enabled && other.facialRecognition == facialRecognition && other.url == url; @@ -38,16 +42,18 @@ class SystemConfigMachineLearningDto { int get hashCode => // ignore: unnecessary_parenthesis (clip.hashCode) + + (duplicateDetection.hashCode) + (enabled.hashCode) + (facialRecognition.hashCode) + (url.hashCode); @override - String toString() => 'SystemConfigMachineLearningDto[clip=$clip, enabled=$enabled, facialRecognition=$facialRecognition, url=$url]'; + String toString() => 'SystemConfigMachineLearningDto[clip=$clip, duplicateDetection=$duplicateDetection, enabled=$enabled, facialRecognition=$facialRecognition, url=$url]'; Map toJson() { final json = {}; json[r'clip'] = this.clip; + json[r'duplicateDetection'] = this.duplicateDetection; json[r'enabled'] = this.enabled; json[r'facialRecognition'] = this.facialRecognition; json[r'url'] = this.url; @@ -63,6 +69,7 @@ class SystemConfigMachineLearningDto { return SystemConfigMachineLearningDto( clip: CLIPConfig.fromJson(json[r'clip'])!, + duplicateDetection: DuplicateDetectionConfig.fromJson(json[r'duplicateDetection'])!, enabled: mapValueOfType(json, r'enabled')!, facialRecognition: RecognitionConfig.fromJson(json[r'facialRecognition'])!, url: mapValueOfType(json, r'url')!, @@ -114,6 +121,7 @@ class SystemConfigMachineLearningDto { /// The list of required keys that must be present in a JSON. static const requiredKeys = { 'clip', + 'duplicateDetection', 'enabled', 'facialRecognition', 'url', diff --git a/mobile/openapi/test/all_job_status_response_dto_test.dart b/mobile/openapi/test/all_job_status_response_dto_test.dart index afadb2ffc8..b8344a3567 100644 --- a/mobile/openapi/test/all_job_status_response_dto_test.dart +++ b/mobile/openapi/test/all_job_status_response_dto_test.dart @@ -21,6 +21,11 @@ void main() { // TODO }); + // JobStatusDto duplicateDetection + test('to test the property `duplicateDetection`', () async { + // TODO + }); + // JobStatusDto faceDetection test('to test the property `faceDetection`', () async { // TODO diff --git a/mobile/openapi/test/asset_api_test.dart b/mobile/openapi/test/asset_api_test.dart index de84e53546..4ab806f35b 100644 --- a/mobile/openapi/test/asset_api_test.dart +++ b/mobile/openapi/test/asset_api_test.dart @@ -50,6 +50,11 @@ void main() { // TODO }); + //Future> getAssetDuplicates() async + test('test getAssetDuplicates', () async { + // TODO + }); + //Future getAssetInfo(String id, { String key }) async test('test getAssetInfo', () async { // TODO diff --git a/mobile/openapi/test/duplicate_detection_config_test.dart b/mobile/openapi/test/duplicate_detection_config_test.dart new file mode 100644 index 0000000000..5368c5f3c7 --- /dev/null +++ b/mobile/openapi/test/duplicate_detection_config_test.dart @@ -0,0 +1,32 @@ +// +// 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 + +import 'package:openapi/api.dart'; +import 'package:test/test.dart'; + +// tests for DuplicateDetectionConfig +void main() { + // final instance = DuplicateDetectionConfig(); + + group('test DuplicateDetectionConfig', () { + // bool enabled + test('to test the property `enabled`', () async { + // TODO + }); + + // double maxDistance + test('to test the property `maxDistance`', () async { + // TODO + }); + + + }); + +} diff --git a/mobile/openapi/test/server_features_dto_test.dart b/mobile/openapi/test/server_features_dto_test.dart index 5e2749e0a9..5645ac5904 100644 --- a/mobile/openapi/test/server_features_dto_test.dart +++ b/mobile/openapi/test/server_features_dto_test.dart @@ -21,6 +21,11 @@ void main() { // TODO }); + // bool duplicateDetection + test('to test the property `duplicateDetection`', () async { + // TODO + }); + // bool email test('to test the property `email`', () async { // TODO diff --git a/mobile/openapi/test/system_config_machine_learning_dto_test.dart b/mobile/openapi/test/system_config_machine_learning_dto_test.dart index 61183846f7..2310b5140e 100644 --- a/mobile/openapi/test/system_config_machine_learning_dto_test.dart +++ b/mobile/openapi/test/system_config_machine_learning_dto_test.dart @@ -21,6 +21,11 @@ void main() { // TODO }); + // DuplicateDetectionConfig duplicateDetection + test('to test the property `duplicateDetection`', () async { + // TODO + }); + // bool enabled test('to test the property `enabled`', () async { // TODO diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index ac8634766a..d25076d419 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -1194,6 +1194,30 @@ ] } }, + "/asset/duplicates": { + "get": { + "operationId": "getAssetDuplicates", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/AssetResponseDto" + }, + "type": "array" + } + } + }, + "description": "" + } + }, + "tags": [ + "Asset" + ] + } + }, "/asset/exist": { "post": { "description": "Checks if multiple assets exist on the server and returns all existing - used by background backup", @@ -6812,6 +6836,9 @@ "backgroundTask": { "$ref": "#/components/schemas/JobStatusDto" }, + "duplicateDetection": { + "$ref": "#/components/schemas/JobStatusDto" + }, "faceDetection": { "$ref": "#/components/schemas/JobStatusDto" }, @@ -6851,6 +6878,7 @@ }, "required": [ "backgroundTask", + "duplicateDetection", "faceDetection", "facialRecognition", "library", @@ -7873,6 +7901,24 @@ ], "type": "object" }, + "DuplicateDetectionConfig": { + "properties": { + "enabled": { + "type": "boolean" + }, + "maxDistance": { + "format": "float", + "maximum": 0.1, + "minimum": 0.001, + "type": "number" + } + }, + "required": [ + "enabled", + "maxDistance" + ], + "type": "object" + }, "EntityType": { "enum": [ "ASSET", @@ -8167,6 +8213,7 @@ "faceDetection", "facialRecognition", "smartSearch", + "duplicateDetection", "backgroundTask", "storageTemplateMigration", "migration", @@ -9379,6 +9426,9 @@ "configFile": { "type": "boolean" }, + "duplicateDetection": { + "type": "boolean" + }, "email": { "type": "boolean" }, @@ -9415,6 +9465,7 @@ }, "required": [ "configFile", + "duplicateDetection", "email", "facialRecognition", "map", @@ -10247,6 +10298,9 @@ "clip": { "$ref": "#/components/schemas/CLIPConfig" }, + "duplicateDetection": { + "$ref": "#/components/schemas/DuplicateDetectionConfig" + }, "enabled": { "type": "boolean" }, @@ -10259,6 +10313,7 @@ }, "required": [ "clip", + "duplicateDetection", "enabled", "facialRecognition", "url" diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index d6a2b2529f..e174dda002 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -410,6 +410,7 @@ export type JobStatusDto = { }; export type AllJobStatusResponseDto = { backgroundTask: JobStatusDto; + duplicateDetection: JobStatusDto; faceDetection: JobStatusDto; facialRecognition: JobStatusDto; library: JobStatusDto; @@ -748,6 +749,7 @@ export type ServerConfigDto = { }; export type ServerFeaturesDto = { configFile: boolean; + duplicateDetection: boolean; email: boolean; facialRecognition: boolean; map: boolean; @@ -927,6 +929,10 @@ export type ClipConfig = { modelName: string; modelType?: ModelType; }; +export type DuplicateDetectionConfig = { + enabled: boolean; + maxDistance: number; +}; export type RecognitionConfig = { enabled: boolean; maxDistance: number; @@ -937,6 +943,7 @@ export type RecognitionConfig = { }; export type SystemConfigMachineLearningDto = { clip: ClipConfig; + duplicateDetection: DuplicateDetectionConfig; enabled: boolean; facialRecognition: RecognitionConfig; url: string; @@ -1399,6 +1406,14 @@ export function getAllUserAssetsByDeviceId({ deviceId }: { ...opts })); } +export function getAssetDuplicates(opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: AssetResponseDto[]; + }>("/asset/duplicates", { + ...opts + })); +} /** * Checks if multiple assets exist on the server and returns all existing - used by background backup */ @@ -2876,6 +2891,7 @@ export enum JobName { FaceDetection = "faceDetection", FacialRecognition = "facialRecognition", SmartSearch = "smartSearch", + DuplicateDetection = "duplicateDetection", BackgroundTask = "backgroundTask", StorageTemplateMigration = "storageTemplateMigration", Migration = "migration", diff --git a/server/src/config.ts b/server/src/config.ts index a9a9b2398c..6a7d2b754c 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -111,6 +111,10 @@ export interface SystemConfig { enabled: boolean; modelName: string; }; + duplicateDetection: { + enabled: boolean; + maxDistance: number; + }; facialRecognition: { enabled: boolean; modelName: string; @@ -249,6 +253,10 @@ export const defaults = Object.freeze({ enabled: true, modelName: 'ViT-B-32__openai', }, + duplicateDetection: { + enabled: false, + maxDistance: 0.03, + }, facialRecognition: { enabled: true, modelName: 'buffalo_l', diff --git a/server/src/controllers/asset.controller.ts b/server/src/controllers/asset.controller.ts index f2d076e17b..7e51f17b59 100644 --- a/server/src/controllers/asset.controller.ts +++ b/server/src/controllers/asset.controller.ts @@ -57,6 +57,11 @@ export class AssetController { return this.service.getStatistics(auth, dto); } + @Get('duplicates') + getAssetDuplicates(@Auth() auth: AuthDto): Promise { + return this.service.getDuplicates(auth); + } + @Post('jobs') @HttpCode(HttpStatus.NO_CONTENT) @Authenticated() diff --git a/server/src/dtos/job.dto.ts b/server/src/dtos/job.dto.ts index 6234d055b9..b7d8cf59bf 100644 --- a/server/src/dtos/job.dto.ts +++ b/server/src/dtos/job.dto.ts @@ -73,6 +73,9 @@ export class AllJobStatusResponseDto implements Record @ApiProperty({ type: JobStatusDto }) [QueueName.SEARCH]!: JobStatusDto; + @ApiProperty({ type: JobStatusDto }) + [QueueName.DUPLICATE_DETECTION]!: JobStatusDto; + @ApiProperty({ type: JobStatusDto }) [QueueName.FACE_DETECTION]!: JobStatusDto; diff --git a/server/src/dtos/model-config.dto.ts b/server/src/dtos/model-config.dto.ts index d1e8bf3391..a3efd19f82 100644 --- a/server/src/dtos/model-config.dto.ts +++ b/server/src/dtos/model-config.dto.ts @@ -4,10 +4,12 @@ import { IsEnum, IsNotEmpty, IsNumber, IsString, Max, Min } from 'class-validato import { CLIPMode, ModelType } from 'src/interfaces/machine-learning.interface'; import { Optional, ValidateBoolean } from 'src/validation'; -export class ModelConfig { +export class TaskConfig { @ValidateBoolean() enabled!: boolean; +} +export class ModelConfig extends TaskConfig { @IsString() @IsNotEmpty() modelName!: string; @@ -25,6 +27,15 @@ export class CLIPConfig extends ModelConfig { mode?: CLIPMode; } +export class DuplicateDetectionConfig extends TaskConfig { + @IsNumber() + @Min(0.001) + @Max(0.1) + @Type(() => Number) + @ApiProperty({ type: 'number', format: 'float' }) + maxDistance!: number; +} + export class RecognitionConfig extends ModelConfig { @IsNumber() @Min(0) diff --git a/server/src/dtos/server-info.dto.ts b/server/src/dtos/server-info.dto.ts index 513329d063..210e1f894f 100644 --- a/server/src/dtos/server-info.dto.ts +++ b/server/src/dtos/server-info.dto.ts @@ -97,6 +97,7 @@ export class ServerConfigDto { export class ServerFeaturesDto { smartSearch!: boolean; + duplicateDetection!: boolean; configFile!: boolean; facialRecognition!: boolean; map!: boolean; diff --git a/server/src/dtos/system-config.dto.ts b/server/src/dtos/system-config.dto.ts index da68c27478..7cf9bb3f8e 100644 --- a/server/src/dtos/system-config.dto.ts +++ b/server/src/dtos/system-config.dto.ts @@ -30,7 +30,7 @@ import { TranscodePolicy, VideoCodec, } from 'src/config'; -import { CLIPConfig, RecognitionConfig } from 'src/dtos/model-config.dto'; +import { CLIPConfig, DuplicateDetectionConfig, RecognitionConfig } from 'src/dtos/model-config.dto'; import { ConcurrentQueueName, QueueName } from 'src/interfaces/job.interface'; import { ValidateBoolean, validateCronExpression } from 'src/validation'; @@ -262,6 +262,11 @@ class SystemConfigMachineLearningDto { @IsObject() clip!: CLIPConfig; + @Type(() => DuplicateDetectionConfig) + @ValidateNested() + @IsObject() + duplicateDetection!: DuplicateDetectionConfig; + @Type(() => RecognitionConfig) @ValidateNested() @IsObject() diff --git a/server/src/entities/asset-job-status.entity.ts b/server/src/entities/asset-job-status.entity.ts index b500752037..44c0a04696 100644 --- a/server/src/entities/asset-job-status.entity.ts +++ b/server/src/entities/asset-job-status.entity.ts @@ -15,4 +15,7 @@ export class AssetJobStatusEntity { @Column({ type: 'timestamptz', nullable: true }) metadataExtractedAt!: Date | null; + + @Column({ type: 'timestamptz', nullable: true }) + duplicatesDetectedAt!: Date | null; } diff --git a/server/src/entities/asset.entity.ts b/server/src/entities/asset.entity.ts index 1181b42da9..7169ee9070 100644 --- a/server/src/entities/asset.entity.ts +++ b/server/src/entities/asset.entity.ts @@ -165,6 +165,10 @@ export class AssetEntity { @OneToOne(() => AssetJobStatusEntity, (jobStatus) => jobStatus.asset, { nullable: true }) jobStatus?: AssetJobStatusEntity; + + @Index('IDX_assets_duplicateId') + @Column({ type: 'uuid', nullable: true }) + duplicateId!: string | null; } export enum AssetType { diff --git a/server/src/entities/smart-search.entity.ts b/server/src/entities/smart-search.entity.ts index 4595ad2403..da1e0e52f1 100644 --- a/server/src/entities/smart-search.entity.ts +++ b/server/src/entities/smart-search.entity.ts @@ -11,10 +11,6 @@ export class SmartSearchEntity { assetId!: string; @Index('clip_index', { synchronize: false }) - @Column({ - type: 'float4', - array: true, - select: false, - }) + @Column({ type: 'float4', array: true, transformer: { from: (v) => JSON.parse(v), to: (v) => v } }) embedding!: number[]; } diff --git a/server/src/interfaces/asset.interface.ts b/server/src/interfaces/asset.interface.ts index b523f36bfa..4b9ff031e5 100644 --- a/server/src/interfaces/asset.interface.ts +++ b/server/src/interfaces/asset.interface.ts @@ -40,6 +40,7 @@ export enum WithoutProperty { ENCODED_VIDEO = 'encoded-video', EXIF = 'exif', SMART_SEARCH = 'smart-search', + DUPLICATE = 'duplicate', OBJECT_TAGS = 'object-tags', FACES = 'faces', PERSON = 'person', @@ -60,6 +61,7 @@ export interface AssetBuilderOptions { isArchived?: boolean; isFavorite?: boolean; isTrashed?: boolean; + isDuplicate?: boolean; albumId?: string; personId?: string; userIds?: string[]; @@ -143,6 +145,12 @@ export interface AssetDeltaSyncOptions { limit: number; } +export interface AssetUpdateDuplicateOptions { + targetDuplicateId: string | null; + assetIds: string[]; + duplicateIds: string[]; +} + export type AssetPathEntity = Pick; export const IAssetRepository = 'IAssetRepository'; @@ -176,6 +184,7 @@ export interface IAssetRepository { getAll(pagination: PaginationOptions, options?: AssetSearchOptions): Paginated; getAllByDeviceId(userId: string, deviceId: string): Promise; updateAll(ids: string[], options: Partial): Promise; + updateDuplicates(options: AssetUpdateDuplicateOptions): Promise; update(asset: AssetUpdateOptions): Promise; remove(asset: AssetEntity): Promise; softDeleteAll(ids: string[]): Promise; @@ -186,9 +195,10 @@ export interface IAssetRepository { getTimeBuckets(options: TimeBucketOptions): Promise; getTimeBucket(timeBucket: string, options: TimeBucketOptions): Promise; upsertExif(exif: Partial): Promise; - upsertJobStatus(jobStatus: Partial): Promise; + upsertJobStatus(...jobStatus: Partial[]): Promise; getAssetIdByCity(userId: string, options: AssetExploreFieldOptions): Promise>; getAssetIdByTag(userId: string, options: AssetExploreFieldOptions): Promise>; + getDuplicates(options: AssetBuilderOptions): Promise; getAllForUserFullSync(options: AssetFullSyncOptions): Promise; getChangedDeltaSync(options: AssetDeltaSyncOptions): Promise; } diff --git a/server/src/interfaces/job.interface.ts b/server/src/interfaces/job.interface.ts index 0deb6d7266..e5ba7f43eb 100644 --- a/server/src/interfaces/job.interface.ts +++ b/server/src/interfaces/job.interface.ts @@ -5,6 +5,7 @@ export enum QueueName { FACE_DETECTION = 'faceDetection', FACIAL_RECOGNITION = 'facialRecognition', SMART_SEARCH = 'smartSearch', + DUPLICATE_DETECTION = 'duplicateDetection', BACKGROUND_TASK = 'backgroundTask', STORAGE_TEMPLATE_MIGRATION = 'storageTemplateMigration', MIGRATION = 'migration', @@ -16,7 +17,7 @@ export enum QueueName { export type ConcurrentQueueName = Exclude< QueueName, - QueueName.STORAGE_TEMPLATE_MIGRATION | QueueName.FACIAL_RECOGNITION + QueueName.STORAGE_TEMPLATE_MIGRATION | QueueName.FACIAL_RECOGNITION | QueueName.DUPLICATE_DETECTION >; export enum JobCommand { @@ -86,6 +87,10 @@ export enum JobName { QUEUE_SMART_SEARCH = 'queue-smart-search', SMART_SEARCH = 'smart-search', + // duplicate detection + QUEUE_DUPLICATE_DETECTION = 'queue-duplicate-detection', + DUPLICATE_DETECTION = 'duplicate-detection', + // XMP sidecars QUEUE_SIDECAR = 'queue-sidecar', SIDECAR_DISCOVERY = 'sidecar-discovery', @@ -212,6 +217,10 @@ export type JobItem = | { name: JobName.QUEUE_SMART_SEARCH; data: IBaseJob } | { name: JobName.SMART_SEARCH; data: IEntityJob } + // Duplicate Detection + | { name: JobName.QUEUE_DUPLICATE_DETECTION; data: IBaseJob } + | { name: JobName.DUPLICATE_DETECTION; data: IEntityJob } + // Filesystem | { name: JobName.DELETE_FILES; data: IDeleteFilesJob } diff --git a/server/src/interfaces/search.interface.ts b/server/src/interfaces/search.interface.ts index 56dbe1da4b..57523aa940 100644 --- a/server/src/interfaces/search.interface.ts +++ b/server/src/interfaces/search.interface.ts @@ -152,15 +152,29 @@ export interface FaceEmbeddingSearch extends SearchEmbeddingOptions { maxDistance?: number; } +export interface AssetDuplicateSearch { + assetId: string; + embedding: Embedding; + userIds: string[]; + maxDistance?: number; +} + export interface FaceSearchResult { distance: number; face: AssetFaceEntity; } +export interface AssetDuplicateResult { + assetId: string; + duplicateId: string | null; + distance: number; +} + export interface ISearchRepository { init(modelName: string): Promise; searchMetadata(pagination: SearchPaginationOptions, options: AssetSearchOptions): Paginated; searchSmart(pagination: SearchPaginationOptions, options: SmartSearchOptions): Paginated; + searchDuplicates(options: AssetDuplicateSearch): Promise; searchFaces(search: FaceEmbeddingSearch): Promise; upsert(assetId: string, embedding: number[]): Promise; searchPlaces(placeName: string): Promise; diff --git a/server/src/migrations/1711989989911-AddAssetDuplicateColumns.ts b/server/src/migrations/1711989989911-AddAssetDuplicateColumns.ts new file mode 100644 index 0000000000..d295ec2d7c --- /dev/null +++ b/server/src/migrations/1711989989911-AddAssetDuplicateColumns.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateAssetDuplicateColumns1711989989911 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE assets ADD COLUMN "duplicateId" uuid`); + await queryRunner.query(`ALTER TABLE asset_job_status ADD COLUMN "duplicatesDetectedAt" timestamptz`); + await queryRunner.query(`CREATE INDEX "IDX_assets_duplicateId" ON assets ("duplicateId")`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE assets DROP COLUMN "duplicateId"`); + await queryRunner.query(`ALTER TABLE asset_job_status DROP COLUMN "duplicatesDetectedAt"`); + } +} diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index 7cc133b039..3c6c83ff1d 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -30,6 +30,7 @@ SELECT "entity"."originalFileName" AS "entity_originalFileName", "entity"."sidecarPath" AS "entity_sidecarPath", "entity"."stackId" AS "entity_stackId", + "entity"."duplicateId" AS "entity_duplicateId", "exifInfo"."assetId" AS "exifInfo_assetId", "exifInfo"."description" AS "exifInfo_description", "exifInfo"."exifImageWidth" AS "exifInfo_exifImageWidth", @@ -111,7 +112,8 @@ SELECT "AssetEntity"."livePhotoVideoId" AS "AssetEntity_livePhotoVideoId", "AssetEntity"."originalFileName" AS "AssetEntity_originalFileName", "AssetEntity"."sidecarPath" AS "AssetEntity_sidecarPath", - "AssetEntity"."stackId" AS "AssetEntity_stackId" + "AssetEntity"."stackId" AS "AssetEntity_stackId", + "AssetEntity"."duplicateId" AS "AssetEntity_duplicateId" FROM "assets" "AssetEntity" WHERE @@ -147,6 +149,7 @@ SELECT "AssetEntity"."originalFileName" AS "AssetEntity_originalFileName", "AssetEntity"."sidecarPath" AS "AssetEntity_sidecarPath", "AssetEntity"."stackId" AS "AssetEntity_stackId", + "AssetEntity"."duplicateId" AS "AssetEntity_duplicateId", "AssetEntity__AssetEntity_exifInfo"."assetId" AS "AssetEntity__AssetEntity_exifInfo_assetId", "AssetEntity__AssetEntity_exifInfo"."description" AS "AssetEntity__AssetEntity_exifInfo_description", "AssetEntity__AssetEntity_exifInfo"."exifImageWidth" AS "AssetEntity__AssetEntity_exifInfo_exifImageWidth", @@ -230,7 +233,8 @@ SELECT "bd93d5747511a4dad4923546c51365bf1a803774"."livePhotoVideoId" AS "bd93d5747511a4dad4923546c51365bf1a803774_livePhotoVideoId", "bd93d5747511a4dad4923546c51365bf1a803774"."originalFileName" AS "bd93d5747511a4dad4923546c51365bf1a803774_originalFileName", "bd93d5747511a4dad4923546c51365bf1a803774"."sidecarPath" AS "bd93d5747511a4dad4923546c51365bf1a803774_sidecarPath", - "bd93d5747511a4dad4923546c51365bf1a803774"."stackId" AS "bd93d5747511a4dad4923546c51365bf1a803774_stackId" + "bd93d5747511a4dad4923546c51365bf1a803774"."stackId" AS "bd93d5747511a4dad4923546c51365bf1a803774_stackId", + "bd93d5747511a4dad4923546c51365bf1a803774"."duplicateId" AS "bd93d5747511a4dad4923546c51365bf1a803774_duplicateId" FROM "assets" "AssetEntity" LEFT JOIN "exif" "AssetEntity__AssetEntity_exifInfo" ON "AssetEntity__AssetEntity_exifInfo"."assetId" = "AssetEntity"."id" @@ -311,7 +315,8 @@ FROM "AssetEntity"."livePhotoVideoId" AS "AssetEntity_livePhotoVideoId", "AssetEntity"."originalFileName" AS "AssetEntity_originalFileName", "AssetEntity"."sidecarPath" AS "AssetEntity_sidecarPath", - "AssetEntity"."stackId" AS "AssetEntity_stackId" + "AssetEntity"."stackId" AS "AssetEntity_stackId", + "AssetEntity"."duplicateId" AS "AssetEntity_duplicateId" FROM "assets" "AssetEntity" LEFT JOIN "libraries" "AssetEntity__AssetEntity_library" ON "AssetEntity__AssetEntity_library"."id" = "AssetEntity"."libraryId" @@ -407,7 +412,8 @@ SELECT "AssetEntity"."livePhotoVideoId" AS "AssetEntity_livePhotoVideoId", "AssetEntity"."originalFileName" AS "AssetEntity_originalFileName", "AssetEntity"."sidecarPath" AS "AssetEntity_sidecarPath", - "AssetEntity"."stackId" AS "AssetEntity_stackId" + "AssetEntity"."stackId" AS "AssetEntity_stackId", + "AssetEntity"."duplicateId" AS "AssetEntity_duplicateId" FROM "assets" "AssetEntity" WHERE @@ -423,6 +429,15 @@ SET WHERE "id" IN ($2) +-- AssetRepository.updateDuplicates +UPDATE "assets" +SET + "duplicateId" = $1, + "updatedAt" = CURRENT_TIMESTAMP +WHERE + "duplicateId" IN ($2) + OR "id" IN ($3) + -- AssetRepository.getByChecksum SELECT "AssetEntity"."id" AS "AssetEntity_id", @@ -452,7 +467,8 @@ SELECT "AssetEntity"."livePhotoVideoId" AS "AssetEntity_livePhotoVideoId", "AssetEntity"."originalFileName" AS "AssetEntity_originalFileName", "AssetEntity"."sidecarPath" AS "AssetEntity_sidecarPath", - "AssetEntity"."stackId" AS "AssetEntity_stackId" + "AssetEntity"."stackId" AS "AssetEntity_stackId", + "AssetEntity"."duplicateId" AS "AssetEntity_duplicateId" FROM "assets" "AssetEntity" WHERE @@ -519,7 +535,8 @@ SELECT "AssetEntity"."livePhotoVideoId" AS "AssetEntity_livePhotoVideoId", "AssetEntity"."originalFileName" AS "AssetEntity_originalFileName", "AssetEntity"."sidecarPath" AS "AssetEntity_sidecarPath", - "AssetEntity"."stackId" AS "AssetEntity_stackId" + "AssetEntity"."stackId" AS "AssetEntity_stackId", + "AssetEntity"."duplicateId" AS "AssetEntity_duplicateId" FROM "assets" "AssetEntity" WHERE @@ -575,6 +592,7 @@ SELECT "asset"."originalFileName" AS "asset_originalFileName", "asset"."sidecarPath" AS "asset_sidecarPath", "asset"."stackId" AS "asset_stackId", + "asset"."duplicateId" AS "asset_duplicateId", "exifInfo"."assetId" AS "exifInfo_assetId", "exifInfo"."description" AS "exifInfo_description", "exifInfo"."exifImageWidth" AS "exifInfo_exifImageWidth", @@ -632,7 +650,8 @@ SELECT "stackedAssets"."livePhotoVideoId" AS "stackedAssets_livePhotoVideoId", "stackedAssets"."originalFileName" AS "stackedAssets_originalFileName", "stackedAssets"."sidecarPath" AS "stackedAssets_sidecarPath", - "stackedAssets"."stackId" AS "stackedAssets_stackId" + "stackedAssets"."stackId" AS "stackedAssets_stackId", + "stackedAssets"."duplicateId" AS "stackedAssets_duplicateId" FROM "assets" "asset" LEFT JOIN "exif" "exifInfo" ON "exifInfo"."assetId" = "asset"."id" @@ -713,6 +732,7 @@ SELECT "asset"."originalFileName" AS "asset_originalFileName", "asset"."sidecarPath" AS "asset_sidecarPath", "asset"."stackId" AS "asset_stackId", + "asset"."duplicateId" AS "asset_duplicateId", "exifInfo"."assetId" AS "exifInfo_assetId", "exifInfo"."description" AS "exifInfo_description", "exifInfo"."exifImageWidth" AS "exifInfo_exifImageWidth", @@ -770,7 +790,8 @@ SELECT "stackedAssets"."livePhotoVideoId" AS "stackedAssets_livePhotoVideoId", "stackedAssets"."originalFileName" AS "stackedAssets_originalFileName", "stackedAssets"."sidecarPath" AS "stackedAssets_sidecarPath", - "stackedAssets"."stackId" AS "stackedAssets_stackId" + "stackedAssets"."stackId" AS "stackedAssets_stackId", + "stackedAssets"."duplicateId" AS "stackedAssets_duplicateId" FROM "assets" "asset" LEFT JOIN "exif" "exifInfo" ON "exifInfo"."assetId" = "asset"."id" @@ -797,6 +818,112 @@ ORDER BY )::timestamptz DESC, "asset"."fileCreatedAt" DESC +-- AssetRepository.getDuplicates +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"."originalPath" AS "asset_originalPath", + "asset"."previewPath" AS "asset_previewPath", + "asset"."thumbnailPath" AS "asset_thumbnailPath", + "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", + "exifInfo"."assetId" AS "exifInfo_assetId", + "exifInfo"."description" AS "exifInfo_description", + "exifInfo"."exifImageWidth" AS "exifInfo_exifImageWidth", + "exifInfo"."exifImageHeight" AS "exifInfo_exifImageHeight", + "exifInfo"."fileSizeInByte" AS "exifInfo_fileSizeInByte", + "exifInfo"."orientation" AS "exifInfo_orientation", + "exifInfo"."dateTimeOriginal" AS "exifInfo_dateTimeOriginal", + "exifInfo"."modifyDate" AS "exifInfo_modifyDate", + "exifInfo"."timeZone" AS "exifInfo_timeZone", + "exifInfo"."latitude" AS "exifInfo_latitude", + "exifInfo"."longitude" AS "exifInfo_longitude", + "exifInfo"."projectionType" AS "exifInfo_projectionType", + "exifInfo"."city" AS "exifInfo_city", + "exifInfo"."livePhotoCID" AS "exifInfo_livePhotoCID", + "exifInfo"."autoStackId" AS "exifInfo_autoStackId", + "exifInfo"."state" AS "exifInfo_state", + "exifInfo"."country" AS "exifInfo_country", + "exifInfo"."make" AS "exifInfo_make", + "exifInfo"."model" AS "exifInfo_model", + "exifInfo"."lensModel" AS "exifInfo_lensModel", + "exifInfo"."fNumber" AS "exifInfo_fNumber", + "exifInfo"."focalLength" AS "exifInfo_focalLength", + "exifInfo"."iso" AS "exifInfo_iso", + "exifInfo"."exposureTime" AS "exifInfo_exposureTime", + "exifInfo"."profileDescription" AS "exifInfo_profileDescription", + "exifInfo"."colorspace" AS "exifInfo_colorspace", + "exifInfo"."bitsPerSample" AS "exifInfo_bitsPerSample", + "exifInfo"."fps" AS "exifInfo_fps", + "stack"."id" AS "stack_id", + "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"."originalPath" AS "stackedAssets_originalPath", + "stackedAssets"."previewPath" AS "stackedAssets_previewPath", + "stackedAssets"."thumbnailPath" AS "stackedAssets_thumbnailPath", + "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"."isVisible" = true + AND "asset"."ownerId" IN ($1, $2) + AND "asset"."duplicateId" IS NOT NULL + ) + AND ("asset"."deletedAt" IS NULL) +ORDER BY + "asset"."duplicateId" ASC + -- AssetRepository.getAssetIdByCity WITH "cities" AS ( @@ -887,6 +1014,7 @@ SELECT "asset"."originalFileName" AS "asset_originalFileName", "asset"."sidecarPath" AS "asset_sidecarPath", "asset"."stackId" AS "asset_stackId", + "asset"."duplicateId" AS "asset_duplicateId", "exifInfo"."assetId" AS "exifInfo_assetId", "exifInfo"."description" AS "exifInfo_description", "exifInfo"."exifImageWidth" AS "exifInfo_exifImageWidth", @@ -944,7 +1072,8 @@ SELECT "stackedAssets"."livePhotoVideoId" AS "stackedAssets_livePhotoVideoId", "stackedAssets"."originalFileName" AS "stackedAssets_originalFileName", "stackedAssets"."sidecarPath" AS "stackedAssets_sidecarPath", - "stackedAssets"."stackId" AS "stackedAssets_stackId" + "stackedAssets"."stackId" AS "stackedAssets_stackId", + "stackedAssets"."duplicateId" AS "stackedAssets_duplicateId" FROM "assets" "asset" LEFT JOIN "exif" "exifInfo" ON "exifInfo"."assetId" = "asset"."id" @@ -992,6 +1121,7 @@ SELECT "asset"."originalFileName" AS "asset_originalFileName", "asset"."sidecarPath" AS "asset_sidecarPath", "asset"."stackId" AS "asset_stackId", + "asset"."duplicateId" AS "asset_duplicateId", "exifInfo"."assetId" AS "exifInfo_assetId", "exifInfo"."description" AS "exifInfo_description", "exifInfo"."exifImageWidth" AS "exifInfo_exifImageWidth", @@ -1049,7 +1179,8 @@ SELECT "stackedAssets"."livePhotoVideoId" AS "stackedAssets_livePhotoVideoId", "stackedAssets"."originalFileName" AS "stackedAssets_originalFileName", "stackedAssets"."sidecarPath" AS "stackedAssets_sidecarPath", - "stackedAssets"."stackId" AS "stackedAssets_stackId" + "stackedAssets"."stackId" AS "stackedAssets_stackId", + "stackedAssets"."duplicateId" AS "stackedAssets_duplicateId" FROM "assets" "asset" LEFT JOIN "exif" "exifInfo" ON "exifInfo"."assetId" = "asset"."id" diff --git a/server/src/queries/person.repository.sql b/server/src/queries/person.repository.sql index 68c9d520cb..7e22a30ecd 100644 --- a/server/src/queries/person.repository.sql +++ b/server/src/queries/person.repository.sql @@ -174,7 +174,8 @@ FROM "AssetFaceEntity__AssetFaceEntity_asset"."livePhotoVideoId" AS "AssetFaceEntity__AssetFaceEntity_asset_livePhotoVideoId", "AssetFaceEntity__AssetFaceEntity_asset"."originalFileName" AS "AssetFaceEntity__AssetFaceEntity_asset_originalFileName", "AssetFaceEntity__AssetFaceEntity_asset"."sidecarPath" AS "AssetFaceEntity__AssetFaceEntity_asset_sidecarPath", - "AssetFaceEntity__AssetFaceEntity_asset"."stackId" AS "AssetFaceEntity__AssetFaceEntity_asset_stackId" + "AssetFaceEntity__AssetFaceEntity_asset"."stackId" AS "AssetFaceEntity__AssetFaceEntity_asset_stackId", + "AssetFaceEntity__AssetFaceEntity_asset"."duplicateId" AS "AssetFaceEntity__AssetFaceEntity_asset_duplicateId" FROM "asset_faces" "AssetFaceEntity" LEFT JOIN "person" "AssetFaceEntity__AssetFaceEntity_person" ON "AssetFaceEntity__AssetFaceEntity_person"."id" = "AssetFaceEntity"."personId" @@ -272,6 +273,7 @@ FROM "AssetEntity"."originalFileName" AS "AssetEntity_originalFileName", "AssetEntity"."sidecarPath" AS "AssetEntity_sidecarPath", "AssetEntity"."stackId" AS "AssetEntity_stackId", + "AssetEntity"."duplicateId" AS "AssetEntity_duplicateId", "AssetEntity__AssetEntity_faces"."id" AS "AssetEntity__AssetEntity_faces_id", "AssetEntity__AssetEntity_faces"."assetId" AS "AssetEntity__AssetEntity_faces_assetId", "AssetEntity__AssetEntity_faces"."personId" AS "AssetEntity__AssetEntity_faces_personId", @@ -400,7 +402,8 @@ SELECT "AssetFaceEntity__AssetFaceEntity_asset"."livePhotoVideoId" AS "AssetFaceEntity__AssetFaceEntity_asset_livePhotoVideoId", "AssetFaceEntity__AssetFaceEntity_asset"."originalFileName" AS "AssetFaceEntity__AssetFaceEntity_asset_originalFileName", "AssetFaceEntity__AssetFaceEntity_asset"."sidecarPath" AS "AssetFaceEntity__AssetFaceEntity_asset_sidecarPath", - "AssetFaceEntity__AssetFaceEntity_asset"."stackId" AS "AssetFaceEntity__AssetFaceEntity_asset_stackId" + "AssetFaceEntity__AssetFaceEntity_asset"."stackId" AS "AssetFaceEntity__AssetFaceEntity_asset_stackId", + "AssetFaceEntity__AssetFaceEntity_asset"."duplicateId" AS "AssetFaceEntity__AssetFaceEntity_asset_duplicateId" FROM "asset_faces" "AssetFaceEntity" LEFT JOIN "assets" "AssetFaceEntity__AssetFaceEntity_asset" ON "AssetFaceEntity__AssetFaceEntity_asset"."id" = "AssetFaceEntity"."assetId" diff --git a/server/src/queries/search.repository.sql b/server/src/queries/search.repository.sql index e75cd3322a..1a4245592b 100644 --- a/server/src/queries/search.repository.sql +++ b/server/src/queries/search.repository.sql @@ -35,6 +35,7 @@ FROM "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"."primaryAssetId" AS "stack_primaryAssetId", "stackedAssets"."id" AS "stackedAssets_id", @@ -64,7 +65,8 @@ FROM "stackedAssets"."livePhotoVideoId" AS "stackedAssets_livePhotoVideoId", "stackedAssets"."originalFileName" AS "stackedAssets_originalFileName", "stackedAssets"."sidecarPath" AS "stackedAssets_sidecarPath", - "stackedAssets"."stackId" AS "stackedAssets_stackId" + "stackedAssets"."stackId" AS "stackedAssets_stackId", + "stackedAssets"."duplicateId" AS "stackedAssets_duplicateId" FROM "assets" "asset" LEFT JOIN "exif" "exifInfo" ON "exifInfo"."assetId" = "asset"."id" @@ -129,6 +131,7 @@ SELECT "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"."primaryAssetId" AS "stack_primaryAssetId", "stackedAssets"."id" AS "stackedAssets_id", @@ -158,7 +161,8 @@ SELECT "stackedAssets"."livePhotoVideoId" AS "stackedAssets_livePhotoVideoId", "stackedAssets"."originalFileName" AS "stackedAssets_originalFileName", "stackedAssets"."sidecarPath" AS "stackedAssets_sidecarPath", - "stackedAssets"."stackId" AS "stackedAssets_stackId" + "stackedAssets"."stackId" AS "stackedAssets_stackId", + "stackedAssets"."duplicateId" AS "stackedAssets_duplicateId" FROM "assets" "asset" LEFT JOIN "exif" "exifInfo" ON "exifInfo"."assetId" = "asset"."id" @@ -185,6 +189,35 @@ LIMIT 101 COMMIT +-- SearchRepository.searchDuplicates +WITH + "cte" AS ( + SELECT + "asset"."duplicateId" AS "duplicateId", + "search"."assetId" AS "assetId", + "search"."embedding" <= > $1 AS "distance" + FROM + "assets" "asset" + INNER JOIN "smart_search" "search" ON "search"."assetId" = "asset"."id" + WHERE + ( + "asset"."ownerId" IN ($2) + AND "asset"."id" != $3 + AND "asset"."isVisible" = $4 + ) + AND ("asset"."deletedAt" IS NULL) + ORDER BY + "search"."embedding" <= > $1 ASC + LIMIT + 64 + ) +SELECT + res.* +FROM + "cte" "res" +WHERE + res.distance <= $5 + -- SearchRepository.searchFaces START TRANSACTION SET @@ -337,6 +370,7 @@ SELECT "asset"."originalFileName" AS "asset_originalFileName", "asset"."sidecarPath" AS "asset_sidecarPath", "asset"."stackId" AS "asset_stackId", + "asset"."duplicateId" AS "asset_duplicateId", "exif"."assetId" AS "exif_assetId", "exif"."description" AS "exif_description", "exif"."exifImageWidth" AS "exif_exifImageWidth", diff --git a/server/src/queries/shared.link.repository.sql b/server/src/queries/shared.link.repository.sql index ae416696ee..6ae80b3e6a 100644 --- a/server/src/queries/shared.link.repository.sql +++ b/server/src/queries/shared.link.repository.sql @@ -49,6 +49,7 @@ FROM "SharedLinkEntity__SharedLinkEntity_assets"."originalFileName" AS "SharedLinkEntity__SharedLinkEntity_assets_originalFileName", "SharedLinkEntity__SharedLinkEntity_assets"."sidecarPath" AS "SharedLinkEntity__SharedLinkEntity_assets_sidecarPath", "SharedLinkEntity__SharedLinkEntity_assets"."stackId" AS "SharedLinkEntity__SharedLinkEntity_assets_stackId", + "SharedLinkEntity__SharedLinkEntity_assets"."duplicateId" AS "SharedLinkEntity__SharedLinkEntity_assets_duplicateId", "9b1d35b344d838023994a3233afd6ffe098be6d8"."assetId" AS "9b1d35b344d838023994a3233afd6ffe098be6d8_assetId", "9b1d35b344d838023994a3233afd6ffe098be6d8"."description" AS "9b1d35b344d838023994a3233afd6ffe098be6d8_description", "9b1d35b344d838023994a3233afd6ffe098be6d8"."exifImageWidth" AS "9b1d35b344d838023994a3233afd6ffe098be6d8_exifImageWidth", @@ -115,6 +116,7 @@ FROM "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6"."originalFileName" AS "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6_originalFileName", "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6"."sidecarPath" AS "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6_sidecarPath", "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6"."stackId" AS "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6_stackId", + "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6"."duplicateId" AS "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6_duplicateId", "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f"."assetId" AS "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f_assetId", "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f"."description" AS "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f_description", "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f"."exifImageWidth" AS "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f_exifImageWidth", @@ -237,6 +239,7 @@ SELECT "SharedLinkEntity__SharedLinkEntity_assets"."originalFileName" AS "SharedLinkEntity__SharedLinkEntity_assets_originalFileName", "SharedLinkEntity__SharedLinkEntity_assets"."sidecarPath" AS "SharedLinkEntity__SharedLinkEntity_assets_sidecarPath", "SharedLinkEntity__SharedLinkEntity_assets"."stackId" AS "SharedLinkEntity__SharedLinkEntity_assets_stackId", + "SharedLinkEntity__SharedLinkEntity_assets"."duplicateId" AS "SharedLinkEntity__SharedLinkEntity_assets_duplicateId", "SharedLinkEntity__SharedLinkEntity_album"."id" AS "SharedLinkEntity__SharedLinkEntity_album_id", "SharedLinkEntity__SharedLinkEntity_album"."ownerId" AS "SharedLinkEntity__SharedLinkEntity_album_ownerId", "SharedLinkEntity__SharedLinkEntity_album"."albumName" AS "SharedLinkEntity__SharedLinkEntity_album_albumName", diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index 59af894785..b4869b9fbb 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -18,6 +18,7 @@ import { AssetStats, AssetStatsOptions, AssetUpdateAllOptions, + AssetUpdateDuplicateOptions, AssetUpdateOptions, IAssetRepository, LivePhotoSearchOptions, @@ -73,7 +74,7 @@ export class AssetRepository implements IAssetRepository { await this.exifRepository.upsert(exif, { conflictPaths: ['assetId'] }); } - async upsertJobStatus(jobStatus: Partial): Promise { + async upsertJobStatus(...jobStatus: Partial[]): Promise { await this.jobStatusRepository.upsert(jobStatus, { conflictPaths: ['assetId'] }); } @@ -257,6 +258,21 @@ export class AssetRepository implements IAssetRepository { await this.repository.update({ id: In(ids) }, options); } + @GenerateSql({ + params: [{ targetDuplicateId: DummyValue.UUID, duplicateIds: [DummyValue.UUID], assetIds: [DummyValue.UUID] }], + }) + async updateDuplicates(options: AssetUpdateDuplicateOptions): Promise { + await this.repository + .createQueryBuilder() + .update() + .set({ duplicateId: options.targetDuplicateId }) + .where({ + duplicateId: In(options.duplicateIds), + }) + .orWhere({ id: In(options.assetIds) }) + .execute(); + } + @Chunked() async softDeleteAll(ids: string[]): Promise { await this.repository.softDelete({ id: In(ids) }); @@ -375,6 +391,18 @@ export class AssetRepository implements IAssetRepository { break; } + case WithoutProperty.DUPLICATE: { + where = { + previewPath: Not(IsNull()), + isVisible: true, + smartSearch: true, + jobStatus: { + duplicatesDetectedAt: IsNull(), + }, + }; + break; + } + case WithoutProperty.OBJECT_TAGS: { relations = { smartInfo: true, @@ -614,6 +642,13 @@ export class AssetRepository implements IAssetRepository { ); } + @GenerateSql({ params: [{ userIds: [DummyValue.UUID, DummyValue.UUID] }] }) + getDuplicates(options: AssetBuilderOptions): Promise { + return this.getBuilder({ ...options, isDuplicate: true }) + .orderBy('asset.duplicateId') + .getMany(); + } + @GenerateSql({ params: [DummyValue.UUID, { minAssetsPerField: 5, maxFields: 12 }] }) async getAssetIdByCity( ownerId: string, @@ -673,16 +708,14 @@ export class AssetRepository implements IAssetRepository { } private getBuilder(options: AssetBuilderOptions) { - const { isArchived, isFavorite, isTrashed, albumId, personId, userIds, withStacked, exifInfo, assetType } = options; - const builder = this.repository.createQueryBuilder('asset').where('asset.isVisible = true'); - if (assetType !== undefined) { - builder.andWhere('asset.type = :assetType', { assetType }); + if (options.assetType !== undefined) { + builder.andWhere('asset.type = :assetType', { assetType: options.assetType }); } let stackJoined = false; - if (exifInfo !== false) { + if (options.exifInfo !== false) { stackJoined = true; builder .leftJoinAndSelect('asset.exifInfo', 'exifInfo') @@ -690,34 +723,38 @@ export class AssetRepository implements IAssetRepository { .leftJoinAndSelect('stack.assets', 'stackedAssets'); } - if (albumId) { - builder.leftJoin('asset.albums', 'album').andWhere('album.id = :albumId', { albumId }); + if (options.albumId) { + builder.leftJoin('asset.albums', 'album').andWhere('album.id = :albumId', { albumId: options.albumId }); } - if (userIds) { - builder.andWhere('asset.ownerId IN (:...userIds )', { userIds }); + if (options.userIds) { + builder.andWhere('asset.ownerId IN (:...userIds )', { userIds: options.userIds }); } - if (isArchived !== undefined) { - builder.andWhere('asset.isArchived = :isArchived', { isArchived }); + if (options.isArchived !== undefined) { + builder.andWhere('asset.isArchived = :isArchived', { isArchived: options.isArchived }); } - if (isFavorite !== undefined) { - builder.andWhere('asset.isFavorite = :isFavorite', { isFavorite }); + if (options.isFavorite !== undefined) { + builder.andWhere('asset.isFavorite = :isFavorite', { isFavorite: options.isFavorite }); } - if (isTrashed !== undefined) { - builder.andWhere(`asset.deletedAt ${isTrashed ? 'IS NOT NULL' : 'IS NULL'}`).withDeleted(); + if (options.isTrashed !== undefined) { + builder.andWhere(`asset.deletedAt ${options.isTrashed ? 'IS NOT NULL' : 'IS NULL'}`).withDeleted(); } - if (personId !== undefined) { + if (options.isDuplicate !== undefined) { + builder.andWhere(`asset.duplicateId ${options.isDuplicate ? 'IS NOT NULL' : 'IS NULL'}`); + } + + if (options.personId !== undefined) { builder .innerJoin('asset.faces', 'faces') .innerJoin('faces.person', 'person') - .andWhere('person.id = :personId', { personId }); + .andWhere('person.id = :personId', { personId: options.personId }); } - if (withStacked) { + if (options.withStacked) { if (!stackJoined) { builder.leftJoinAndSelect('asset.stack', 'stack').leftJoinAndSelect('stack.assets', 'stackedAssets'); } diff --git a/server/src/repositories/job.repository.ts b/server/src/repositories/job.repository.ts index 78729d5733..c708ea3767 100644 --- a/server/src/repositories/job.repository.ts +++ b/server/src/repositories/job.repository.ts @@ -65,6 +65,10 @@ export const JOBS_TO_QUEUE: Record = { [JobName.QUEUE_SMART_SEARCH]: QueueName.SMART_SEARCH, [JobName.SMART_SEARCH]: QueueName.SMART_SEARCH, + // duplicate detection + [JobName.QUEUE_DUPLICATE_DETECTION]: QueueName.DUPLICATE_DETECTION, + [JobName.DUPLICATE_DETECTION]: QueueName.DUPLICATE_DETECTION, + // XMP sidecars [JobName.QUEUE_SIDECAR]: QueueName.SIDECAR, [JobName.SIDECAR_DISCOVERY]: QueueName.SIDECAR, diff --git a/server/src/repositories/search.repository.ts b/server/src/repositories/search.repository.ts index 6ac49a3190..5bc48fbf99 100644 --- a/server/src/repositories/search.repository.ts +++ b/server/src/repositories/search.repository.ts @@ -10,6 +10,8 @@ import { SmartSearchEntity } from 'src/entities/smart-search.entity'; import { DatabaseExtension } from 'src/interfaces/database.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { + AssetDuplicateResult, + AssetDuplicateSearch, AssetSearchOptions, FaceEmbeddingSearch, FaceSearchResult, @@ -145,6 +147,44 @@ export class SearchRepository implements ISearchRepository { return results; } + @GenerateSql({ + params: [ + { + embedding: Array.from({ length: 512 }, Math.random), + maxDistance: 0.6, + userIds: [DummyValue.UUID], + }, + ], + }) + searchDuplicates({ + assetId, + embedding, + maxDistance, + userIds, + }: AssetDuplicateSearch): Promise { + const cte = this.assetRepository.createQueryBuilder('asset'); + cte + .select('search.assetId', 'assetId') + .addSelect('asset.duplicateId', 'duplicateId') + .addSelect(`search.embedding <=> :embedding`, 'distance') + .innerJoin('asset.smartSearch', 'search') + .where('asset.ownerId IN (:...userIds )') + .andWhere('asset.id != :assetId') + .andWhere('asset.isVisible = :isVisible') + .orderBy('search.embedding <=> :embedding') + .limit(64) + .setParameters({ assetId, embedding: asVector(embedding), isVisible: true, userIds }); + + const builder = this.assetRepository.manager + .createQueryBuilder() + .addCommonTableExpression(cte, 'cte') + .from('cte', 'res') + .select('res.*') + .where('res.distance <= :maxDistance', { maxDistance }); + + return builder.getRawMany() as any as Promise; + } + @GenerateSql({ params: [ { diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index d266b1ed2f..a0cbf40278 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -286,6 +286,11 @@ export class AssetService { return data; } + async getDuplicates(auth: AuthDto): Promise { + const res = await this.assetRepository.getDuplicates({ userIds: [auth.user.id] }); + return res.map((a) => mapAsset(a, { auth })); + } + async update(auth: AuthDto, id: string, dto: UpdateAssetDto): Promise { await this.access.requirePermission(auth, Permission.ASSET_UPDATE, id); diff --git a/server/src/services/job.service.spec.ts b/server/src/services/job.service.spec.ts index abbd41f7bf..20e52ac28e 100644 --- a/server/src/services/job.service.spec.ts +++ b/server/src/services/job.service.spec.ts @@ -109,6 +109,7 @@ describe(JobService.name, () => { await expect(sut.getAllJobsStatus()).resolves.toEqual({ [QueueName.BACKGROUND_TASK]: expectedJobStatus, + [QueueName.DUPLICATE_DETECTION]: expectedJobStatus, [QueueName.SMART_SEARCH]: expectedJobStatus, [QueueName.METADATA_EXTRACTION]: expectedJobStatus, [QueueName.SEARCH]: expectedJobStatus, diff --git a/server/src/services/job.service.ts b/server/src/services/job.service.ts index 9e1bb78db1..8504631d4d 100644 --- a/server/src/services/job.service.ts +++ b/server/src/services/job.service.ts @@ -115,6 +115,10 @@ export class JobService { return this.jobRepository.queue({ name: JobName.QUEUE_SMART_SEARCH, data: { force } }); } + case QueueName.DUPLICATE_DETECTION: { + return this.jobRepository.queue({ name: JobName.QUEUE_DUPLICATE_DETECTION, data: { force } }); + } + case QueueName.METADATA_EXTRACTION: { return this.jobRepository.queue({ name: JobName.QUEUE_METADATA_EXTRACTION, data: { force } }); } @@ -191,7 +195,11 @@ export class JobService { } private isConcurrentQueue(name: QueueName): name is ConcurrentQueueName { - return ![QueueName.FACIAL_RECOGNITION, QueueName.STORAGE_TEMPLATE_MIGRATION].includes(name); + return ![ + QueueName.FACIAL_RECOGNITION, + QueueName.STORAGE_TEMPLATE_MIGRATION, + QueueName.DUPLICATE_DETECTION, + ].includes(name); } async handleNightlyJobs() { @@ -294,6 +302,13 @@ export class JobService { break; } + case JobName.SMART_SEARCH: { + if (item.data.source === 'upload') { + await this.jobRepository.queue({ name: JobName.DUPLICATE_DETECTION, data: item.data }); + } + break; + } + case JobName.USER_DELETION: { this.eventRepository.clientBroadcast(ClientEvent.USER_DELETE, item.data.id); break; diff --git a/server/src/services/microservices.service.ts b/server/src/services/microservices.service.ts index d1f94c5bdf..24acf6b978 100644 --- a/server/src/services/microservices.service.ts +++ b/server/src/services/microservices.service.ts @@ -9,6 +9,7 @@ import { MediaService } from 'src/services/media.service'; import { MetadataService } from 'src/services/metadata.service'; import { NotificationService } from 'src/services/notification.service'; import { PersonService } from 'src/services/person.service'; +import { SearchService } from 'src/services/search.service'; import { SessionService } from 'src/services/session.service'; import { SmartInfoService } from 'src/services/smart-info.service'; import { StorageTemplateService } from 'src/services/storage-template.service'; @@ -35,6 +36,7 @@ export class MicroservicesService { private storageTemplateService: StorageTemplateService, private storageService: StorageService, private userService: UserService, + private searchService: SearchService, ) {} async init() { @@ -53,6 +55,8 @@ export class MicroservicesService { [JobName.USER_SYNC_USAGE]: () => this.userService.handleUserSyncUsage(), [JobName.QUEUE_SMART_SEARCH]: (data) => this.smartInfoService.handleQueueEncodeClip(data), [JobName.SMART_SEARCH]: (data) => this.smartInfoService.handleEncodeClip(data), + [JobName.QUEUE_DUPLICATE_DETECTION]: (data) => this.searchService.handleQueueSearchDuplicates(data), + [JobName.DUPLICATE_DETECTION]: (data) => this.searchService.handleSearchDuplicates(data), [JobName.STORAGE_TEMPLATE_MIGRATION]: () => this.storageTemplateService.handleMigration(), [JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE]: (data) => this.storageTemplateService.handleMigrationSingle(data), [JobName.QUEUE_MIGRATION]: () => this.mediaService.handleQueueMigration(), diff --git a/server/src/services/search.service.spec.ts b/server/src/services/search.service.spec.ts index 321e495fdc..dac6af2cf8 100644 --- a/server/src/services/search.service.spec.ts +++ b/server/src/services/search.service.spec.ts @@ -1,5 +1,7 @@ import { mapAsset } from 'src/dtos/asset-response.dto'; -import { IAssetRepository } from 'src/interfaces/asset.interface'; +import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; +import { ICryptoRepository } from 'src/interfaces/crypto.interface'; +import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IMachineLearningRepository } from 'src/interfaces/machine-learning.interface'; import { IMetadataRepository } from 'src/interfaces/metadata.interface'; @@ -12,6 +14,8 @@ import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; import { personStub } from 'test/fixtures/person.stub'; import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock'; +import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock'; +import { newJobRepositoryMock } from 'test/repositories/job.repository.mock'; import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; import { newMachineLearningRepositoryMock } from 'test/repositories/machine-learning.repository.mock'; import { newMetadataRepositoryMock } from 'test/repositories/metadata.repository.mock'; @@ -19,7 +23,7 @@ import { newPartnerRepositoryMock } from 'test/repositories/partner.repository.m import { newPersonRepositoryMock } from 'test/repositories/person.repository.mock'; import { newSearchRepositoryMock } from 'test/repositories/search.repository.mock'; import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; -import { Mocked, vitest } from 'vitest'; +import { Mocked, beforeEach, vitest } from 'vitest'; vitest.useFakeTimers(); @@ -33,6 +37,8 @@ describe(SearchService.name, () => { let partnerMock: Mocked; let metadataMock: Mocked; let loggerMock: Mocked; + let cryptoMock: Mocked; + let jobMock: Mocked; beforeEach(() => { assetMock = newAssetRepositoryMock(); @@ -43,6 +49,8 @@ describe(SearchService.name, () => { partnerMock = newPartnerRepositoryMock(); metadataMock = newMetadataRepositoryMock(); loggerMock = newLoggerRepositoryMock(); + cryptoMock = newCryptoRepositoryMock(); + jobMock = newJobRepositoryMock(); sut = new SearchService( systemMock, @@ -53,6 +61,8 @@ describe(SearchService.name, () => { partnerMock, metadataMock, loggerMock, + cryptoMock, + jobMock, ); }); @@ -76,15 +86,15 @@ describe(SearchService.name, () => { describe('getExploreData', () => { it('should get assets by city and tag', async () => { - assetMock.getAssetIdByCity.mockResolvedValueOnce({ + assetMock.getAssetIdByCity.mockResolvedValue({ fieldName: 'exifInfo.city', items: [{ value: 'Paris', data: assetStub.image.id }], }); - assetMock.getAssetIdByTag.mockResolvedValueOnce({ + assetMock.getAssetIdByTag.mockResolvedValue({ fieldName: 'smartInfo.tags', items: [{ value: 'train', data: assetStub.imageFrom2015.id }], }); - assetMock.getByIdsWithAllRelations.mockResolvedValueOnce([assetStub.image, assetStub.imageFrom2015]); + assetMock.getByIdsWithAllRelations.mockResolvedValue([assetStub.image, assetStub.imageFrom2015]); const expectedResponse = [ { fieldName: 'exifInfo.city', items: [{ value: 'Paris', data: mapAsset(assetStub.image) }] }, { fieldName: 'smartInfo.tags', items: [{ value: 'train', data: mapAsset(assetStub.imageFrom2015) }] }, @@ -95,4 +105,234 @@ describe(SearchService.name, () => { expect(result).toEqual(expectedResponse); }); }); + + describe('handleQueueSearchDuplicates', () => { + beforeEach(() => { + systemMock.get.mockResolvedValue({ + machineLearning: { + enabled: true, + duplicateDetection: { + enabled: true, + }, + }, + }); + }); + + it('should skip if machine learning is disabled', async () => { + systemMock.get.mockResolvedValue({ + machineLearning: { + enabled: false, + duplicateDetection: { + enabled: true, + }, + }, + }); + + await expect(sut.handleQueueSearchDuplicates({})).resolves.toBe(JobStatus.SKIPPED); + expect(jobMock.queue).not.toHaveBeenCalled(); + expect(jobMock.queueAll).not.toHaveBeenCalled(); + expect(systemMock.get).toHaveBeenCalled(); + }); + + it('should skip if duplicate detection is disabled', async () => { + systemMock.get.mockResolvedValue({ + machineLearning: { + enabled: true, + duplicateDetection: { + enabled: false, + }, + }, + }); + + await expect(sut.handleQueueSearchDuplicates({})).resolves.toBe(JobStatus.SKIPPED); + expect(jobMock.queue).not.toHaveBeenCalled(); + expect(jobMock.queueAll).not.toHaveBeenCalled(); + expect(systemMock.get).toHaveBeenCalled(); + }); + + it('should queue missing assets', async () => { + assetMock.getWithout.mockResolvedValue({ + items: [assetStub.image], + hasNextPage: false, + }); + + await sut.handleQueueSearchDuplicates({}); + + expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.DUPLICATE); + expect(jobMock.queueAll).toHaveBeenCalledWith([ + { + name: JobName.DUPLICATE_DETECTION, + data: { id: assetStub.image.id }, + }, + ]); + }); + + it('should queue all assets', async () => { + assetMock.getAll.mockResolvedValue({ + items: [assetStub.image], + hasNextPage: false, + }); + personMock.getAll.mockResolvedValue({ + items: [personStub.withName], + hasNextPage: false, + }); + + await sut.handleQueueSearchDuplicates({ force: true }); + + expect(assetMock.getAll).toHaveBeenCalled(); + expect(jobMock.queueAll).toHaveBeenCalledWith([ + { + name: JobName.DUPLICATE_DETECTION, + data: { id: assetStub.image.id }, + }, + ]); + }); + }); + + describe('handleSearchDuplicates', () => { + beforeEach(() => { + systemMock.get.mockResolvedValue({ + machineLearning: { + enabled: true, + duplicateDetection: { + enabled: true, + }, + }, + }); + }); + + it('should skip if machine learning is disabled', async () => { + systemMock.get.mockResolvedValue({ + machineLearning: { + enabled: false, + duplicateDetection: { + enabled: true, + }, + }, + }); + const id = assetStub.livePhotoMotionAsset.id; + assetMock.getById.mockResolvedValue(assetStub.livePhotoMotionAsset); + + const result = await sut.handleSearchDuplicates({ id }); + + expect(result).toBe(JobStatus.SKIPPED); + }); + + it('should skip if duplicate detection is disabled', async () => { + systemMock.get.mockResolvedValue({ + machineLearning: { + enabled: true, + duplicateDetection: { + enabled: false, + }, + }, + }); + const id = assetStub.livePhotoMotionAsset.id; + assetMock.getById.mockResolvedValue(assetStub.livePhotoMotionAsset); + + const result = await sut.handleSearchDuplicates({ id }); + + expect(result).toBe(JobStatus.SKIPPED); + }); + + it('should fail if asset is not found', async () => { + const result = await sut.handleSearchDuplicates({ id: assetStub.image.id }); + + expect(result).toBe(JobStatus.FAILED); + expect(loggerMock.error).toHaveBeenCalledWith(`Asset ${assetStub.image.id} not found`); + }); + + it('should skip if asset is not visible', async () => { + const id = assetStub.livePhotoMotionAsset.id; + assetMock.getById.mockResolvedValue(assetStub.livePhotoMotionAsset); + + const result = await sut.handleSearchDuplicates({ id }); + + expect(result).toBe(JobStatus.SKIPPED); + expect(loggerMock.debug).toHaveBeenCalledWith(`Asset ${id} is not visible, skipping`); + }); + + it('should fail if asset is missing preview image', async () => { + assetMock.getById.mockResolvedValue(assetStub.noResizePath); + + const result = await sut.handleSearchDuplicates({ id: assetStub.noResizePath.id }); + + expect(result).toBe(JobStatus.FAILED); + expect(loggerMock.warn).toHaveBeenCalledWith(`Asset ${assetStub.noResizePath.id} is missing preview image`); + }); + + it('should fail if asset is missing embedding', async () => { + assetMock.getById.mockResolvedValue(assetStub.image); + + const result = await sut.handleSearchDuplicates({ id: assetStub.image.id }); + + expect(result).toBe(JobStatus.FAILED); + expect(loggerMock.debug).toHaveBeenCalledWith(`Asset ${assetStub.image.id} is missing embedding`); + }); + + it('should search for duplicates and update asset with duplicateId', async () => { + assetMock.getById.mockResolvedValue(assetStub.hasEmbedding); + searchMock.searchDuplicates.mockResolvedValue([ + { assetId: assetStub.image.id, distance: 0.01, duplicateId: null }, + ]); + const expectedAssetIds = [assetStub.image.id, assetStub.hasEmbedding.id]; + + const result = await sut.handleSearchDuplicates({ id: assetStub.hasEmbedding.id }); + + expect(result).toBe(JobStatus.SUCCESS); + expect(searchMock.searchDuplicates).toHaveBeenCalledWith({ + assetId: assetStub.hasEmbedding.id, + embedding: assetStub.hasEmbedding.smartSearch!.embedding, + maxDistance: 0.03, + userIds: [assetStub.hasEmbedding.ownerId], + }); + expect(assetMock.updateDuplicates).toHaveBeenCalledWith({ + assetIds: expectedAssetIds, + targetDuplicateId: expect.any(String), + duplicateIds: [], + }); + expect(assetMock.upsertJobStatus).toHaveBeenCalledWith( + ...expectedAssetIds.map((assetId) => ({ assetId, duplicatesDetectedAt: expect.any(Date) })), + ); + }); + + it('should use existing duplicate ID among matched duplicates', async () => { + const duplicateId = assetStub.hasDupe.duplicateId; + assetMock.getById.mockResolvedValue(assetStub.hasEmbedding); + searchMock.searchDuplicates.mockResolvedValue([{ assetId: assetStub.hasDupe.id, distance: 0.01, duplicateId }]); + const expectedAssetIds = [assetStub.hasEmbedding.id]; + + const result = await sut.handleSearchDuplicates({ id: assetStub.hasEmbedding.id }); + + expect(result).toBe(JobStatus.SUCCESS); + expect(searchMock.searchDuplicates).toHaveBeenCalledWith({ + assetId: assetStub.hasEmbedding.id, + embedding: assetStub.hasEmbedding.smartSearch!.embedding, + maxDistance: 0.03, + userIds: [assetStub.hasEmbedding.ownerId], + }); + expect(assetMock.updateDuplicates).toHaveBeenCalledWith({ + assetIds: expectedAssetIds, + targetDuplicateId: assetStub.hasDupe.duplicateId, + duplicateIds: [], + }); + expect(assetMock.upsertJobStatus).toHaveBeenCalledWith( + ...expectedAssetIds.map((assetId) => ({ assetId, duplicatesDetectedAt: expect.any(Date) })), + ); + }); + + it('should remove duplicateId if no duplicates found and asset has duplicateId', async () => { + assetMock.getById.mockResolvedValue(assetStub.hasDupe); + searchMock.searchDuplicates.mockResolvedValue([]); + + const result = await sut.handleSearchDuplicates({ id: assetStub.hasDupe.id }); + + expect(result).toBe(JobStatus.SUCCESS); + expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.hasDupe.id, duplicateId: null }); + expect(assetMock.upsertJobStatus).toHaveBeenCalledWith({ + assetId: assetStub.hasDupe.id, + duplicatesDetectedAt: expect.any(Date), + }); + }); + }); }); diff --git a/server/src/services/search.service.ts b/server/src/services/search.service.ts index 10a2ccda2a..28f9b9713e 100644 --- a/server/src/services/search.service.ts +++ b/server/src/services/search.service.ts @@ -16,15 +16,25 @@ import { } from 'src/dtos/search.dto'; import { AssetOrder } from 'src/entities/album.entity'; import { AssetEntity } from 'src/entities/asset.entity'; -import { IAssetRepository } from 'src/interfaces/asset.interface'; +import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; +import { ICryptoRepository } from 'src/interfaces/crypto.interface'; +import { + IBaseJob, + IEntityJob, + IJobRepository, + JOBS_ASSET_PAGINATION_SIZE, + JobName, + JobStatus, +} from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IMachineLearningRepository } from 'src/interfaces/machine-learning.interface'; import { IMetadataRepository } from 'src/interfaces/metadata.interface'; import { IPartnerRepository } from 'src/interfaces/partner.interface'; import { IPersonRepository } from 'src/interfaces/person.interface'; -import { ISearchRepository, SearchExploreItem } from 'src/interfaces/search.interface'; +import { AssetDuplicateResult, ISearchRepository, SearchExploreItem } from 'src/interfaces/search.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; -import { isSmartSearchEnabled } from 'src/utils/misc'; +import { isDuplicateDetectionEnabled, isSmartSearchEnabled } from 'src/utils/misc'; +import { usePagination } from 'src/utils/pagination'; @Injectable() export class SearchService { @@ -39,6 +49,8 @@ export class SearchService { @Inject(IPartnerRepository) private partnerRepository: IPartnerRepository, @Inject(IMetadataRepository) private metadataRepository: IMetadataRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository, + @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository, + @Inject(IJobRepository) private jobRepository: IJobRepository, ) { this.logger.setContext(SearchService.name); this.configCore = SystemConfigCore.create(systemMetadataRepository, logger); @@ -147,6 +159,97 @@ export class SearchService { } } + async handleQueueSearchDuplicates({ force }: IBaseJob): Promise { + const { machineLearning } = await this.configCore.getConfig(); + if (!isDuplicateDetectionEnabled(machineLearning)) { + return JobStatus.SKIPPED; + } + + const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => { + return force + ? this.assetRepository.getAll(pagination, { isVisible: true }) + : this.assetRepository.getWithout(pagination, WithoutProperty.DUPLICATE); + }); + + for await (const assets of assetPagination) { + await this.jobRepository.queueAll( + assets.map((asset) => ({ name: JobName.DUPLICATE_DETECTION, data: { id: asset.id } })), + ); + } + + return JobStatus.SUCCESS; + } + + async handleSearchDuplicates({ id }: IEntityJob): Promise { + const { machineLearning } = await this.configCore.getConfig(); + if (!isDuplicateDetectionEnabled(machineLearning)) { + return JobStatus.SKIPPED; + } + + const asset = await this.assetRepository.getById(id, { smartSearch: true }); + if (!asset) { + this.logger.error(`Asset ${id} not found`); + return JobStatus.FAILED; + } + + if (!asset.isVisible) { + this.logger.debug(`Asset ${id} is not visible, skipping`); + return JobStatus.SKIPPED; + } + + if (!asset.previewPath) { + this.logger.warn(`Asset ${id} is missing preview image`); + return JobStatus.FAILED; + } + + if (!asset.smartSearch?.embedding) { + this.logger.debug(`Asset ${id} is missing embedding`); + return JobStatus.FAILED; + } + + const duplicateAssets = await this.searchRepository.searchDuplicates({ + assetId: asset.id, + embedding: asset.smartSearch.embedding, + maxDistance: machineLearning.duplicateDetection.maxDistance, + userIds: [asset.ownerId], + }); + + let assetIds = [asset.id]; + if (duplicateAssets.length > 0) { + this.logger.debug( + `Found ${duplicateAssets.length} duplicate${duplicateAssets.length === 1 ? '' : 's'} for asset ${asset.id}`, + ); + assetIds = await this.updateDuplicates(asset, duplicateAssets); + } else if (asset.duplicateId) { + this.logger.debug(`No duplicates found for asset ${asset.id}, removing duplicateId`); + await this.assetRepository.update({ id: asset.id, duplicateId: null }); + } + + const duplicatesDetectedAt = new Date(); + await this.assetRepository.upsertJobStatus(...assetIds.map((assetId) => ({ assetId, duplicatesDetectedAt }))); + + return JobStatus.SUCCESS; + } + + private async updateDuplicates(asset: AssetEntity, duplicateAssets: AssetDuplicateResult[]): Promise { + const duplicateIds = [ + ...new Set( + duplicateAssets + .filter((asset): asset is AssetDuplicateResult & { duplicateId: string } => !!asset.duplicateId) + .map((duplicate) => duplicate.duplicateId), + ), + ]; + + const targetDuplicateId = asset.duplicateId ?? duplicateIds.shift() ?? this.cryptoRepository.randomUUID(); + const assetIdsToUpdate = duplicateAssets + .filter((asset) => asset.duplicateId !== targetDuplicateId) + .map((duplicate) => duplicate.assetId); + assetIdsToUpdate.push(asset.id); + + await this.assetRepository.updateDuplicates({ targetDuplicateId, assetIds: assetIdsToUpdate, duplicateIds }); + return assetIdsToUpdate; + } + private async getUserIdsToSearch(auth: AuthDto): Promise { const userIds: string[] = [auth.user.id]; const partners = await this.partnerRepository.getAll(auth.user.id); diff --git a/server/src/services/server-info.service.spec.ts b/server/src/services/server-info.service.spec.ts index 273582b1cf..ff1a73c216 100644 --- a/server/src/services/server-info.service.spec.ts +++ b/server/src/services/server-info.service.spec.ts @@ -164,6 +164,7 @@ describe(ServerInfoService.name, () => { it('should respond the server features', async () => { await expect(sut.getFeatures()).resolves.toEqual({ smartSearch: true, + duplicateDetection: false, facialRecognition: true, map: true, reverseGeocoding: true, diff --git a/server/src/services/server-info.service.ts b/server/src/services/server-info.service.ts index c8ca3069b3..7531b326b2 100644 --- a/server/src/services/server-info.service.ts +++ b/server/src/services/server-info.service.ts @@ -22,7 +22,7 @@ import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interf import { IUserRepository, UserStatsQueryResponse } from 'src/interfaces/user.interface'; import { asHumanReadable } from 'src/utils/bytes'; import { mimeTypes } from 'src/utils/mime-types'; -import { isFacialRecognitionEnabled, isSmartSearchEnabled } from 'src/utils/misc'; +import { isDuplicateDetectionEnabled, isFacialRecognitionEnabled, isSmartSearchEnabled } from 'src/utils/misc'; import { Version } from 'src/utils/version'; @Injectable() @@ -88,6 +88,7 @@ export class ServerInfoService { return { smartSearch: isSmartSearchEnabled(machineLearning), facialRecognition: isFacialRecognitionEnabled(machineLearning), + duplicateDetection: isDuplicateDetectionEnabled(machineLearning), map: map.enabled, reverseGeocoding: reverseGeocoding.enabled, sidecar: true, diff --git a/server/src/services/system-config.service.spec.ts b/server/src/services/system-config.service.spec.ts index e349b2fc11..61ba8df379 100644 --- a/server/src/services/system-config.service.spec.ts +++ b/server/src/services/system-config.service.spec.ts @@ -79,6 +79,10 @@ const updatedConfig = Object.freeze({ enabled: true, modelName: 'ViT-B-32__openai', }, + duplicateDetection: { + enabled: false, + maxDistance: 0.03, + }, facialRecognition: { enabled: true, modelName: 'buffalo_l', diff --git a/server/src/utils/misc.ts b/server/src/utils/misc.ts index ce0a0df4b7..db4687c514 100644 --- a/server/src/utils/misc.ts +++ b/server/src/utils/misc.ts @@ -62,6 +62,8 @@ export const isSmartSearchEnabled = (machineLearning: SystemConfig['machineLearn isMachineLearningEnabled(machineLearning) && machineLearning.clip.enabled; export const isFacialRecognitionEnabled = (machineLearning: SystemConfig['machineLearning']) => isMachineLearningEnabled(machineLearning) && machineLearning.facialRecognition.enabled; +export const isDuplicateDetectionEnabled = (machineLearning: SystemConfig['machineLearning']) => + isMachineLearningEnabled(machineLearning) && machineLearning.duplicateDetection.enabled; export const isConnectionAborted = (error: Error | any) => error.code === 'ECONNABORTED'; diff --git a/server/test/fixtures/asset.stub.ts b/server/test/fixtures/asset.stub.ts index e5d30f72fa..35a1790a3a 100644 --- a/server/test/fixtures/asset.stub.ts +++ b/server/test/fixtures/asset.stub.ts @@ -50,6 +50,7 @@ export const assetStub = { isExternal: false, libraryId: 'library-id', library: libraryStub.uploadLibrary1, + duplicateId: null, }), noWebpPath: Object.freeze({ @@ -89,6 +90,7 @@ export const assetStub = { fileSizeInByte: 123_000, } as ExifEntity, deletedAt: null, + duplicateId: null, }), noThumbhash: Object.freeze({ @@ -125,6 +127,7 @@ export const assetStub = { faces: [], sidecarPath: null, deletedAt: null, + duplicateId: null, }), primaryImage: Object.freeze({ @@ -171,6 +174,7 @@ export const assetStub = { { id: 'stack-child-asset-1' } as AssetEntity, { id: 'stack-child-asset-2' } as AssetEntity, ]), + duplicateId: null, }), image: Object.freeze({ @@ -212,6 +216,7 @@ export const assetStub = { exifImageHeight: 3840, exifImageWidth: 2160, } as ExifEntity, + duplicateId: null, }), external: Object.freeze({ @@ -251,6 +256,7 @@ export const assetStub = { exifInfo: { fileSizeInByte: 5000, } as ExifEntity, + duplicateId: null, }), offline: Object.freeze({ @@ -290,6 +296,7 @@ export const assetStub = { fileSizeInByte: 5000, } as ExifEntity, deletedAt: null, + duplicateId: null, }), externalOffline: Object.freeze({ @@ -329,6 +336,7 @@ export const assetStub = { fileSizeInByte: 5000, } as ExifEntity, deletedAt: null, + duplicateId: null, }), image1: Object.freeze({ @@ -368,6 +376,7 @@ export const assetStub = { exifInfo: { fileSizeInByte: 5000, } as ExifEntity, + duplicateId: null, }), imageFrom2015: Object.freeze({ @@ -407,6 +416,7 @@ export const assetStub = { fileSizeInByte: 5000, } as ExifEntity, deletedAt: null, + duplicateId: null, }), video: Object.freeze({ @@ -446,6 +456,7 @@ export const assetStub = { fileSizeInByte: 100_000, } as ExifEntity, deletedAt: null, + duplicateId: null, }), livePhotoMotionAsset: Object.freeze({ @@ -541,6 +552,7 @@ export const assetStub = { country: 'test-country', } as ExifEntity, deletedAt: null, + duplicateId: null, }), sidecar: Object.freeze({ id: 'asset-id', @@ -576,6 +588,7 @@ export const assetStub = { faces: [], sidecarPath: '/original/path.ext.xmp', deletedAt: null, + duplicateId: null, }), sidecarWithoutExt: Object.freeze({ id: 'asset-id', @@ -611,6 +624,7 @@ export const assetStub = { faces: [], sidecarPath: '/original/path.xmp', deletedAt: null, + duplicateId: null, }), readOnly: Object.freeze({ @@ -647,6 +661,7 @@ export const assetStub = { faces: [], sidecarPath: '/original/path.ext.xmp', deletedAt: null, + duplicateId: null, }), hasEncodedVideo: Object.freeze({ @@ -686,6 +701,7 @@ export const assetStub = { fileSizeInByte: 100_000, } as ExifEntity, deletedAt: null, + duplicateId: null, }), missingFileExtension: Object.freeze({ id: 'asset-id', @@ -724,6 +740,7 @@ export const assetStub = { exifInfo: { fileSizeInByte: 5000, } as ExifEntity, + duplicateId: null, }), hasFileExtension: Object.freeze({ id: 'asset-id', @@ -762,6 +779,7 @@ export const assetStub = { exifInfo: { fileSizeInByte: 5000, } as ExifEntity, + duplicateId: null, }), imageDng: Object.freeze({ id: 'asset-id', @@ -802,5 +820,92 @@ export const assetStub = { profileDescription: 'Adobe RGB', bitsPerSample: 14, } as ExifEntity, + duplicateId: null, + }), + hasEmbedding: Object.freeze({ + id: 'asset-id-embedding', + 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', + previewPath: '/uploads/user-id/thumbs/path.jpg', + checksum: Buffer.from('file hash', 'utf8'), + type: AssetType.IMAGE, + thumbnailPath: '/uploads/user-id/webp/path.ext', + 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, + duration: null, + isVisible: true, + isExternal: false, + livePhotoVideo: null, + livePhotoVideoId: null, + isOffline: false, + libraryId: 'library-id', + library: libraryStub.uploadLibrary1, + tags: [], + sharedLinks: [], + originalFileName: 'asset-id.jpg', + faces: [], + deletedAt: null, + sidecarPath: null, + exifInfo: { + fileSizeInByte: 5000, + } as ExifEntity, + duplicateId: null, + smartSearch: { + assetId: 'asset-id', + embedding: Array.from({ length: 512 }, Math.random), + }, + }), + hasDupe: Object.freeze({ + id: 'asset-id-dupe', + 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', + previewPath: '/uploads/user-id/thumbs/path.jpg', + checksum: Buffer.from('file hash', 'utf8'), + type: AssetType.IMAGE, + thumbnailPath: '/uploads/user-id/webp/path.ext', + 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, + duration: null, + isVisible: true, + isExternal: false, + livePhotoVideo: null, + livePhotoVideoId: null, + isOffline: false, + libraryId: 'library-id', + library: libraryStub.uploadLibrary1, + tags: [], + sharedLinks: [], + originalFileName: 'asset-id.jpg', + faces: [], + deletedAt: null, + sidecarPath: null, + exifInfo: { + fileSizeInByte: 5000, + } as ExifEntity, + duplicateId: 'duplicate-id', + smartSearch: { + assetId: 'asset-id', + embedding: Array.from({ length: 512 }, Math.random), + }, }), }; diff --git a/server/test/fixtures/shared-link.stub.ts b/server/test/fixtures/shared-link.stub.ts index 2ffbe1eb2b..d83bd49096 100644 --- a/server/test/fixtures/shared-link.stub.ts +++ b/server/test/fixtures/shared-link.stub.ts @@ -262,6 +262,7 @@ export const sharedLinkStub = { faces: [], sidecarPath: null, deletedAt: null, + duplicateId: null, }, ], }, diff --git a/server/test/repositories/asset.repository.mock.ts b/server/test/repositories/asset.repository.mock.ts index e22fc1f011..1ad8e31ce2 100644 --- a/server/test/repositories/asset.repository.mock.ts +++ b/server/test/repositories/asset.repository.mock.ts @@ -22,6 +22,7 @@ export const newAssetRepositoryMock = (): Mocked => { getAll: vitest.fn().mockResolvedValue({ items: [], hasNextPage: false }), getAllByDeviceId: vitest.fn(), updateAll: vitest.fn(), + updateDuplicates: vitest.fn(), getExternalLibraryAssetPaths: vitest.fn(), getByLibraryIdAndOriginalPath: vitest.fn(), deleteAll: vitest.fn(), @@ -38,5 +39,6 @@ export const newAssetRepositoryMock = (): Mocked => { getAssetIdByTag: vitest.fn(), getAllForUserFullSync: vitest.fn(), getChangedDeltaSync: vitest.fn(), + getDuplicates: vitest.fn(), }; }; diff --git a/server/test/repositories/search.repository.mock.ts b/server/test/repositories/search.repository.mock.ts index d43b2b9ce9..7da93e02af 100644 --- a/server/test/repositories/search.repository.mock.ts +++ b/server/test/repositories/search.repository.mock.ts @@ -6,6 +6,7 @@ export const newSearchRepositoryMock = (): Mocked => { init: vitest.fn(), searchMetadata: vitest.fn(), searchSmart: vitest.fn(), + searchDuplicates: vitest.fn(), searchFaces: vitest.fn(), upsert: vitest.fn(), searchPlaces: vitest.fn(), diff --git a/web/src/lib/stores/server-config.store.ts b/web/src/lib/stores/server-config.store.ts index ba49d04756..40670df25f 100644 --- a/web/src/lib/stores/server-config.store.ts +++ b/web/src/lib/stores/server-config.store.ts @@ -6,6 +6,7 @@ export type FeatureFlags = ServerFeaturesDto & { loaded: boolean }; export const featureFlags = writable({ loaded: false, smartSearch: true, + duplicateDetection: false, facialRecognition: true, sidecar: true, map: true, diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts index 6553231df2..fb61842b48 100644 --- a/web/src/lib/utils.ts +++ b/web/src/lib/utils.ts @@ -116,6 +116,7 @@ export const getJobName = (jobName: JobName) => { [JobName.MetadataExtraction]: 'Extract Metadata', [JobName.Sidecar]: 'Sidecar Metadata', [JobName.SmartSearch]: 'Smart Search', + [JobName.DuplicateDetection]: 'Duplicate Detection', [JobName.FaceDetection]: 'Face Detection', [JobName.FacialRecognition]: 'Facial Recognition', [JobName.VideoConversion]: 'Transcode Videos', From d8eca168cae983d45b41455fcdc16df5e448e474 Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Thu, 16 May 2024 13:30:26 -0400 Subject: [PATCH 074/163] feat(server): fully accelerated nvenc (#9452) * use arrayContaining * libplacebo for nvenc update dockerfile * tweaks * update nvenc options * tweak settings * refactor * toggle for hardware decoding, software / hardware decoding for nvenc and rkmpp * fix software tone-mapping not being applied * separate configs for hw/sw * update api * add hw decode toggle * fix mutating config * remove `version` flag * fix config type * remove submodule * handle temporal AQ * remove duplicate tests * use `tonemap_opencl` * wording * update docs --- docker/hwaccel.ml.yml | 6 +- docker/hwaccel.transcoding.yml | 2 - docs/docs/features/hardware-transcoding.md | 4 +- mobile/openapi/doc/SystemConfigFFmpegDto.md | 1 + .../lib/model/system_config_f_fmpeg_dto.dart | 10 +- .../test/system_config_f_fmpeg_dto_test.dart | 5 + open-api/immich-openapi-specs.json | 4 + open-api/typescript-sdk/src/fetch-client.ts | 1 + server/src/config.ts | 2 + server/src/dtos/system-config.dto.ts | 3 + server/src/services/media.service.spec.ts | 105 +++++++++++- server/src/services/media.service.ts | 16 +- .../services/system-config.service.spec.ts | 1 + server/src/utils/media.ts | 149 ++++++++++++------ .../settings/ffmpeg/ffmpeg-settings.svelte | 10 ++ 15 files changed, 255 insertions(+), 64 deletions(-) diff --git a/docker/hwaccel.ml.yml b/docker/hwaccel.ml.yml index 3afc5ceafd..d9455d2bb7 100644 --- a/docker/hwaccel.ml.yml +++ b/docker/hwaccel.ml.yml @@ -1,9 +1,7 @@ -version: "3.8" - # Configurations for hardware-accelerated machine learning # If using Unraid or another platform that doesn't allow multiple Compose files, -# you can inline the config for a backend by copying its contents +# you can inline the config for a backend by copying its contents # into the immich-machine-learning service in the docker-compose.yml file. # See https://immich.app/docs/features/ml-hardware-acceleration for info on usage. @@ -30,7 +28,7 @@ services: openvino: device_cgroup_rules: - - "c 189:* rmw" + - 'c 189:* rmw' devices: - /dev/dri:/dev/dri volumes: diff --git a/docker/hwaccel.transcoding.yml b/docker/hwaccel.transcoding.yml index ef9c0a5bb1..bd4e2a46b8 100644 --- a/docker/hwaccel.transcoding.yml +++ b/docker/hwaccel.transcoding.yml @@ -1,5 +1,3 @@ -version: "3.8" - # Configurations for hardware-accelerated transcoding # If using Unraid or another platform that doesn't allow multiple Compose files, diff --git a/docs/docs/features/hardware-transcoding.md b/docs/docs/features/hardware-transcoding.md index cc195a300c..6067e7babb 100644 --- a/docs/docs/features/hardware-transcoding.md +++ b/docs/docs/features/hardware-transcoding.md @@ -22,7 +22,8 @@ You do not need to redo any transcoding jobs after enabling hardware acceleratio - WSL2 does not support Quick Sync. - Raspberry Pi is currently not supported. - Two-pass mode is only supported for NVENC. Other APIs will ignore this setting. -- Only encoding is currently hardware accelerated, so the CPU is still used for software decoding and tone-mapping. +- By default, only encoding is currently hardware accelerated. This means the CPU is still used for software decoding and tone-mapping. + - NVENC and RKMPP can be fully accelerated by enabling hardware decoding in the video transcoding settings. - Hardware dependent - Codec support varies, but H.264 and HEVC are usually supported. - Notably, NVIDIA and AMD GPUs do not support VP9 encoding. @@ -65,6 +66,7 @@ For RKMPP to work: 3. Redeploy the `immich-microservices` container with these updated settings. 4. In the Admin page under `Video transcoding settings`, change the hardware acceleration setting to the appropriate option and save. +5. (Optional) If using a compatible backend, you may enable hardware decoding for optimal performance. #### Single Compose File diff --git a/mobile/openapi/doc/SystemConfigFFmpegDto.md b/mobile/openapi/doc/SystemConfigFFmpegDto.md index 05fe1c4437..28ec94c8cc 100644 --- a/mobile/openapi/doc/SystemConfigFFmpegDto.md +++ b/mobile/openapi/doc/SystemConfigFFmpegDto.md @@ -9,6 +9,7 @@ import 'package:openapi/api.dart'; Name | Type | Description | Notes ------------ | ------------- | ------------- | ------------- **accel** | [**TranscodeHWAccel**](TranscodeHWAccel.md) | | +**accelDecode** | **bool** | | **acceptedAudioCodecs** | [**List**](AudioCodec.md) | | [default to const []] **acceptedVideoCodecs** | [**List**](VideoCodec.md) | | [default to const []] **bframes** | **int** | | 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 136f1eec32..3c856bcdbe 100644 --- a/mobile/openapi/lib/model/system_config_f_fmpeg_dto.dart +++ b/mobile/openapi/lib/model/system_config_f_fmpeg_dto.dart @@ -14,6 +14,7 @@ class SystemConfigFFmpegDto { /// Returns a new [SystemConfigFFmpegDto] instance. SystemConfigFFmpegDto({ required this.accel, + required this.accelDecode, this.acceptedAudioCodecs = const [], this.acceptedVideoCodecs = const [], required this.bframes, @@ -37,6 +38,8 @@ class SystemConfigFFmpegDto { TranscodeHWAccel accel; + bool accelDecode; + List acceptedAudioCodecs; List acceptedVideoCodecs; @@ -87,6 +90,7 @@ class SystemConfigFFmpegDto { @override bool operator ==(Object other) => identical(this, other) || other is SystemConfigFFmpegDto && other.accel == accel && + other.accelDecode == accelDecode && _deepEquality.equals(other.acceptedAudioCodecs, acceptedAudioCodecs) && _deepEquality.equals(other.acceptedVideoCodecs, acceptedVideoCodecs) && other.bframes == bframes && @@ -111,6 +115,7 @@ class SystemConfigFFmpegDto { int get hashCode => // ignore: unnecessary_parenthesis (accel.hashCode) + + (accelDecode.hashCode) + (acceptedAudioCodecs.hashCode) + (acceptedVideoCodecs.hashCode) + (bframes.hashCode) + @@ -132,11 +137,12 @@ class SystemConfigFFmpegDto { (twoPass.hashCode); @override - String toString() => 'SystemConfigFFmpegDto[accel=$accel, acceptedAudioCodecs=$acceptedAudioCodecs, acceptedVideoCodecs=$acceptedVideoCodecs, bframes=$bframes, cqMode=$cqMode, crf=$crf, gopSize=$gopSize, maxBitrate=$maxBitrate, npl=$npl, preferredHwDevice=$preferredHwDevice, preset=$preset, refs=$refs, targetAudioCodec=$targetAudioCodec, targetResolution=$targetResolution, targetVideoCodec=$targetVideoCodec, temporalAQ=$temporalAQ, threads=$threads, tonemap=$tonemap, transcode=$transcode, twoPass=$twoPass]'; + String toString() => 'SystemConfigFFmpegDto[accel=$accel, accelDecode=$accelDecode, acceptedAudioCodecs=$acceptedAudioCodecs, acceptedVideoCodecs=$acceptedVideoCodecs, bframes=$bframes, cqMode=$cqMode, crf=$crf, gopSize=$gopSize, maxBitrate=$maxBitrate, npl=$npl, preferredHwDevice=$preferredHwDevice, preset=$preset, refs=$refs, targetAudioCodec=$targetAudioCodec, targetResolution=$targetResolution, targetVideoCodec=$targetVideoCodec, temporalAQ=$temporalAQ, threads=$threads, tonemap=$tonemap, transcode=$transcode, twoPass=$twoPass]'; Map toJson() { final json = {}; json[r'accel'] = this.accel; + json[r'accelDecode'] = this.accelDecode; json[r'acceptedAudioCodecs'] = this.acceptedAudioCodecs; json[r'acceptedVideoCodecs'] = this.acceptedVideoCodecs; json[r'bframes'] = this.bframes; @@ -168,6 +174,7 @@ class SystemConfigFFmpegDto { return SystemConfigFFmpegDto( accel: TranscodeHWAccel.fromJson(json[r'accel'])!, + accelDecode: mapValueOfType(json, r'accelDecode')!, acceptedAudioCodecs: AudioCodec.listFromJson(json[r'acceptedAudioCodecs']), acceptedVideoCodecs: VideoCodec.listFromJson(json[r'acceptedVideoCodecs']), bframes: mapValueOfType(json, r'bframes')!, @@ -235,6 +242,7 @@ class SystemConfigFFmpegDto { /// The list of required keys that must be present in a JSON. static const requiredKeys = { 'accel', + 'accelDecode', 'acceptedAudioCodecs', 'acceptedVideoCodecs', 'bframes', diff --git a/mobile/openapi/test/system_config_f_fmpeg_dto_test.dart b/mobile/openapi/test/system_config_f_fmpeg_dto_test.dart index 3c038172bc..873b24089f 100644 --- a/mobile/openapi/test/system_config_f_fmpeg_dto_test.dart +++ b/mobile/openapi/test/system_config_f_fmpeg_dto_test.dart @@ -21,6 +21,11 @@ void main() { // TODO }); + // bool accelDecode + test('to test the property `accelDecode`', () async { + // TODO + }); + // List acceptedAudioCodecs (default value: const []) test('to test the property `acceptedAudioCodecs`', () async { // TODO diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index d25076d419..425bf81714 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -10050,6 +10050,9 @@ "accel": { "$ref": "#/components/schemas/TranscodeHWAccel" }, + "accelDecode": { + "type": "boolean" + }, "acceptedAudioCodecs": { "items": { "$ref": "#/components/schemas/AudioCodec" @@ -10125,6 +10128,7 @@ }, "required": [ "accel", + "accelDecode", "acceptedAudioCodecs", "acceptedVideoCodecs", "bframes", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index e174dda002..020188c8a8 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -863,6 +863,7 @@ export type AssetFullSyncDto = { }; export type SystemConfigFFmpegDto = { accel: TranscodeHWAccel; + accelDecode: boolean; acceptedAudioCodecs: AudioCodec[]; acceptedVideoCodecs: VideoCodec[]; bframes: number; diff --git a/server/src/config.ts b/server/src/config.ts index 6a7d2b754c..3ad981f97d 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -97,6 +97,7 @@ export interface SystemConfig { preferredHwDevice: string; transcode: TranscodePolicy; accel: TranscodeHWAccel; + accelDecode: boolean; tonemap: ToneMapping; }; job: Record; @@ -228,6 +229,7 @@ export const defaults = Object.freeze({ transcode: TranscodePolicy.REQUIRED, tonemap: ToneMapping.HABLE, accel: TranscodeHWAccel.DISABLED, + accelDecode: false, }, job: { [QueueName.BACKGROUND_TASK]: { concurrency: 5 }, diff --git a/server/src/dtos/system-config.dto.ts b/server/src/dtos/system-config.dto.ts index 7cf9bb3f8e..4dcea6ec3d 100644 --- a/server/src/dtos/system-config.dto.ts +++ b/server/src/dtos/system-config.dto.ts @@ -132,6 +132,9 @@ export class SystemConfigFFmpegDto { @ApiProperty({ enumName: 'TranscodeHWAccel', enum: TranscodeHWAccel }) accel!: TranscodeHWAccel; + @ValidateBoolean() + accelDecode!: boolean; + @IsEnum(ToneMapping) @ApiProperty({ enumName: 'ToneMapping', enum: ToneMapping }) tonemap!: ToneMapping; diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts index 271987c8f7..044ca764e7 100644 --- a/server/src/services/media.service.spec.ts +++ b/server/src/services/media.service.spec.ts @@ -1335,6 +1335,51 @@ describe(MediaService.name, () => { ); }); + it('should use hardware decoding for nvenc if enabled', async () => { + mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); + systemMock.get.mockResolvedValue({ + ffmpeg: { accel: TranscodeHWAccel.NVENC, accelDecode: true }, + }); + 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.arrayContaining([ + '-hwaccel cuda', + '-hwaccel_output_format cuda', + '-noautorotate', + '-threads 1', + ]), + outputOptions: expect.arrayContaining([expect.stringContaining('scale_cuda=-2:720:format=nv12')]), + twoPass: false, + }, + ); + }); + + it('should use hardware tone-mapping for nvenc if hardware decoding is enabled and should tone map', async () => { + mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); + systemMock.get.mockResolvedValue({ + ffmpeg: { accel: TranscodeHWAccel.NVENC, accelDecode: true }, + }); + 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.arrayContaining(['-hwaccel cuda', '-hwaccel_output_format cuda']), + outputOptions: expect.arrayContaining([ + expect.stringContaining( + 'tonemap_cuda=desat=0:matrix=bt709:primaries=bt709:range=pc:tonemap=hable:transfer=bt709:format=nv12', + ), + ]), + twoPass: false, + }, + ); + }); + it('should set options for qsv', async () => { storageMock.readdir.mockResolvedValue(['renderD128']); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); @@ -1633,8 +1678,9 @@ describe(MediaService.name, () => { it('should set options for rkmpp', async () => { storageMock.readdir.mockResolvedValue(['renderD128']); + storageMock.stat.mockResolvedValue({ ...new Stats(), isFile: () => true, isCharacterDevice: () => true }); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); - systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.RKMPP } }); + systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.RKMPP, accelDecode: true } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -1663,10 +1709,12 @@ describe(MediaService.name, () => { it('should set vbr options for rkmpp when max bitrate is enabled', async () => { storageMock.readdir.mockResolvedValue(['renderD128']); + storageMock.stat.mockResolvedValue({ ...new Stats(), isFile: () => true, isCharacterDevice: () => true }); mediaMock.probe.mockResolvedValue(probeStub.videoStreamVp9); systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.RKMPP, + accelDecode: true, maxBitrate: '10000k', targetVideoCodec: VideoCodec.HEVC, }, @@ -1686,9 +1734,10 @@ describe(MediaService.name, () => { it('should set cqp options for rkmpp when max bitrate is disabled', async () => { storageMock.readdir.mockResolvedValue(['renderD128']); + storageMock.stat.mockResolvedValue({ ...new Stats(), isFile: () => true, isCharacterDevice: () => true }); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); systemMock.get.mockResolvedValue({ - ffmpeg: { accel: TranscodeHWAccel.RKMPP, crf: 30, maxBitrate: '0' }, + ffmpeg: { accel: TranscodeHWAccel.RKMPP, accelDecode: true, crf: 30, maxBitrate: '0' }, }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); @@ -1707,7 +1756,9 @@ describe(MediaService.name, () => { storageMock.readdir.mockResolvedValue(['renderD128']); storageMock.stat.mockResolvedValue({ ...new Stats(), isFile: () => true, isCharacterDevice: () => true }); mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); - systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.RKMPP, crf: 30, maxBitrate: '0' } }); + systemMock.get.mockResolvedValue({ + ffmpeg: { accel: TranscodeHWAccel.RKMPP, accelDecode: true, crf: 30, maxBitrate: '0' }, + }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( @@ -1724,6 +1775,54 @@ describe(MediaService.name, () => { }, ); }); + + it('should use software decoding and tone-mapping if hardware decoding is disabled', async () => { + storageMock.readdir.mockResolvedValue(['renderD128']); + storageMock.stat.mockResolvedValue({ ...new Stats(), isFile: () => true, isCharacterDevice: () => true }); + mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); + systemMock.get.mockResolvedValue({ + ffmpeg: { accel: TranscodeHWAccel.RKMPP, accelDecode: false, crf: 30, maxBitrate: '0' }, + }); + 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: [], + outputOptions: expect.arrayContaining([ + expect.stringContaining( + 'zscale=t=linear:npl=100,tonemap=hable:desat=0,zscale=p=bt709:t=bt709:m=bt709:range=pc,format=yuv420p', + ), + ]), + twoPass: false, + }, + ); + }); + + it('should use software decoding and tone-mapping if opencl is not available', async () => { + storageMock.readdir.mockResolvedValue(['renderD128']); + storageMock.stat.mockResolvedValue({ ...new Stats(), isFile: () => false, isCharacterDevice: () => false }); + mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); + systemMock.get.mockResolvedValue({ + ffmpeg: { accel: TranscodeHWAccel.RKMPP, accelDecode: true, crf: 30, maxBitrate: '0' }, + }); + 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: [], + outputOptions: expect.arrayContaining([ + expect.stringContaining( + '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 required and video is hdr', async () => { diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index 86fe8252ad..dc252b4c1c 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -36,9 +36,11 @@ import { AV1Config, H264Config, HEVCConfig, - NVENCConfig, + NvencHwDecodeConfig, + NvencSwDecodeConfig, QSVConfig, - RKMPPConfig, + RkmppHwDecodeConfig, + RkmppSwDecodeConfig, ThumbnailConfig, VAAPIConfig, VP9Config, @@ -360,8 +362,7 @@ export class MediaService { `Error occurred during transcoding. Retrying with ${config.accel.toUpperCase()} acceleration disabled.`, ); } - config.accel = TranscodeHWAccel.DISABLED; - transcodeOptions = await this.getCodecConfig(config).then((c) => + transcodeOptions = await this.getCodecConfig({ ...config, accel: TranscodeHWAccel.DISABLED }).then((c) => c.getOptions(target, mainVideoStream, mainAudioStream), ); await this.mediaRepository.transcode(input, output, transcodeOptions); @@ -494,7 +495,7 @@ export class MediaService { let handler: VideoCodecHWConfig; switch (config.accel) { case TranscodeHWAccel.NVENC: { - handler = new NVENCConfig(config); + handler = config.accelDecode ? new NvencHwDecodeConfig(config) : new NvencSwDecodeConfig(config); break; } case TranscodeHWAccel.QSV: { @@ -506,7 +507,10 @@ export class MediaService { break; } case TranscodeHWAccel.RKMPP: { - handler = new RKMPPConfig(config, await this.getDevices(), await this.hasOpenCL()); + handler = + config.accelDecode && (await this.hasOpenCL()) + ? new RkmppHwDecodeConfig(config, await this.getDevices()) + : new RkmppSwDecodeConfig(config, await this.getDevices()); break; } default: { diff --git a/server/src/services/system-config.service.spec.ts b/server/src/services/system-config.service.spec.ts index 61ba8df379..d549267925 100644 --- a/server/src/services/system-config.service.spec.ts +++ b/server/src/services/system-config.service.spec.ts @@ -66,6 +66,7 @@ const updatedConfig = Object.freeze({ preferredHwDevice: 'auto', transcode: TranscodePolicy.REQUIRED, accel: TranscodeHWAccel.DISABLED, + accelDecode: false, tonemap: ToneMapping.HABLE, }, logging: { diff --git a/server/src/utils/media.ts b/server/src/utils/media.ts index f43b78464e..6c51c0d303 100644 --- a/server/src/utils/media.ts +++ b/server/src/utils/media.ts @@ -26,14 +26,18 @@ class BaseConfig implements VideoCodecSWConfig { } } - options.outputOptions.push(...this.getPresetOptions(), ...this.getThreadOptions(), ...this.getBitrateOptions()); + options.outputOptions.push( + ...this.getPresetOptions(), + ...this.getOutputThreadOptions(), + ...this.getBitrateOptions(), + ); return options; } // eslint-disable-next-line @typescript-eslint/no-unused-vars getBaseInputOptions(videoStream: VideoStreamInfo): string[] { - return []; + return this.getInputThreadOptions(); } getBaseOutputOptions(target: TranscodeTarget, videoStream: VideoStreamInfo, audioStream?: AudioStreamInfo) { @@ -80,11 +84,7 @@ class BaseConfig implements VideoCodecSWConfig { options.push(`scale=${this.getScaling(videoStream)}`); } - if (this.shouldToneMap(videoStream)) { - options.push(...this.getToneMapping()); - } - options.push('format=yuv420p'); - + options.push(...this.getToneMapping(videoStream), 'format=yuv420p'); return options; } @@ -112,7 +112,11 @@ class BaseConfig implements VideoCodecSWConfig { } } - getThreadOptions(): Array { + getInputThreadOptions(): Array { + return []; + } + + getOutputThreadOptions(): Array { if (this.config.threads <= 0) { return []; } @@ -218,7 +222,11 @@ class BaseConfig implements VideoCodecSWConfig { } } - getToneMapping() { + getToneMapping(videoStream: VideoStreamInfo) { + if (!this.shouldToneMap(videoStream)) { + return []; + } + const colors = this.getColors(); return [ @@ -348,8 +356,8 @@ export class ThumbnailConfig extends BaseConfig { } export class H264Config extends BaseConfig { - getThreadOptions() { - const options = super.getThreadOptions(); + getOutputThreadOptions() { + const options = super.getOutputThreadOptions(); if (this.config.threads === 1) { options.push('-x264-params frame-threads=1:pools=none'); } @@ -359,8 +367,8 @@ export class H264Config extends BaseConfig { } export class HEVCConfig extends BaseConfig { - getThreadOptions() { - const options = super.getThreadOptions(); + getOutputThreadOptions() { + const options = super.getOutputThreadOptions(); if (this.config.threads === 1) { options.push('-x265-params frame-threads=1:pools=none'); } @@ -391,8 +399,8 @@ export class VP9Config extends BaseConfig { return [`-${this.useCQP() ? 'q:v' : 'crf'} ${this.config.crf}`, `-b:v ${bitrates.max}${bitrates.unit}`]; } - getThreadOptions() { - return ['-row-mt 1', ...super.getThreadOptions()]; + getOutputThreadOptions() { + return ['-row-mt 1', ...super.getOutputThreadOptions()]; } eligibleForTwoPass() { @@ -425,7 +433,7 @@ export class AV1Config extends BaseConfig { return options; } - getThreadOptions() { + getOutputThreadOptions() { return []; // Already set above with svtav1-params } @@ -434,7 +442,7 @@ export class AV1Config extends BaseConfig { } } -export class NVENCConfig extends BaseHWConfig { +export class NvencSwDecodeConfig extends BaseHWConfig { getSupportedCodecs() { return [VideoCodec.H264, VideoCodec.HEVC, VideoCodec.AV1]; } @@ -462,7 +470,7 @@ export class NVENCConfig extends BaseHWConfig { } getFilterOptions(videoStream: VideoStreamInfo) { - const options = this.shouldToneMap(videoStream) ? this.getToneMapping() : []; + const options = this.getToneMapping(videoStream); options.push('format=nv12', 'hwupload_cuda'); if (this.shouldScale(videoStream)) { options.push(`scale_cuda=${this.getScaling(videoStream)}`); @@ -513,6 +521,52 @@ export class NVENCConfig extends BaseHWConfig { } } +export class NvencHwDecodeConfig extends NvencSwDecodeConfig { + getBaseInputOptions() { + return ['-hwaccel cuda', '-hwaccel_output_format cuda', '-noautorotate', ...this.getInputThreadOptions()]; + } + + getFilterOptions(videoStream: VideoStreamInfo) { + const options = []; + if (this.shouldScale(videoStream)) { + options.push(`scale_cuda=${this.getScaling(videoStream)}`); + } + options.push(...this.getToneMapping(videoStream)); + if (options.length > 0) { + options[options.length - 1] += ':format=nv12'; + } else { + options.push('format=nv12'); + } + return options; + } + + getToneMapping(videoStream: VideoStreamInfo) { + if (!this.shouldToneMap(videoStream)) { + return []; + } + + const colors = this.getColors(); + const tonemapOptions = [ + 'desat=0', + `matrix=${colors.matrix}`, + `primaries=${colors.primaries}`, + 'range=pc', + `tonemap=${this.config.tonemap}`, + `transfer=${colors.transfer}`, + ]; + + return [`tonemap_cuda=${tonemapOptions.join(':')}`]; + } + + getInputThreadOptions() { + return [`-threads ${this.config.threads <= 0 ? 1 : this.config.threads}`]; + } + + getOutputThreadOptions() { + return []; + } +} + export class QSVConfig extends BaseHWConfig { getBaseInputOptions() { if (this.devices.length === 0) { @@ -538,7 +592,7 @@ export class QSVConfig extends BaseHWConfig { } getFilterOptions(videoStream: VideoStreamInfo) { - const options = this.shouldToneMap(videoStream) ? this.getToneMapping() : []; + const options = this.getToneMapping(videoStream); options.push('format=nv12', 'hwupload=extra_hw_frames=64'); if (this.shouldScale(videoStream)) { options.push(`scale_qsv=${this.getScaling(videoStream)}`); @@ -604,7 +658,7 @@ export class VAAPIConfig extends BaseHWConfig { } getFilterOptions(videoStream: VideoStreamInfo) { - const options = this.shouldToneMap(videoStream) ? this.getToneMapping() : []; + const options = this.getToneMapping(videoStream); options.push('format=nv12', 'hwupload'); if (this.shouldScale(videoStream)) { options.push(`scale_vaapi=${this.getScaling(videoStream)}`); @@ -656,47 +710,22 @@ export class VAAPIConfig extends BaseHWConfig { } } -export class RKMPPConfig extends BaseHWConfig { - private hasOpenCL: boolean; - +export class RkmppSwDecodeConfig extends BaseHWConfig { constructor( protected config: SystemConfigFFmpegDto, devices: string[] = [], - hasOpenCL: boolean = false, ) { super(config, devices); - this.hasOpenCL = hasOpenCL; } eligibleForTwoPass(): boolean { return false; } - getBaseInputOptions(videoStream: VideoStreamInfo) { + getBaseInputOptions(): string[] { if (this.devices.length === 0) { throw new Error('No RKMPP device found'); } - return this.shouldToneMap(videoStream) && !this.hasOpenCL - ? [] // disable hardware decoding & filters - : ['-hwaccel rkmpp', '-hwaccel_output_format drm_prime', '-afbc rga']; - } - - getFilterOptions(videoStream: VideoStreamInfo) { - if (this.shouldToneMap(videoStream)) { - if (!this.hasOpenCL) { - return super.getFilterOptions(videoStream); - } - const colors = this.getColors(); - return [ - `scale_rkrga=${this.getScaling(videoStream)}:format=p010:afbc=1`, - 'hwmap=derive_device=opencl:mode=read', - `tonemap_opencl=format=nv12:r=pc:p=${colors.primaries}:t=${colors.transfer}:m=${colors.matrix}:tonemap=${this.config.tonemap}:desat=0`, - 'hwmap=derive_device=rkmpp:mode=write:reverse=1', - 'format=drm_prime', - ]; - } else if (this.shouldScale(videoStream)) { - return [`scale_rkrga=${this.getScaling(videoStream)}:format=nv12:afbc=1`]; - } return []; } @@ -734,3 +763,29 @@ export class RKMPPConfig extends BaseHWConfig { return `${this.config.targetVideoCodec}_rkmpp`; } } + +export class RkmppHwDecodeConfig extends RkmppSwDecodeConfig { + getBaseInputOptions() { + if (this.devices.length === 0) { + throw new Error('No RKMPP device found'); + } + + return ['-hwaccel rkmpp', '-hwaccel_output_format drm_prime', '-afbc rga']; + } + + getFilterOptions(videoStream: VideoStreamInfo) { + if (this.shouldToneMap(videoStream)) { + const colors = this.getColors(); + return [ + `scale_rkrga=${this.getScaling(videoStream)}:format=p010:afbc=1`, + 'hwmap=derive_device=opencl:mode=read', + `tonemap_opencl=format=nv12:r=pc:p=${colors.primaries}:t=${colors.transfer}:m=${colors.matrix}:tonemap=${this.config.tonemap}:desat=0`, + 'hwmap=derive_device=rkmpp:mode=write:reverse=1', + 'format=drm_prime', + ]; + } else if (this.shouldScale(videoStream)) { + return [`scale_rkrga=${this.getScaling(videoStream)}:format=nv12:afbc=1`]; + } + return []; + } +} 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 bc91c2c993..e194b48ab9 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 @@ -276,6 +276,15 @@ isEdited={config.ffmpeg.accel !== savedConfig.ffmpeg.accel} /> + + + Date: Thu, 16 May 2024 17:24:54 -0400 Subject: [PATCH 075/163] fix(server): use jasonnnnnnnnnb (#9539) --- server/src/entities/system-metadata.entity.ts | 2 +- .../src/migrations/1715890481637-FixJsonB.ts | 24 +++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) create mode 100644 server/src/migrations/1715890481637-FixJsonB.ts diff --git a/server/src/entities/system-metadata.entity.ts b/server/src/entities/system-metadata.entity.ts index b702d2606d..fcbc66edae 100644 --- a/server/src/entities/system-metadata.entity.ts +++ b/server/src/entities/system-metadata.entity.ts @@ -6,7 +6,7 @@ export class SystemMetadataEntity { + await queryRunner.query(`ALTER TABLE "system_metadata" ALTER COLUMN "value" DROP DEFAULT`); + const records = await queryRunner.query('SELECT "key", "value" FROM "system_metadata"'); + for (const { key, value } of records) { + await queryRunner.query(`UPDATE "system_metadata" SET "value" = $1 WHERE "key" = $2`, [value, key]); + } + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "system_metadata" ALTER COLUMN "value" SET DEFAULT '{}'`); + const records = await queryRunner.query('SELECT "key", "value" FROM "system_metadata"'); + for (const { key, value } of records) { + await queryRunner.query(`UPDATE "system_metadata" SET "value" = $1 WHERE "key" = $2`, [ + JSON.stringify(JSON.stringify(value)), + key, + ]); + } + } +} From ff5230062490a000d85dced1559a6434bb96327b Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Thu, 16 May 2024 19:39:33 -0400 Subject: [PATCH 076/163] refactor(server): duplicate controller and service (#9542) * duplicate controller and service * change endpoint name * fix search tests * remove unused import * add to index --- server/src/controllers/asset.controller.ts | 5 - .../src/controllers/duplicate.controller.ts | 18 ++ server/src/services/asset.service.ts | 5 - server/src/services/duplicate.service.spec.ts | 269 ++++++++++++++++++ server/src/services/duplicate.service.ts | 133 +++++++++ server/src/services/index.ts | 2 + server/src/services/microservices.service.ts | 8 +- server/src/services/search.service.spec.ts | 242 +--------------- server/src/services/search.service.ts | 109 +------ 9 files changed, 430 insertions(+), 361 deletions(-) create mode 100644 server/src/controllers/duplicate.controller.ts create mode 100644 server/src/services/duplicate.service.spec.ts create mode 100644 server/src/services/duplicate.service.ts diff --git a/server/src/controllers/asset.controller.ts b/server/src/controllers/asset.controller.ts index 7e51f17b59..f2d076e17b 100644 --- a/server/src/controllers/asset.controller.ts +++ b/server/src/controllers/asset.controller.ts @@ -57,11 +57,6 @@ export class AssetController { return this.service.getStatistics(auth, dto); } - @Get('duplicates') - getAssetDuplicates(@Auth() auth: AuthDto): Promise { - return this.service.getDuplicates(auth); - } - @Post('jobs') @HttpCode(HttpStatus.NO_CONTENT) @Authenticated() diff --git a/server/src/controllers/duplicate.controller.ts b/server/src/controllers/duplicate.controller.ts new file mode 100644 index 0000000000..ecabc0ee74 --- /dev/null +++ b/server/src/controllers/duplicate.controller.ts @@ -0,0 +1,18 @@ +import { Controller, Get } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { AssetResponseDto } from 'src/dtos/asset-response.dto'; +import { AuthDto } from 'src/dtos/auth.dto'; +import { Auth, Authenticated } from 'src/middleware/auth.guard'; +import { DuplicateService } from 'src/services/duplicate.service'; + +@ApiTags('Duplicate') +@Controller('duplicates') +export class DuplicateController { + constructor(private service: DuplicateService) {} + + @Get() + @Authenticated() + getAssetDuplicates(@Auth() auth: AuthDto): Promise { + return this.service.getDuplicates(auth); + } +} diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index a0cbf40278..d266b1ed2f 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -286,11 +286,6 @@ export class AssetService { return data; } - async getDuplicates(auth: AuthDto): Promise { - const res = await this.assetRepository.getDuplicates({ userIds: [auth.user.id] }); - return res.map((a) => mapAsset(a, { auth })); - } - async update(auth: AuthDto, id: string, dto: UpdateAssetDto): Promise { await this.access.requirePermission(auth, Permission.ASSET_UPDATE, id); diff --git a/server/src/services/duplicate.service.spec.ts b/server/src/services/duplicate.service.spec.ts new file mode 100644 index 0000000000..4560d9024c --- /dev/null +++ b/server/src/services/duplicate.service.spec.ts @@ -0,0 +1,269 @@ +import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; +import { ICryptoRepository } from 'src/interfaces/crypto.interface'; +import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; +import { ILoggerRepository } from 'src/interfaces/logger.interface'; +import { ISearchRepository } from 'src/interfaces/search.interface'; +import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +import { DuplicateService } from 'src/services/duplicate.service'; +import { SearchService } from 'src/services/search.service'; +import { assetStub } from 'test/fixtures/asset.stub'; +import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock'; +import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock'; +import { newJobRepositoryMock } from 'test/repositories/job.repository.mock'; +import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; +import { newSearchRepositoryMock } from 'test/repositories/search.repository.mock'; +import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; +import { Mocked, beforeEach, vitest } from 'vitest'; + +vitest.useFakeTimers(); + +describe(SearchService.name, () => { + let sut: DuplicateService; + let assetMock: Mocked; + let systemMock: Mocked; + let searchMock: Mocked; + let loggerMock: Mocked; + let cryptoMock: Mocked; + let jobMock: Mocked; + + beforeEach(() => { + assetMock = newAssetRepositoryMock(); + systemMock = newSystemMetadataRepositoryMock(); + searchMock = newSearchRepositoryMock(); + loggerMock = newLoggerRepositoryMock(); + cryptoMock = newCryptoRepositoryMock(); + jobMock = newJobRepositoryMock(); + + sut = new DuplicateService(systemMock, searchMock, assetMock, loggerMock, cryptoMock, jobMock); + }); + + it('should work', () => { + expect(sut).toBeDefined(); + }); + + describe('handleQueueSearchDuplicates', () => { + beforeEach(() => { + systemMock.get.mockResolvedValue({ + machineLearning: { + enabled: true, + duplicateDetection: { + enabled: true, + }, + }, + }); + }); + + it('should skip if machine learning is disabled', async () => { + systemMock.get.mockResolvedValue({ + machineLearning: { + enabled: false, + duplicateDetection: { + enabled: true, + }, + }, + }); + + await expect(sut.handleQueueSearchDuplicates({})).resolves.toBe(JobStatus.SKIPPED); + expect(jobMock.queue).not.toHaveBeenCalled(); + expect(jobMock.queueAll).not.toHaveBeenCalled(); + expect(systemMock.get).toHaveBeenCalled(); + }); + + it('should skip if duplicate detection is disabled', async () => { + systemMock.get.mockResolvedValue({ + machineLearning: { + enabled: true, + duplicateDetection: { + enabled: false, + }, + }, + }); + + await expect(sut.handleQueueSearchDuplicates({})).resolves.toBe(JobStatus.SKIPPED); + expect(jobMock.queue).not.toHaveBeenCalled(); + expect(jobMock.queueAll).not.toHaveBeenCalled(); + expect(systemMock.get).toHaveBeenCalled(); + }); + + it('should queue missing assets', async () => { + assetMock.getWithout.mockResolvedValue({ + items: [assetStub.image], + hasNextPage: false, + }); + + await sut.handleQueueSearchDuplicates({}); + + expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.DUPLICATE); + expect(jobMock.queueAll).toHaveBeenCalledWith([ + { + name: JobName.DUPLICATE_DETECTION, + data: { id: assetStub.image.id }, + }, + ]); + }); + + it('should queue all assets', async () => { + assetMock.getAll.mockResolvedValue({ + items: [assetStub.image], + hasNextPage: false, + }); + + await sut.handleQueueSearchDuplicates({ force: true }); + + expect(assetMock.getAll).toHaveBeenCalled(); + expect(jobMock.queueAll).toHaveBeenCalledWith([ + { + name: JobName.DUPLICATE_DETECTION, + data: { id: assetStub.image.id }, + }, + ]); + }); + }); + + describe('handleSearchDuplicates', () => { + beforeEach(() => { + systemMock.get.mockResolvedValue({ + machineLearning: { + enabled: true, + duplicateDetection: { + enabled: true, + }, + }, + }); + }); + + it('should skip if machine learning is disabled', async () => { + systemMock.get.mockResolvedValue({ + machineLearning: { + enabled: false, + duplicateDetection: { + enabled: true, + }, + }, + }); + const id = assetStub.livePhotoMotionAsset.id; + assetMock.getById.mockResolvedValue(assetStub.livePhotoMotionAsset); + + const result = await sut.handleSearchDuplicates({ id }); + + expect(result).toBe(JobStatus.SKIPPED); + }); + + it('should skip if duplicate detection is disabled', async () => { + systemMock.get.mockResolvedValue({ + machineLearning: { + enabled: true, + duplicateDetection: { + enabled: false, + }, + }, + }); + const id = assetStub.livePhotoMotionAsset.id; + assetMock.getById.mockResolvedValue(assetStub.livePhotoMotionAsset); + + const result = await sut.handleSearchDuplicates({ id }); + + expect(result).toBe(JobStatus.SKIPPED); + }); + + it('should fail if asset is not found', async () => { + const result = await sut.handleSearchDuplicates({ id: assetStub.image.id }); + + expect(result).toBe(JobStatus.FAILED); + expect(loggerMock.error).toHaveBeenCalledWith(`Asset ${assetStub.image.id} not found`); + }); + + it('should skip if asset is not visible', async () => { + const id = assetStub.livePhotoMotionAsset.id; + assetMock.getById.mockResolvedValue(assetStub.livePhotoMotionAsset); + + const result = await sut.handleSearchDuplicates({ id }); + + expect(result).toBe(JobStatus.SKIPPED); + expect(loggerMock.debug).toHaveBeenCalledWith(`Asset ${id} is not visible, skipping`); + }); + + it('should fail if asset is missing preview image', async () => { + assetMock.getById.mockResolvedValue(assetStub.noResizePath); + + const result = await sut.handleSearchDuplicates({ id: assetStub.noResizePath.id }); + + expect(result).toBe(JobStatus.FAILED); + expect(loggerMock.warn).toHaveBeenCalledWith(`Asset ${assetStub.noResizePath.id} is missing preview image`); + }); + + it('should fail if asset is missing embedding', async () => { + assetMock.getById.mockResolvedValue(assetStub.image); + + const result = await sut.handleSearchDuplicates({ id: assetStub.image.id }); + + expect(result).toBe(JobStatus.FAILED); + expect(loggerMock.debug).toHaveBeenCalledWith(`Asset ${assetStub.image.id} is missing embedding`); + }); + + it('should search for duplicates and update asset with duplicateId', async () => { + assetMock.getById.mockResolvedValue(assetStub.hasEmbedding); + searchMock.searchDuplicates.mockResolvedValue([ + { assetId: assetStub.image.id, distance: 0.01, duplicateId: null }, + ]); + const expectedAssetIds = [assetStub.image.id, assetStub.hasEmbedding.id]; + + const result = await sut.handleSearchDuplicates({ id: assetStub.hasEmbedding.id }); + + expect(result).toBe(JobStatus.SUCCESS); + expect(searchMock.searchDuplicates).toHaveBeenCalledWith({ + assetId: assetStub.hasEmbedding.id, + embedding: assetStub.hasEmbedding.smartSearch!.embedding, + maxDistance: 0.03, + userIds: [assetStub.hasEmbedding.ownerId], + }); + expect(assetMock.updateDuplicates).toHaveBeenCalledWith({ + assetIds: expectedAssetIds, + targetDuplicateId: expect.any(String), + duplicateIds: [], + }); + expect(assetMock.upsertJobStatus).toHaveBeenCalledWith( + ...expectedAssetIds.map((assetId) => ({ assetId, duplicatesDetectedAt: expect.any(Date) })), + ); + }); + + it('should use existing duplicate ID among matched duplicates', async () => { + const duplicateId = assetStub.hasDupe.duplicateId; + assetMock.getById.mockResolvedValue(assetStub.hasEmbedding); + searchMock.searchDuplicates.mockResolvedValue([{ assetId: assetStub.hasDupe.id, distance: 0.01, duplicateId }]); + const expectedAssetIds = [assetStub.hasEmbedding.id]; + + const result = await sut.handleSearchDuplicates({ id: assetStub.hasEmbedding.id }); + + expect(result).toBe(JobStatus.SUCCESS); + expect(searchMock.searchDuplicates).toHaveBeenCalledWith({ + assetId: assetStub.hasEmbedding.id, + embedding: assetStub.hasEmbedding.smartSearch!.embedding, + maxDistance: 0.03, + userIds: [assetStub.hasEmbedding.ownerId], + }); + expect(assetMock.updateDuplicates).toHaveBeenCalledWith({ + assetIds: expectedAssetIds, + targetDuplicateId: assetStub.hasDupe.duplicateId, + duplicateIds: [], + }); + expect(assetMock.upsertJobStatus).toHaveBeenCalledWith( + ...expectedAssetIds.map((assetId) => ({ assetId, duplicatesDetectedAt: expect.any(Date) })), + ); + }); + + it('should remove duplicateId if no duplicates found and asset has duplicateId', async () => { + assetMock.getById.mockResolvedValue(assetStub.hasDupe); + searchMock.searchDuplicates.mockResolvedValue([]); + + const result = await sut.handleSearchDuplicates({ id: assetStub.hasDupe.id }); + + expect(result).toBe(JobStatus.SUCCESS); + expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.hasDupe.id, duplicateId: null }); + expect(assetMock.upsertJobStatus).toHaveBeenCalledWith({ + assetId: assetStub.hasDupe.id, + duplicatesDetectedAt: expect.any(Date), + }); + }); + }); +}); diff --git a/server/src/services/duplicate.service.ts b/server/src/services/duplicate.service.ts new file mode 100644 index 0000000000..a01e1b866a --- /dev/null +++ b/server/src/services/duplicate.service.ts @@ -0,0 +1,133 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { SystemConfigCore } from 'src/cores/system-config.core'; +import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto'; +import { AuthDto } from 'src/dtos/auth.dto'; +import { AssetEntity } from 'src/entities/asset.entity'; +import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; +import { ICryptoRepository } from 'src/interfaces/crypto.interface'; +import { + IBaseJob, + IEntityJob, + IJobRepository, + JOBS_ASSET_PAGINATION_SIZE, + JobName, + JobStatus, +} from 'src/interfaces/job.interface'; +import { ILoggerRepository } from 'src/interfaces/logger.interface'; +import { AssetDuplicateResult, ISearchRepository } from 'src/interfaces/search.interface'; +import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +import { isDuplicateDetectionEnabled } from 'src/utils/misc'; +import { usePagination } from 'src/utils/pagination'; + +@Injectable() +export class DuplicateService { + private configCore: SystemConfigCore; + + constructor( + @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, + @Inject(ISearchRepository) private searchRepository: ISearchRepository, + @Inject(IAssetRepository) private assetRepository: IAssetRepository, + @Inject(ILoggerRepository) private logger: ILoggerRepository, + @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository, + @Inject(IJobRepository) private jobRepository: IJobRepository, + ) { + this.logger.setContext(DuplicateService.name); + this.configCore = SystemConfigCore.create(systemMetadataRepository, logger); + } + + async getDuplicates(auth: AuthDto): Promise { + const res = await this.assetRepository.getDuplicates({ userIds: [auth.user.id] }); + return res.map((a) => mapAsset(a, { auth })); + } + + async handleQueueSearchDuplicates({ force }: IBaseJob): Promise { + const { machineLearning } = await this.configCore.getConfig(); + if (!isDuplicateDetectionEnabled(machineLearning)) { + return JobStatus.SKIPPED; + } + + const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => { + return force + ? this.assetRepository.getAll(pagination, { isVisible: true }) + : this.assetRepository.getWithout(pagination, WithoutProperty.DUPLICATE); + }); + + for await (const assets of assetPagination) { + await this.jobRepository.queueAll( + assets.map((asset) => ({ name: JobName.DUPLICATE_DETECTION, data: { id: asset.id } })), + ); + } + + return JobStatus.SUCCESS; + } + + async handleSearchDuplicates({ id }: IEntityJob): Promise { + const { machineLearning } = await this.configCore.getConfig(); + if (!isDuplicateDetectionEnabled(machineLearning)) { + return JobStatus.SKIPPED; + } + + const asset = await this.assetRepository.getById(id, { smartSearch: true }); + if (!asset) { + this.logger.error(`Asset ${id} not found`); + return JobStatus.FAILED; + } + + if (!asset.isVisible) { + this.logger.debug(`Asset ${id} is not visible, skipping`); + return JobStatus.SKIPPED; + } + + if (!asset.previewPath) { + this.logger.warn(`Asset ${id} is missing preview image`); + return JobStatus.FAILED; + } + + if (!asset.smartSearch?.embedding) { + this.logger.debug(`Asset ${id} is missing embedding`); + return JobStatus.FAILED; + } + + const duplicateAssets = await this.searchRepository.searchDuplicates({ + assetId: asset.id, + embedding: asset.smartSearch.embedding, + maxDistance: machineLearning.duplicateDetection.maxDistance, + userIds: [asset.ownerId], + }); + + let assetIds = [asset.id]; + if (duplicateAssets.length > 0) { + this.logger.debug( + `Found ${duplicateAssets.length} duplicate${duplicateAssets.length === 1 ? '' : 's'} for asset ${asset.id}`, + ); + assetIds = await this.updateDuplicates(asset, duplicateAssets); + } else if (asset.duplicateId) { + this.logger.debug(`No duplicates found for asset ${asset.id}, removing duplicateId`); + await this.assetRepository.update({ id: asset.id, duplicateId: null }); + } + + const duplicatesDetectedAt = new Date(); + await this.assetRepository.upsertJobStatus(...assetIds.map((assetId) => ({ assetId, duplicatesDetectedAt }))); + + return JobStatus.SUCCESS; + } + + private async updateDuplicates(asset: AssetEntity, duplicateAssets: AssetDuplicateResult[]): Promise { + const duplicateIds = [ + ...new Set( + duplicateAssets + .filter((asset): asset is AssetDuplicateResult & { duplicateId: string } => !!asset.duplicateId) + .map((duplicate) => duplicate.duplicateId), + ), + ]; + + const targetDuplicateId = asset.duplicateId ?? duplicateIds.shift() ?? this.cryptoRepository.randomUUID(); + const assetIdsToUpdate = duplicateAssets + .filter((asset) => asset.duplicateId !== targetDuplicateId) + .map((duplicate) => duplicate.assetId); + assetIdsToUpdate.push(asset.id); + + await this.assetRepository.updateDuplicates({ targetDuplicateId, assetIds: assetIdsToUpdate, duplicateIds }); + return assetIdsToUpdate; + } +} diff --git a/server/src/services/index.ts b/server/src/services/index.ts index 95f048ba3c..f130da2349 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -8,6 +8,7 @@ import { AuditService } from 'src/services/audit.service'; import { AuthService } from 'src/services/auth.service'; import { DatabaseService } from 'src/services/database.service'; import { DownloadService } from 'src/services/download.service'; +import { DuplicateService } from 'src/services/duplicate.service'; import { JobService } from 'src/services/job.service'; import { LibraryService } from 'src/services/library.service'; import { MediaService } from 'src/services/media.service'; @@ -44,6 +45,7 @@ export const services = [ AuthService, DatabaseService, DownloadService, + DuplicateService, JobService, LibraryService, MediaService, diff --git a/server/src/services/microservices.service.ts b/server/src/services/microservices.service.ts index 24acf6b978..2c8302fb1d 100644 --- a/server/src/services/microservices.service.ts +++ b/server/src/services/microservices.service.ts @@ -3,13 +3,13 @@ import { IDeleteFilesJob, JobName } from 'src/interfaces/job.interface'; import { AssetService } from 'src/services/asset.service'; import { AuditService } from 'src/services/audit.service'; import { DatabaseService } from 'src/services/database.service'; +import { DuplicateService } from 'src/services/duplicate.service'; import { JobService } from 'src/services/job.service'; import { LibraryService } from 'src/services/library.service'; import { MediaService } from 'src/services/media.service'; import { MetadataService } from 'src/services/metadata.service'; import { NotificationService } from 'src/services/notification.service'; import { PersonService } from 'src/services/person.service'; -import { SearchService } from 'src/services/search.service'; import { SessionService } from 'src/services/session.service'; import { SmartInfoService } from 'src/services/smart-info.service'; import { StorageTemplateService } from 'src/services/storage-template.service'; @@ -36,7 +36,7 @@ export class MicroservicesService { private storageTemplateService: StorageTemplateService, private storageService: StorageService, private userService: UserService, - private searchService: SearchService, + private duplicateService: DuplicateService, ) {} async init() { @@ -55,8 +55,8 @@ export class MicroservicesService { [JobName.USER_SYNC_USAGE]: () => this.userService.handleUserSyncUsage(), [JobName.QUEUE_SMART_SEARCH]: (data) => this.smartInfoService.handleQueueEncodeClip(data), [JobName.SMART_SEARCH]: (data) => this.smartInfoService.handleEncodeClip(data), - [JobName.QUEUE_DUPLICATE_DETECTION]: (data) => this.searchService.handleQueueSearchDuplicates(data), - [JobName.DUPLICATE_DETECTION]: (data) => this.searchService.handleSearchDuplicates(data), + [JobName.QUEUE_DUPLICATE_DETECTION]: (data) => this.duplicateService.handleQueueSearchDuplicates(data), + [JobName.DUPLICATE_DETECTION]: (data) => this.duplicateService.handleSearchDuplicates(data), [JobName.STORAGE_TEMPLATE_MIGRATION]: () => this.storageTemplateService.handleMigration(), [JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE]: (data) => this.storageTemplateService.handleMigrationSingle(data), [JobName.QUEUE_MIGRATION]: () => this.mediaService.handleQueueMigration(), diff --git a/server/src/services/search.service.spec.ts b/server/src/services/search.service.spec.ts index dac6af2cf8..afc98b69de 100644 --- a/server/src/services/search.service.spec.ts +++ b/server/src/services/search.service.spec.ts @@ -1,7 +1,5 @@ import { mapAsset } from 'src/dtos/asset-response.dto'; -import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; -import { ICryptoRepository } from 'src/interfaces/crypto.interface'; -import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; +import { IAssetRepository } from 'src/interfaces/asset.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IMachineLearningRepository } from 'src/interfaces/machine-learning.interface'; import { IMetadataRepository } from 'src/interfaces/metadata.interface'; @@ -14,8 +12,6 @@ import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; import { personStub } from 'test/fixtures/person.stub'; import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock'; -import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock'; -import { newJobRepositoryMock } from 'test/repositories/job.repository.mock'; import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; import { newMachineLearningRepositoryMock } from 'test/repositories/machine-learning.repository.mock'; import { newMetadataRepositoryMock } from 'test/repositories/metadata.repository.mock'; @@ -37,8 +33,6 @@ describe(SearchService.name, () => { let partnerMock: Mocked; let metadataMock: Mocked; let loggerMock: Mocked; - let cryptoMock: Mocked; - let jobMock: Mocked; beforeEach(() => { assetMock = newAssetRepositoryMock(); @@ -49,8 +43,6 @@ describe(SearchService.name, () => { partnerMock = newPartnerRepositoryMock(); metadataMock = newMetadataRepositoryMock(); loggerMock = newLoggerRepositoryMock(); - cryptoMock = newCryptoRepositoryMock(); - jobMock = newJobRepositoryMock(); sut = new SearchService( systemMock, @@ -61,8 +53,6 @@ describe(SearchService.name, () => { partnerMock, metadataMock, loggerMock, - cryptoMock, - jobMock, ); }); @@ -105,234 +95,4 @@ describe(SearchService.name, () => { expect(result).toEqual(expectedResponse); }); }); - - describe('handleQueueSearchDuplicates', () => { - beforeEach(() => { - systemMock.get.mockResolvedValue({ - machineLearning: { - enabled: true, - duplicateDetection: { - enabled: true, - }, - }, - }); - }); - - it('should skip if machine learning is disabled', async () => { - systemMock.get.mockResolvedValue({ - machineLearning: { - enabled: false, - duplicateDetection: { - enabled: true, - }, - }, - }); - - await expect(sut.handleQueueSearchDuplicates({})).resolves.toBe(JobStatus.SKIPPED); - expect(jobMock.queue).not.toHaveBeenCalled(); - expect(jobMock.queueAll).not.toHaveBeenCalled(); - expect(systemMock.get).toHaveBeenCalled(); - }); - - it('should skip if duplicate detection is disabled', async () => { - systemMock.get.mockResolvedValue({ - machineLearning: { - enabled: true, - duplicateDetection: { - enabled: false, - }, - }, - }); - - await expect(sut.handleQueueSearchDuplicates({})).resolves.toBe(JobStatus.SKIPPED); - expect(jobMock.queue).not.toHaveBeenCalled(); - expect(jobMock.queueAll).not.toHaveBeenCalled(); - expect(systemMock.get).toHaveBeenCalled(); - }); - - it('should queue missing assets', async () => { - assetMock.getWithout.mockResolvedValue({ - items: [assetStub.image], - hasNextPage: false, - }); - - await sut.handleQueueSearchDuplicates({}); - - expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.DUPLICATE); - expect(jobMock.queueAll).toHaveBeenCalledWith([ - { - name: JobName.DUPLICATE_DETECTION, - data: { id: assetStub.image.id }, - }, - ]); - }); - - it('should queue all assets', async () => { - assetMock.getAll.mockResolvedValue({ - items: [assetStub.image], - hasNextPage: false, - }); - personMock.getAll.mockResolvedValue({ - items: [personStub.withName], - hasNextPage: false, - }); - - await sut.handleQueueSearchDuplicates({ force: true }); - - expect(assetMock.getAll).toHaveBeenCalled(); - expect(jobMock.queueAll).toHaveBeenCalledWith([ - { - name: JobName.DUPLICATE_DETECTION, - data: { id: assetStub.image.id }, - }, - ]); - }); - }); - - describe('handleSearchDuplicates', () => { - beforeEach(() => { - systemMock.get.mockResolvedValue({ - machineLearning: { - enabled: true, - duplicateDetection: { - enabled: true, - }, - }, - }); - }); - - it('should skip if machine learning is disabled', async () => { - systemMock.get.mockResolvedValue({ - machineLearning: { - enabled: false, - duplicateDetection: { - enabled: true, - }, - }, - }); - const id = assetStub.livePhotoMotionAsset.id; - assetMock.getById.mockResolvedValue(assetStub.livePhotoMotionAsset); - - const result = await sut.handleSearchDuplicates({ id }); - - expect(result).toBe(JobStatus.SKIPPED); - }); - - it('should skip if duplicate detection is disabled', async () => { - systemMock.get.mockResolvedValue({ - machineLearning: { - enabled: true, - duplicateDetection: { - enabled: false, - }, - }, - }); - const id = assetStub.livePhotoMotionAsset.id; - assetMock.getById.mockResolvedValue(assetStub.livePhotoMotionAsset); - - const result = await sut.handleSearchDuplicates({ id }); - - expect(result).toBe(JobStatus.SKIPPED); - }); - - it('should fail if asset is not found', async () => { - const result = await sut.handleSearchDuplicates({ id: assetStub.image.id }); - - expect(result).toBe(JobStatus.FAILED); - expect(loggerMock.error).toHaveBeenCalledWith(`Asset ${assetStub.image.id} not found`); - }); - - it('should skip if asset is not visible', async () => { - const id = assetStub.livePhotoMotionAsset.id; - assetMock.getById.mockResolvedValue(assetStub.livePhotoMotionAsset); - - const result = await sut.handleSearchDuplicates({ id }); - - expect(result).toBe(JobStatus.SKIPPED); - expect(loggerMock.debug).toHaveBeenCalledWith(`Asset ${id} is not visible, skipping`); - }); - - it('should fail if asset is missing preview image', async () => { - assetMock.getById.mockResolvedValue(assetStub.noResizePath); - - const result = await sut.handleSearchDuplicates({ id: assetStub.noResizePath.id }); - - expect(result).toBe(JobStatus.FAILED); - expect(loggerMock.warn).toHaveBeenCalledWith(`Asset ${assetStub.noResizePath.id} is missing preview image`); - }); - - it('should fail if asset is missing embedding', async () => { - assetMock.getById.mockResolvedValue(assetStub.image); - - const result = await sut.handleSearchDuplicates({ id: assetStub.image.id }); - - expect(result).toBe(JobStatus.FAILED); - expect(loggerMock.debug).toHaveBeenCalledWith(`Asset ${assetStub.image.id} is missing embedding`); - }); - - it('should search for duplicates and update asset with duplicateId', async () => { - assetMock.getById.mockResolvedValue(assetStub.hasEmbedding); - searchMock.searchDuplicates.mockResolvedValue([ - { assetId: assetStub.image.id, distance: 0.01, duplicateId: null }, - ]); - const expectedAssetIds = [assetStub.image.id, assetStub.hasEmbedding.id]; - - const result = await sut.handleSearchDuplicates({ id: assetStub.hasEmbedding.id }); - - expect(result).toBe(JobStatus.SUCCESS); - expect(searchMock.searchDuplicates).toHaveBeenCalledWith({ - assetId: assetStub.hasEmbedding.id, - embedding: assetStub.hasEmbedding.smartSearch!.embedding, - maxDistance: 0.03, - userIds: [assetStub.hasEmbedding.ownerId], - }); - expect(assetMock.updateDuplicates).toHaveBeenCalledWith({ - assetIds: expectedAssetIds, - targetDuplicateId: expect.any(String), - duplicateIds: [], - }); - expect(assetMock.upsertJobStatus).toHaveBeenCalledWith( - ...expectedAssetIds.map((assetId) => ({ assetId, duplicatesDetectedAt: expect.any(Date) })), - ); - }); - - it('should use existing duplicate ID among matched duplicates', async () => { - const duplicateId = assetStub.hasDupe.duplicateId; - assetMock.getById.mockResolvedValue(assetStub.hasEmbedding); - searchMock.searchDuplicates.mockResolvedValue([{ assetId: assetStub.hasDupe.id, distance: 0.01, duplicateId }]); - const expectedAssetIds = [assetStub.hasEmbedding.id]; - - const result = await sut.handleSearchDuplicates({ id: assetStub.hasEmbedding.id }); - - expect(result).toBe(JobStatus.SUCCESS); - expect(searchMock.searchDuplicates).toHaveBeenCalledWith({ - assetId: assetStub.hasEmbedding.id, - embedding: assetStub.hasEmbedding.smartSearch!.embedding, - maxDistance: 0.03, - userIds: [assetStub.hasEmbedding.ownerId], - }); - expect(assetMock.updateDuplicates).toHaveBeenCalledWith({ - assetIds: expectedAssetIds, - targetDuplicateId: assetStub.hasDupe.duplicateId, - duplicateIds: [], - }); - expect(assetMock.upsertJobStatus).toHaveBeenCalledWith( - ...expectedAssetIds.map((assetId) => ({ assetId, duplicatesDetectedAt: expect.any(Date) })), - ); - }); - - it('should remove duplicateId if no duplicates found and asset has duplicateId', async () => { - assetMock.getById.mockResolvedValue(assetStub.hasDupe); - searchMock.searchDuplicates.mockResolvedValue([]); - - const result = await sut.handleSearchDuplicates({ id: assetStub.hasDupe.id }); - - expect(result).toBe(JobStatus.SUCCESS); - expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.hasDupe.id, duplicateId: null }); - expect(assetMock.upsertJobStatus).toHaveBeenCalledWith({ - assetId: assetStub.hasDupe.id, - duplicatesDetectedAt: expect.any(Date), - }); - }); - }); }); diff --git a/server/src/services/search.service.ts b/server/src/services/search.service.ts index 28f9b9713e..10a2ccda2a 100644 --- a/server/src/services/search.service.ts +++ b/server/src/services/search.service.ts @@ -16,25 +16,15 @@ import { } from 'src/dtos/search.dto'; import { AssetOrder } from 'src/entities/album.entity'; import { AssetEntity } from 'src/entities/asset.entity'; -import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; -import { ICryptoRepository } from 'src/interfaces/crypto.interface'; -import { - IBaseJob, - IEntityJob, - IJobRepository, - JOBS_ASSET_PAGINATION_SIZE, - JobName, - JobStatus, -} from 'src/interfaces/job.interface'; +import { IAssetRepository } from 'src/interfaces/asset.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IMachineLearningRepository } from 'src/interfaces/machine-learning.interface'; import { IMetadataRepository } from 'src/interfaces/metadata.interface'; import { IPartnerRepository } from 'src/interfaces/partner.interface'; import { IPersonRepository } from 'src/interfaces/person.interface'; -import { AssetDuplicateResult, ISearchRepository, SearchExploreItem } from 'src/interfaces/search.interface'; +import { ISearchRepository, SearchExploreItem } from 'src/interfaces/search.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; -import { isDuplicateDetectionEnabled, isSmartSearchEnabled } from 'src/utils/misc'; -import { usePagination } from 'src/utils/pagination'; +import { isSmartSearchEnabled } from 'src/utils/misc'; @Injectable() export class SearchService { @@ -49,8 +39,6 @@ export class SearchService { @Inject(IPartnerRepository) private partnerRepository: IPartnerRepository, @Inject(IMetadataRepository) private metadataRepository: IMetadataRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository, - @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository, - @Inject(IJobRepository) private jobRepository: IJobRepository, ) { this.logger.setContext(SearchService.name); this.configCore = SystemConfigCore.create(systemMetadataRepository, logger); @@ -159,97 +147,6 @@ export class SearchService { } } - async handleQueueSearchDuplicates({ force }: IBaseJob): Promise { - const { machineLearning } = await this.configCore.getConfig(); - if (!isDuplicateDetectionEnabled(machineLearning)) { - return JobStatus.SKIPPED; - } - - const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => { - return force - ? this.assetRepository.getAll(pagination, { isVisible: true }) - : this.assetRepository.getWithout(pagination, WithoutProperty.DUPLICATE); - }); - - for await (const assets of assetPagination) { - await this.jobRepository.queueAll( - assets.map((asset) => ({ name: JobName.DUPLICATE_DETECTION, data: { id: asset.id } })), - ); - } - - return JobStatus.SUCCESS; - } - - async handleSearchDuplicates({ id }: IEntityJob): Promise { - const { machineLearning } = await this.configCore.getConfig(); - if (!isDuplicateDetectionEnabled(machineLearning)) { - return JobStatus.SKIPPED; - } - - const asset = await this.assetRepository.getById(id, { smartSearch: true }); - if (!asset) { - this.logger.error(`Asset ${id} not found`); - return JobStatus.FAILED; - } - - if (!asset.isVisible) { - this.logger.debug(`Asset ${id} is not visible, skipping`); - return JobStatus.SKIPPED; - } - - if (!asset.previewPath) { - this.logger.warn(`Asset ${id} is missing preview image`); - return JobStatus.FAILED; - } - - if (!asset.smartSearch?.embedding) { - this.logger.debug(`Asset ${id} is missing embedding`); - return JobStatus.FAILED; - } - - const duplicateAssets = await this.searchRepository.searchDuplicates({ - assetId: asset.id, - embedding: asset.smartSearch.embedding, - maxDistance: machineLearning.duplicateDetection.maxDistance, - userIds: [asset.ownerId], - }); - - let assetIds = [asset.id]; - if (duplicateAssets.length > 0) { - this.logger.debug( - `Found ${duplicateAssets.length} duplicate${duplicateAssets.length === 1 ? '' : 's'} for asset ${asset.id}`, - ); - assetIds = await this.updateDuplicates(asset, duplicateAssets); - } else if (asset.duplicateId) { - this.logger.debug(`No duplicates found for asset ${asset.id}, removing duplicateId`); - await this.assetRepository.update({ id: asset.id, duplicateId: null }); - } - - const duplicatesDetectedAt = new Date(); - await this.assetRepository.upsertJobStatus(...assetIds.map((assetId) => ({ assetId, duplicatesDetectedAt }))); - - return JobStatus.SUCCESS; - } - - private async updateDuplicates(asset: AssetEntity, duplicateAssets: AssetDuplicateResult[]): Promise { - const duplicateIds = [ - ...new Set( - duplicateAssets - .filter((asset): asset is AssetDuplicateResult & { duplicateId: string } => !!asset.duplicateId) - .map((duplicate) => duplicate.duplicateId), - ), - ]; - - const targetDuplicateId = asset.duplicateId ?? duplicateIds.shift() ?? this.cryptoRepository.randomUUID(); - const assetIdsToUpdate = duplicateAssets - .filter((asset) => asset.duplicateId !== targetDuplicateId) - .map((duplicate) => duplicate.assetId); - assetIdsToUpdate.push(asset.id); - - await this.assetRepository.updateDuplicates({ targetDuplicateId, assetIds: assetIdsToUpdate, duplicateIds }); - return assetIdsToUpdate; - } - private async getUserIdsToSearch(auth: AuthDto): Promise { const userIds: string[] = [auth.user.id]; const partners = await this.partnerRepository.getAll(auth.user.id); From 85aca2bb54e79d32704164a2c6bf06382f61d6ae Mon Sep 17 00:00:00 2001 From: Zack Pollard Date: Fri, 17 May 2024 14:44:30 +0100 Subject: [PATCH 077/163] feat: microservices be gone (#9551) * feat: microservices be gone and api is a worker now too * chore: remove very old startup scripts, surely nobody is using these anymore, right? right?.... --- docker/docker-compose.dev.yml | 56 +++++++---------- docker/docker-compose.prod.yml | 36 ++++------- docker/docker-compose.yml | 18 ------ e2e/docker-compose.yml | 44 ++++++-------- server/Dockerfile | 1 + server/src/main.ts | 93 ++++++----------------------- server/src/utils/workers.spec.ts | 49 +++++++++++++++ server/src/utils/workers.ts | 21 +++++++ server/src/workers/api.ts | 68 +++++++++++++++++++++ server/src/workers/microservices.ts | 4 +- server/start-microservices.sh | 3 - server/start-server.sh | 3 - 12 files changed, 206 insertions(+), 190 deletions(-) create mode 100644 server/src/utils/workers.spec.ts create mode 100644 server/src/utils/workers.ts create mode 100644 server/src/workers/api.ts delete mode 100755 server/start-microservices.sh delete mode 100755 server/start-server.sh diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index 5b3685ac8d..f18a563b34 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -4,32 +4,29 @@ name: immich-dev -x-server-build: &server-common - image: immich-server-dev:latest - build: - context: ../ - dockerfile: server/Dockerfile - target: dev - restart: always - volumes: - - ../server:/usr/src/app - - ../open-api:/usr/src/open-api - - ${UPLOAD_LOCATION}/photos:/usr/src/app/upload - - ${UPLOAD_LOCATION}/photos/upload:/usr/src/app/upload/upload - - /usr/src/app/node_modules - - /etc/localtime:/etc/localtime:ro - env_file: - - .env - ulimits: - nofile: - soft: 1048576 - hard: 1048576 - services: immich-server: container_name: immich_server - command: ['/usr/src/app/bin/immich-dev', 'immich'] - <<: *server-common + command: ['/usr/src/app/bin/immich-dev'] + image: immich-server-dev:latest + build: + context: ../ + dockerfile: server/Dockerfile + target: dev + restart: always + volumes: + - ../server:/usr/src/app + - ../open-api:/usr/src/open-api + - ${UPLOAD_LOCATION}/photos:/usr/src/app/upload + - ${UPLOAD_LOCATION}/photos/upload:/usr/src/app/upload/upload + - /usr/src/app/node_modules + - /etc/localtime:/etc/localtime:ro + env_file: + - .env + ulimits: + nofile: + soft: 1048576 + hard: 1048576 ports: - 3001:3001 - 9230:9230 @@ -37,19 +34,6 @@ services: - redis - database - immich-microservices: - container_name: immich_microservices - command: ['/usr/src/app/bin/immich-dev', 'microservices'] - <<: *server-common - # extends: - # file: hwaccel.transcoding.yml - # service: cpu # set to one of [nvenc, quicksync, rkmpp, vaapi, vaapi-wsl] for accelerated transcoding - ports: - - 9231:9230 - depends_on: - - database - - immich-server - immich-web: container_name: immich_web image: immich-web-dev:latest diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml index 7901f58ef6..fc62ef0c5b 100644 --- a/docker/docker-compose.prod.yml +++ b/docker/docker-compose.prod.yml @@ -1,40 +1,24 @@ name: immich-prod -x-server-build: &server-common - image: immich-server:latest - build: - context: ../ - dockerfile: server/Dockerfile - volumes: - - ${UPLOAD_LOCATION}/photos:/usr/src/app/upload - - /etc/localtime:/etc/localtime:ro - env_file: - - .env - restart: always - services: immich-server: container_name: immich_server - command: ['start.sh', 'immich'] - <<: *server-common + image: immich-server:latest + build: + context: ../ + dockerfile: server/Dockerfile + volumes: + - ${UPLOAD_LOCATION}/photos:/usr/src/app/upload + - /etc/localtime:/etc/localtime:ro + env_file: + - .env + restart: always ports: - 2283:3001 depends_on: - redis - database - immich-microservices: - container_name: immich_microservices - command: ['start.sh', 'microservices'] - <<: *server-common - # extends: - # file: hwaccel.transcoding.yml - # service: cpu # set to one of [nvenc, quicksync, rkmpp, vaapi, vaapi-wsl] for accelerated transcoding - depends_on: - - redis - - database - - immich-server - immich-machine-learning: container_name: immich_machine_learning image: immich-machine-learning:latest diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 11a2a626f0..16d628258e 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -12,7 +12,6 @@ services: immich-server: container_name: immich_server image: ghcr.io/immich-app/immich-server:${IMMICH_VERSION:-release} - command: ['start.sh', 'immich'] volumes: - ${UPLOAD_LOCATION}:/usr/src/app/upload - /etc/localtime:/etc/localtime:ro @@ -25,23 +24,6 @@ services: - database restart: always - immich-microservices: - container_name: immich_microservices - image: ghcr.io/immich-app/immich-server:${IMMICH_VERSION:-release} - # extends: # uncomment this section for hardware acceleration - see https://immich.app/docs/features/hardware-transcoding - # file: hwaccel.transcoding.yml - # service: cpu # set to one of [nvenc, quicksync, rkmpp, vaapi, vaapi-wsl] for accelerated transcoding - command: ['start.sh', 'microservices'] - volumes: - - ${UPLOAD_LOCATION}:/usr/src/app/upload - - /etc/localtime:/etc/localtime:ro - env_file: - - .env - depends_on: - - redis - - database - restart: always - immich-machine-learning: container_name: immich_machine_learning # For hardware acceleration, add one of -[armnn, cuda, openvino] to the image tag. diff --git a/e2e/docker-compose.yml b/e2e/docker-compose.yml index 703a07254e..5d86112389 100644 --- a/e2e/docker-compose.yml +++ b/e2e/docker-compose.yml @@ -2,38 +2,30 @@ version: '3.8' name: immich-e2e -x-server-build: &server-common - image: immich-server:latest - build: - context: ../ - dockerfile: server/Dockerfile - environment: - - DB_HOSTNAME=database - - DB_USERNAME=postgres - - DB_PASSWORD=postgres - - DB_DATABASE_NAME=immich - - IMMICH_MACHINE_LEARNING_ENABLED=false - - IMMICH_METRICS=true - volumes: - - upload:/usr/src/app/upload - - ./test-assets:/test-assets - depends_on: - - redis - - database - services: immich-server: container_name: immich-e2e-server - command: ['./start.sh', 'immich'] - <<: *server-common + command: ['./start.sh'] + image: immich-server:latest + build: + context: ../ + dockerfile: server/Dockerfile + environment: + - DB_HOSTNAME=database + - DB_USERNAME=postgres + - DB_PASSWORD=postgres + - DB_DATABASE_NAME=immich + - IMMICH_MACHINE_LEARNING_ENABLED=false + - IMMICH_METRICS=true + volumes: + - upload:/usr/src/app/upload + - ./test-assets:/test-assets + depends_on: + - redis + - database ports: - 2283:3001 - immich-microservices: - container_name: immich-e2e-microservices - command: ['./start.sh', 'microservices'] - <<: *server-common - redis: image: redis:6.2-alpine@sha256:84882e87b54734154586e5f8abd4dce69fe7311315e2fc6d67c29614c8de2672 diff --git a/server/Dockerfile b/server/Dockerfile index 20bad262f5..8acdf91d05 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -61,3 +61,4 @@ ENV PATH="${PATH}:/usr/src/app/bin" VOLUME /usr/src/app/upload EXPOSE 3001 ENTRYPOINT ["tini", "--", "/bin/bash"] +CMD ["start.sh"] diff --git a/server/src/main.ts b/server/src/main.ts index 0d30fe7832..68cf73dd13 100644 --- a/server/src/main.ts +++ b/server/src/main.ts @@ -1,67 +1,8 @@ -import { NestFactory } from '@nestjs/core'; -import { NestExpressApplication } from '@nestjs/platform-express'; -import { json } from 'body-parser'; -import cookieParser from 'cookie-parser'; import { CommandFactory } from 'nest-commander'; -import { existsSync } from 'node:fs'; import { Worker } from 'node:worker_threads'; -import sirv from 'sirv'; -import { ApiModule, ImmichAdminModule } from 'src/app.module'; +import { ImmichAdminModule } from 'src/app.module'; import { LogLevel } from 'src/config'; -import { WEB_ROOT, envName, excludePaths, isDev, serverVersion } from 'src/constants'; -import { ILoggerRepository } from 'src/interfaces/logger.interface'; -import { WebSocketAdapter } from 'src/middleware/websocket.adapter'; -import { ApiService } from 'src/services/api.service'; -import { otelSDK } from 'src/utils/instrumentation'; -import { useSwagger } from 'src/utils/misc'; - -const host = process.env.HOST; - -async function bootstrapApi() { - otelSDK.start(); - - const port = Number(process.env.SERVER_PORT) || 3001; - const app = await NestFactory.create(ApiModule, { bufferLogs: true }); - const logger = await app.resolve(ILoggerRepository); - - logger.setAppName('ImmichServer'); - logger.setContext('ImmichServer'); - app.useLogger(logger); - app.set('trust proxy', ['loopback', 'linklocal', 'uniquelocal']); - app.set('etag', 'strong'); - app.use(cookieParser()); - app.use(json({ limit: '10mb' })); - if (isDev) { - app.enableCors(); - } - app.useWebSocketAdapter(new WebSocketAdapter(app)); - useSwagger(app, isDev); - - app.setGlobalPrefix('api', { exclude: excludePaths }); - if (existsSync(WEB_ROOT)) { - // copied from https://github.com/sveltejs/kit/blob/679b5989fe62e3964b9a73b712d7b41831aa1f07/packages/adapter-node/src/handler.js#L46 - // provides serving of precompressed assets and caching of immutable assets - app.use( - sirv(WEB_ROOT, { - etag: true, - gzip: true, - brotli: true, - setHeaders: (res, pathname) => { - if (pathname.startsWith(`/_app/immutable`) && res.statusCode === 200) { - res.setHeader('cache-control', 'public,max-age=31536000,immutable'); - } - }, - }), - ); - } - app.use(app.get(ApiService).ssr(excludePaths)); - - const server = await (host ? app.listen(port, host) : app.listen(port)); - server.requestTimeout = 30 * 60 * 1000; - - logger.log(`Immich Server is listening on ${await app.getUrl()} [v${serverVersion}] [${envName}] `); -} - +import { getWorkers } from 'src/utils/workers'; const immichApp = process.argv[2] || process.env.IMMICH_APP; if (process.argv[2] === immichApp) { @@ -73,11 +14,12 @@ async function bootstrapImmichAdmin() { await CommandFactory.run(ImmichAdminModule); } -function bootstrapMicroservicesWorker() { - const worker = new Worker('./dist/workers/microservices.js'); +function bootstrapWorker(name: string) { + console.log(`Starting ${name} worker`); + const worker = new Worker(`./dist/workers/${name}.js`); worker.on('exit', (exitCode) => { if (exitCode !== 0) { - console.error(`Microservices worker exited with code ${exitCode}`); + console.error(`${name} worker exited with code ${exitCode}`); process.exit(exitCode); } }); @@ -85,23 +27,22 @@ function bootstrapMicroservicesWorker() { function bootstrap() { switch (immichApp) { - case 'immich': { - process.title = 'immich_server'; - if (process.env.INTERNAL_MICROSERVICES === 'true') { - bootstrapMicroservicesWorker(); - } - return bootstrapApi(); - } - case 'microservices': { - process.title = 'immich_microservices'; - return bootstrapMicroservicesWorker(); - } case 'immich-admin': { process.title = 'immich_admin_cli'; return bootstrapImmichAdmin(); } + case 'immich': { + process.title = 'immich_server'; + return bootstrapWorker('api'); + } + case 'microservices': { + process.title = 'immich_microservices'; + return bootstrapWorker('microservices'); + } default: { - throw new Error(`Invalid app name: ${immichApp}. Expected one of immich|microservices|immich-admin`); + for (const worker of getWorkers()) { + bootstrapWorker(worker); + } } } } diff --git a/server/src/utils/workers.spec.ts b/server/src/utils/workers.spec.ts new file mode 100644 index 0000000000..1e4ff5e2d3 --- /dev/null +++ b/server/src/utils/workers.spec.ts @@ -0,0 +1,49 @@ +import { getWorkers } from 'src/utils/workers'; + +describe('getWorkers', () => { + beforeEach(() => { + process.env.IMMICH_WORKERS_INCLUDE = ''; + process.env.IMMICH_WORKERS_EXCLUDE = ''; + }); + + it('should return default workers', () => { + expect(getWorkers()).toEqual(['api', 'microservices']); + }); + + it('should return included workers', () => { + process.env.IMMICH_WORKERS_INCLUDE = 'api'; + expect(getWorkers()).toEqual(['api']); + }); + + it('should excluded workers from defaults', () => { + process.env.IMMICH_WORKERS_EXCLUDE = 'api'; + expect(getWorkers()).toEqual(['microservices']); + }); + + it('should exclude workers from include list', () => { + process.env.IMMICH_WORKERS_INCLUDE = 'api,microservices,randomservice'; + process.env.IMMICH_WORKERS_EXCLUDE = 'randomservice,microservices'; + expect(getWorkers()).toEqual(['api']); + }); + + it('should remove whitespace from included workers before parsing', () => { + process.env.IMMICH_WORKERS_INCLUDE = 'api, microservices'; + expect(getWorkers()).toEqual(['api', 'microservices']); + }); + + it('should remove whitespace from excluded workers before parsing', () => { + process.env.IMMICH_WORKERS_EXCLUDE = 'api, microservices'; + expect(getWorkers()).toEqual([]); + }); + + it('should remove whitespace from included and excluded workers before parsing', () => { + process.env.IMMICH_WORKERS_INCLUDE = 'api, microservices, randomservice,randomservice2'; + process.env.IMMICH_WORKERS_EXCLUDE = 'randomservice,microservices, randomservice2'; + expect(getWorkers()).toEqual(['api']); + }); + + it('should throw error for invalid workers', () => { + process.env.IMMICH_WORKERS_INCLUDE = 'api,microservices,randomservice'; + expect(getWorkers).toThrowError('Invalid worker(s) found: api,microservices,randomservice'); + }); +}); diff --git a/server/src/utils/workers.ts b/server/src/utils/workers.ts new file mode 100644 index 0000000000..14daa2620f --- /dev/null +++ b/server/src/utils/workers.ts @@ -0,0 +1,21 @@ +const WORKER_TYPES = new Set(['api', 'microservices']); + +export const getWorkers = () => { + let workers = ['api', 'microservices']; + const includedWorkers = process.env.IMMICH_WORKERS_INCLUDE?.replaceAll(/\s/g, ''); + const excludedWorkers = process.env.IMMICH_WORKERS_EXCLUDE?.replaceAll(/\s/g, ''); + + if (includedWorkers) { + workers = includedWorkers.split(','); + } + + if (excludedWorkers) { + workers = workers.filter((worker) => !excludedWorkers.split(',').includes(worker)); + } + + if (workers.some((worker) => !WORKER_TYPES.has(worker))) { + throw new Error(`Invalid worker(s) found: ${workers}`); + } + + return workers; +}; diff --git a/server/src/workers/api.ts b/server/src/workers/api.ts new file mode 100644 index 0000000000..2423eac072 --- /dev/null +++ b/server/src/workers/api.ts @@ -0,0 +1,68 @@ +import { NestFactory } from '@nestjs/core'; +import { NestExpressApplication } from '@nestjs/platform-express'; +import { json } from 'body-parser'; +import cookieParser from 'cookie-parser'; +import { existsSync } from 'node:fs'; +import { isMainThread } from 'node:worker_threads'; +import sirv from 'sirv'; +import { ApiModule } from 'src/app.module'; +import { envName, excludePaths, isDev, serverVersion, WEB_ROOT } from 'src/constants'; +import { ILoggerRepository } from 'src/interfaces/logger.interface'; +import { WebSocketAdapter } from 'src/middleware/websocket.adapter'; +import { ApiService } from 'src/services/api.service'; +import { otelSDK } from 'src/utils/instrumentation'; +import { useSwagger } from 'src/utils/misc'; + +const host = process.env.HOST; + +async function bootstrap() { + otelSDK.start(); + + const port = Number(process.env.SERVER_PORT) || 3001; + const app = await NestFactory.create(ApiModule, { bufferLogs: true }); + const logger = await app.resolve(ILoggerRepository); + + logger.setAppName('ImmichServer'); + logger.setContext('ImmichServer'); + app.useLogger(logger); + app.set('trust proxy', ['loopback', 'linklocal', 'uniquelocal']); + app.set('etag', 'strong'); + app.use(cookieParser()); + app.use(json({ limit: '10mb' })); + if (isDev) { + app.enableCors(); + } + app.useWebSocketAdapter(new WebSocketAdapter(app)); + useSwagger(app, isDev); + + app.setGlobalPrefix('api', { exclude: excludePaths }); + if (existsSync(WEB_ROOT)) { + // copied from https://github.com/sveltejs/kit/blob/679b5989fe62e3964b9a73b712d7b41831aa1f07/packages/adapter-node/src/handler.js#L46 + // provides serving of precompressed assets and caching of immutable assets + app.use( + sirv(WEB_ROOT, { + etag: true, + gzip: true, + brotli: true, + setHeaders: (res, pathname) => { + if (pathname.startsWith(`/_app/immutable`) && res.statusCode === 200) { + res.setHeader('cache-control', 'public,max-age=31536000,immutable'); + } + }, + }), + ); + } + app.use(app.get(ApiService).ssr(excludePaths)); + + const server = await (host ? app.listen(port, host) : app.listen(port)); + server.requestTimeout = 30 * 60 * 1000; + + logger.log(`Immich Server is listening on ${await app.getUrl()} [v${serverVersion}] [${envName}] `); +} + +if (!isMainThread) { + bootstrap().catch((error) => { + console.error(error); + process.exit(1); + }); +} diff --git a/server/src/workers/microservices.ts b/server/src/workers/microservices.ts index cd339af13d..2400e62fc8 100644 --- a/server/src/workers/microservices.ts +++ b/server/src/workers/microservices.ts @@ -8,7 +8,7 @@ import { otelSDK } from 'src/utils/instrumentation'; const host = process.env.HOST; -export async function bootstrapMicroservices() { +export async function bootstrap() { otelSDK.start(); const port = Number(process.env.MICROSERVICES_PORT) || 3002; @@ -25,7 +25,7 @@ export async function bootstrapMicroservices() { } if (!isMainThread) { - bootstrapMicroservices().catch((error) => { + bootstrap().catch((error) => { console.error(error); process.exit(1); }); diff --git a/server/start-microservices.sh b/server/start-microservices.sh deleted file mode 100755 index c9e2cb42fb..0000000000 --- a/server/start-microservices.sh +++ /dev/null @@ -1,3 +0,0 @@ -#!/usr/bin/env bash - -./start.sh microservices diff --git a/server/start-server.sh b/server/start-server.sh deleted file mode 100755 index 7ef959f63c..0000000000 --- a/server/start-server.sh +++ /dev/null @@ -1,3 +0,0 @@ -#!/usr/bin/env bash - -./start.sh immich From c8aa6a62c29645589481f77ba8b5e336ea4be8f3 Mon Sep 17 00:00:00 2001 From: Zack Pollard Date: Fri, 17 May 2024 15:10:57 +0100 Subject: [PATCH 078/163] fix: when using old script args, just set the workers include var (#9552) * fix: when using old script args, just set the workers include var and move on * fix: set process.title when using new bootstrap worker startup method --- server/src/main.ts | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/server/src/main.ts b/server/src/main.ts index 68cf73dd13..84ba1f056d 100644 --- a/server/src/main.ts +++ b/server/src/main.ts @@ -32,19 +32,22 @@ function bootstrap() { return bootstrapImmichAdmin(); } case 'immich': { - process.title = 'immich_server'; - return bootstrapWorker('api'); + if (!process.env.IMMICH_WORKERS_INCLUDE) { + process.env.IMMICH_WORKERS_INCLUDE = 'api'; + } + break; } case 'microservices': { - process.title = 'immich_microservices'; - return bootstrapWorker('microservices'); - } - default: { - for (const worker of getWorkers()) { - bootstrapWorker(worker); + if (!process.env.IMMICH_WORKERS_INCLUDE) { + process.env.IMMICH_WORKERS_INCLUDE = 'microservices'; } + break; } } + process.title = 'immich'; + for (const worker of getWorkers()) { + bootstrapWorker(worker); + } } void bootstrap(); From df42352f84914be26826fdaaa62a28f94a3f681e Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 17 May 2024 09:58:55 -0500 Subject: [PATCH 079/163] chore(server): openapi generation (#9554) --- mobile/openapi/README.md | 1 - mobile/openapi/doc/AssetApi.md | 38 ------------------ mobile/openapi/lib/api/asset_api.dart | 44 --------------------- mobile/openapi/test/asset_api_test.dart | 5 --- open-api/immich-openapi-specs.json | 24 ----------- open-api/typescript-sdk/src/fetch-client.ts | 8 ---- 6 files changed, 120 deletions(-) diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 4afeb179a4..b5a49aa26c 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -98,7 +98,6 @@ Class | Method | HTTP request | Description *AssetApi* | [**deleteAssets**](doc//AssetApi.md#deleteassets) | **DELETE** /asset | *AssetApi* | [**getAllAssets**](doc//AssetApi.md#getallassets) | **GET** /asset | *AssetApi* | [**getAllUserAssetsByDeviceId**](doc//AssetApi.md#getalluserassetsbydeviceid) | **GET** /asset/device/{deviceId} | -*AssetApi* | [**getAssetDuplicates**](doc//AssetApi.md#getassetduplicates) | **GET** /asset/duplicates | *AssetApi* | [**getAssetInfo**](doc//AssetApi.md#getassetinfo) | **GET** /asset/{id} | *AssetApi* | [**getAssetStatistics**](doc//AssetApi.md#getassetstatistics) | **GET** /asset/statistics | *AssetApi* | [**getAssetThumbnail**](doc//AssetApi.md#getassetthumbnail) | **GET** /asset/thumbnail/{id} | diff --git a/mobile/openapi/doc/AssetApi.md b/mobile/openapi/doc/AssetApi.md index da070ccfc4..a1491c79a2 100644 --- a/mobile/openapi/doc/AssetApi.md +++ b/mobile/openapi/doc/AssetApi.md @@ -14,7 +14,6 @@ Method | HTTP request | Description [**deleteAssets**](AssetApi.md#deleteassets) | **DELETE** /asset | [**getAllAssets**](AssetApi.md#getallassets) | **GET** /asset | [**getAllUserAssetsByDeviceId**](AssetApi.md#getalluserassetsbydeviceid) | **GET** /asset/device/{deviceId} | -[**getAssetDuplicates**](AssetApi.md#getassetduplicates) | **GET** /asset/duplicates | [**getAssetInfo**](AssetApi.md#getassetinfo) | **GET** /asset/{id} | [**getAssetStatistics**](AssetApi.md#getassetstatistics) | **GET** /asset/statistics | [**getAssetThumbnail**](AssetApi.md#getassetthumbnail) | **GET** /asset/thumbnail/{id} | @@ -325,43 +324,6 @@ Name | Type | Description | Notes [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) -# **getAssetDuplicates** -> List getAssetDuplicates() - - - -### Example -```dart -import 'package:openapi/api.dart'; - -final api_instance = AssetApi(); - -try { - final result = api_instance.getAssetDuplicates(); - print(result); -} catch (e) { - print('Exception when calling AssetApi->getAssetDuplicates: $e\n'); -} -``` - -### Parameters -This endpoint does not need any parameter. - -### Return type - -[**List**](AssetResponseDto.md) - -### Authorization - -No authorization required - -### HTTP request headers - - - **Content-Type**: Not defined - - **Accept**: application/json - -[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) - # **getAssetInfo** > AssetResponseDto getAssetInfo(id, key) diff --git a/mobile/openapi/lib/api/asset_api.dart b/mobile/openapi/lib/api/asset_api.dart index 5c81b89c58..dba33fc181 100644 --- a/mobile/openapi/lib/api/asset_api.dart +++ b/mobile/openapi/lib/api/asset_api.dart @@ -326,50 +326,6 @@ class AssetApi { return null; } - /// Performs an HTTP 'GET /asset/duplicates' operation and returns the [Response]. - Future getAssetDuplicatesWithHttpInfo() async { - // ignore: prefer_const_declarations - final path = r'/asset/duplicates'; - - // ignore: prefer_final_locals - Object? postBody; - - final queryParams = []; - final headerParams = {}; - final formParams = {}; - - const contentTypes = []; - - - return apiClient.invokeAPI( - path, - 'GET', - queryParams, - postBody, - headerParams, - formParams, - contentTypes.isEmpty ? null : contentTypes.first, - ); - } - - Future?> getAssetDuplicates() async { - final response = await getAssetDuplicatesWithHttpInfo(); - 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; - } - /// Performs an HTTP 'GET /asset/{id}' operation and returns the [Response]. /// Parameters: /// diff --git a/mobile/openapi/test/asset_api_test.dart b/mobile/openapi/test/asset_api_test.dart index 4ab806f35b..de84e53546 100644 --- a/mobile/openapi/test/asset_api_test.dart +++ b/mobile/openapi/test/asset_api_test.dart @@ -50,11 +50,6 @@ void main() { // TODO }); - //Future> getAssetDuplicates() async - test('test getAssetDuplicates', () async { - // TODO - }); - //Future getAssetInfo(String id, { String key }) async test('test getAssetInfo', () async { // TODO diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 425bf81714..f59bc7a0ad 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -1194,30 +1194,6 @@ ] } }, - "/asset/duplicates": { - "get": { - "operationId": "getAssetDuplicates", - "parameters": [], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "items": { - "$ref": "#/components/schemas/AssetResponseDto" - }, - "type": "array" - } - } - }, - "description": "" - } - }, - "tags": [ - "Asset" - ] - } - }, "/asset/exist": { "post": { "description": "Checks if multiple assets exist on the server and returns all existing - used by background backup", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 020188c8a8..996f69de83 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -1407,14 +1407,6 @@ export function getAllUserAssetsByDeviceId({ deviceId }: { ...opts })); } -export function getAssetDuplicates(opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchJson<{ - status: 200; - data: AssetResponseDto[]; - }>("/asset/duplicates", { - ...opts - })); -} /** * Checks if multiple assets exist on the server and returns all existing - used by background backup */ From 101bc290f9c2f4976f4cbe4daa3607d95fee8075 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 17 May 2024 10:05:59 -0500 Subject: [PATCH 080/163] chore(web): light theme text color improvement (#9553) * chore(web): light theme text color improvement * openapi * openapi --- web/src/app.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/app.css b/web/src/app.css index 9db440e46c..94682ac905 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -89,7 +89,7 @@ html::-webkit-scrollbar-thumb:hover { body { margin: 0; - color: #5f6368; + color: #3a3a3a; } input:focus-visible { From 4807fc40a6e9516aa8dbab3874d9bf0b23b83ac0 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Fri, 17 May 2024 11:44:22 -0400 Subject: [PATCH 081/163] refactor!: LOG_LEVEL => IMMICH_LOG_LEVEL (#9557) refactor: LOG_LEVEL => IMMICH_LOG_LEVEL --- docs/docs/features/ml-hardware-acceleration.md | 2 +- docs/docs/install/environment-variables.md | 2 +- machine-learning/app/config.py | 4 ++-- server/src/config.ts | 2 +- server/src/main.ts | 2 +- server/src/services/system-config.service.ts | 6 +++--- 6 files changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/docs/features/ml-hardware-acceleration.md b/docs/docs/features/ml-hardware-acceleration.md index 84c4a4e6bd..2bcb5ee8e8 100644 --- a/docs/docs/features/ml-hardware-acceleration.md +++ b/docs/docs/features/ml-hardware-acceleration.md @@ -95,7 +95,7 @@ immich-machine-learning: Once this is done, you can redeploy the `immich-machine-learning` container. :::info -You can confirm the device is being recognized and used by checking its utilization (via `nvtop` for CUDA, `intel_gpu_top` for OpenVINO, etc.). You can also enable debug logging by setting `LOG_LEVEL=debug` in the `.env` file and restarting the `immich-machine-learning` container. When a Smart Search or Face Detection job begins, you should see a log for `Available ORT providers` containing the relevant provider. In the case of ARM NN, the absence of a `Could not load ANN shared libraries` log entry means it loaded successfully. +You can confirm the device is being recognized and used by checking its utilization (via `nvtop` for CUDA, `intel_gpu_top` for OpenVINO, etc.). You can also enable debug logging by setting `IMMICH_LOG_LEVEL=debug` in the `.env` file and restarting the `immich-machine-learning` container. When a Smart Search or Face Detection job begins, you should see a log for `Available ORT providers` containing the relevant provider. In the case of ARM NN, the absence of a `Could not load ANN shared libraries` log entry means it loaded successfully. ::: [hw-file]: https://github.com/immich-app/immich/releases/latest/download/hwaccel.ml.yml diff --git a/docs/docs/install/environment-variables.md b/docs/docs/install/environment-variables.md index 5b498f1b27..d4b5664fbc 100644 --- a/docs/docs/install/environment-variables.md +++ b/docs/docs/install/environment-variables.md @@ -42,7 +42,7 @@ Regardless of filesystem, it is not recommended to use a network share for your | :------------------------------ | :------------------------------------------- | :----------------------: | :-------------------------------------- | | `TZ` | Timezone | | microservices | | `NODE_ENV` | Environment (production, development) | `production` | server, microservices, machine learning | -| `LOG_LEVEL` | Log Level (verbose, debug, log, warn, error) | `log` | server, microservices, machine learning | +| `IMMICH_LOG_LEVEL` | Log Level (verbose, debug, log, warn, error) | `log` | server, microservices, machine learning | | `IMMICH_MEDIA_LOCATION` | Media Location | `./upload`\*1 | server, microservices | | `IMMICH_CONFIG_FILE` | Path to config file | | server, microservices | | `IMMICH_WEB_ROOT` | Path of root index.html | `/usr/src/app/www` | server | diff --git a/machine-learning/app/config.py b/machine-learning/app/config.py index a911659dbc..ab27499a25 100644 --- a/machine-learning/app/config.py +++ b/machine-learning/app/config.py @@ -41,7 +41,7 @@ class Settings(BaseSettings): class LogSettings(BaseSettings): - log_level: str = "info" + immich_log_level: str = "info" no_color: bool = False class Config: @@ -77,7 +77,7 @@ LOG_LEVELS: dict[str, int] = { settings = Settings() log_settings = LogSettings() -LOG_LEVEL = LOG_LEVELS.get(log_settings.log_level.lower(), logging.INFO) +LOG_LEVEL = LOG_LEVELS.get(log_settings.immich_log_level.lower(), logging.INFO) class CustomRichHandler(RichHandler): diff --git a/server/src/config.ts b/server/src/config.ts index 3ad981f97d..1f4ed9f0eb 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -361,7 +361,7 @@ export const immichAppConfig: ConfigModuleOptions = { isGlobal: true, validationSchema: Joi.object({ NODE_ENV: Joi.string().optional().valid('development', 'production', 'staging').default('development'), - LOG_LEVEL: Joi.string() + IMMICH_LOG_LEVEL: Joi.string() .optional() .valid(...Object.values(LogLevel)), diff --git a/server/src/main.ts b/server/src/main.ts index 84ba1f056d..167c772690 100644 --- a/server/src/main.ts +++ b/server/src/main.ts @@ -10,7 +10,7 @@ if (process.argv[2] === immichApp) { } async function bootstrapImmichAdmin() { - process.env.LOG_LEVEL = LogLevel.WARN; + process.env.IMMICH_LOG_LEVEL = LogLevel.WARN; await CommandFactory.run(ImmichAdminModule); } diff --git a/server/src/services/system-config.service.ts b/server/src/services/system-config.service.ts index 3474771875..e198888020 100644 --- a/server/src/services/system-config.service.ts +++ b/server/src/services/system-config.service.ts @@ -62,7 +62,7 @@ export class SystemConfigService { @OnServerEvent(ServerAsyncEvent.CONFIG_VALIDATE) onValidateConfig({ newConfig, oldConfig }: ServerAsyncEventMap[ServerAsyncEvent.CONFIG_VALIDATE]) { if (!_.isEqual(instanceToPlain(newConfig.logging), oldConfig.logging) && this.getEnvLogLevel()) { - throw new Error('Logging cannot be changed while the environment variable LOG_LEVEL is set.'); + throw new Error('Logging cannot be changed while the environment variable IMMICH_LOG_LEVEL is set.'); } } @@ -135,10 +135,10 @@ export class SystemConfigService { const configLevel = logging.enabled ? logging.level : false; const level = envLevel ?? configLevel; this.logger.setLogLevel(level); - this.logger.log(`LogLevel=${level} ${envLevel ? '(set via LOG_LEVEL)' : '(set via system config)'}`); + this.logger.log(`LogLevel=${level} ${envLevel ? '(set via IMMICH_LOG_LEVEL)' : '(set via system config)'}`); } private getEnvLogLevel() { - return process.env.LOG_LEVEL as LogLevel; + return process.env.IMMICH_LOG_LEVEL as LogLevel; } } From c03981ac1d87ade23a96652291320feac1e5bad7 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Fri, 17 May 2024 12:22:39 -0400 Subject: [PATCH 082/163] refactor(server): new version check (#9555) --- server/src/constants.ts | 2 +- .../src/controllers/server-info.controller.ts | 8 +- server/src/dtos/server-info.dto.ts | 8 +- server/src/entities/system-metadata.entity.ts | 4 + server/src/interfaces/job.interface.ts | 8 +- server/src/repositories/job.repository.ts | 3 + server/src/services/api.service.ts | 5 +- server/src/services/index.ts | 2 + server/src/services/microservices.service.ts | 3 + .../src/services/server-info.service.spec.ts | 17 +-- server/src/services/server-info.service.ts | 71 ----------- server/src/services/version.service.spec.ts | 110 ++++++++++++++++++ server/src/services/version.service.ts | 105 +++++++++++++++++ server/src/utils/misc.ts | 6 +- server/src/workers/api.ts | 4 +- web/src/lib/stores/websocket.ts | 3 +- 16 files changed, 257 insertions(+), 102 deletions(-) create mode 100644 server/src/services/version.service.spec.ts create mode 100644 server/src/services/version.service.ts diff --git a/server/src/constants.ts b/server/src/constants.ts index b6d6de815e..937dcf373a 100644 --- a/server/src/constants.ts +++ b/server/src/constants.ts @@ -17,7 +17,7 @@ export const AUDIT_LOG_MAX_DURATION = Duration.fromObject({ days: 100 }); export const ONE_HOUR = Duration.fromObject({ hours: 1 }); export const envName = (process.env.NODE_ENV || 'development').toUpperCase(); -export const isDev = process.env.NODE_ENV === 'development'; +export const isDev = () => process.env.NODE_ENV === 'development'; export const APP_MEDIA_LOCATION = process.env.IMMICH_MEDIA_LOCATION || './upload'; export const WEB_ROOT = process.env.IMMICH_WEB_ROOT || '/usr/src/app/www'; diff --git a/server/src/controllers/server-info.controller.ts b/server/src/controllers/server-info.controller.ts index 758c50299a..960acfe3fd 100644 --- a/server/src/controllers/server-info.controller.ts +++ b/server/src/controllers/server-info.controller.ts @@ -12,11 +12,15 @@ import { } from 'src/dtos/server-info.dto'; import { Authenticated } from 'src/middleware/auth.guard'; import { ServerInfoService } from 'src/services/server-info.service'; +import { VersionService } from 'src/services/version.service'; @ApiTags('Server Info') @Controller('server-info') export class ServerInfoController { - constructor(private service: ServerInfoService) {} + constructor( + private service: ServerInfoService, + private versionService: VersionService, + ) {} @Get() @Authenticated() @@ -31,7 +35,7 @@ export class ServerInfoController { @Get('version') getServerVersion(): ServerVersionResponseDto { - return this.service.getVersion(); + return this.versionService.getVersion(); } @Get('features') diff --git a/server/src/dtos/server-info.dto.ts b/server/src/dtos/server-info.dto.ts index 210e1f894f..11a3bd1250 100644 --- a/server/src/dtos/server-info.dto.ts +++ b/server/src/dtos/server-info.dto.ts @@ -1,7 +1,6 @@ import { ApiProperty, ApiResponseProperty } from '@nestjs/swagger'; -import type { DateTime } from 'luxon'; import { SystemConfigThemeDto } from 'src/dtos/system-config.dto'; -import { IVersion, VersionType } from 'src/utils/version'; +import { IVersion } from 'src/utils/version'; export class ServerPingResponse { @ApiResponseProperty({ type: String, example: 'pong' }) @@ -112,8 +111,9 @@ export class ServerFeaturesDto { } export interface ReleaseNotification { - isAvailable: VersionType; - checkedAt: DateTime | null; + isAvailable: boolean; + /** ISO8601 */ + checkedAt: string; serverVersion: ServerVersionResponseDto; releaseVersion: ServerVersionResponseDto; } diff --git a/server/src/entities/system-metadata.entity.ts b/server/src/entities/system-metadata.entity.ts index fcbc66edae..b097c21200 100644 --- a/server/src/entities/system-metadata.entity.ts +++ b/server/src/entities/system-metadata.entity.ts @@ -14,10 +14,14 @@ export enum SystemMetadataKey { REVERSE_GEOCODING_STATE = 'reverse-geocoding-state', ADMIN_ONBOARDING = 'admin-onboarding', SYSTEM_CONFIG = 'system-config', + VERSION_CHECK_STATE = 'version-check-state', } +export type VersionCheckMetadata = { checkedAt: string; releaseVersion: string }; + export interface SystemMetadata extends Record> { [SystemMetadataKey.REVERSE_GEOCODING_STATE]: { lastUpdate?: string; lastImportFileName?: string }; [SystemMetadataKey.ADMIN_ONBOARDING]: { isOnboarded: boolean }; [SystemMetadataKey.SYSTEM_CONFIG]: DeepPartial; + [SystemMetadataKey.VERSION_CHECK_STATE]: VersionCheckMetadata; } diff --git a/server/src/interfaces/job.interface.ts b/server/src/interfaces/job.interface.ts index e5ba7f43eb..37401df896 100644 --- a/server/src/interfaces/job.interface.ts +++ b/server/src/interfaces/job.interface.ts @@ -100,6 +100,9 @@ export enum JobName { // Notification NOTIFY_SIGNUP = 'notify-signup', SEND_EMAIL = 'notification-send-email', + + // Version check + VERSION_CHECK = 'version-check', } export const JOBS_ASSET_PAGINATION_SIZE = 1000; @@ -243,7 +246,10 @@ export type JobItem = // Notification | { name: JobName.SEND_EMAIL; data: IEmailJob } - | { name: JobName.NOTIFY_SIGNUP; data: INotifySignupJob }; + | { name: JobName.NOTIFY_SIGNUP; data: INotifySignupJob } + + // Version check + | { name: JobName.VERSION_CHECK; data: IBaseJob }; export enum JobStatus { SUCCESS = 'success', diff --git a/server/src/repositories/job.repository.ts b/server/src/repositories/job.repository.ts index c708ea3767..606549454d 100644 --- a/server/src/repositories/job.repository.ts +++ b/server/src/repositories/job.repository.ts @@ -86,6 +86,9 @@ export const JOBS_TO_QUEUE: Record = { // Notification [JobName.SEND_EMAIL]: QueueName.NOTIFICATION, [JobName.NOTIFY_SIGNUP]: QueueName.NOTIFICATION, + + // Version check + [JobName.VERSION_CHECK]: QueueName.BACKGROUND_TASK, }; @Instrumentation() diff --git a/server/src/services/api.service.ts b/server/src/services/api.service.ts index fb9912da95..9c786a848f 100644 --- a/server/src/services/api.service.ts +++ b/server/src/services/api.service.ts @@ -12,6 +12,7 @@ import { ServerInfoService } from 'src/services/server-info.service'; import { SharedLinkService } from 'src/services/shared-link.service'; import { StorageService } from 'src/services/storage.service'; import { SystemConfigService } from 'src/services/system-config.service'; +import { VersionService } from 'src/services/version.service'; import { OpenGraphTags } from 'src/utils/misc'; const render = (index: string, meta: OpenGraphTags) => { @@ -44,6 +45,7 @@ export class ApiService { private sharedLinkService: SharedLinkService, private storageService: StorageService, private databaseService: DatabaseService, + private versionService: VersionService, @Inject(ILoggerRepository) private logger: ILoggerRepository, ) { this.logger.setContext(ApiService.name); @@ -51,7 +53,7 @@ export class ApiService { @Interval(ONE_HOUR.as('milliseconds')) async onVersionCheck() { - await this.serverService.handleVersionCheck(); + await this.versionService.handleQueueVersionCheck(); } @Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT) @@ -64,6 +66,7 @@ export class ApiService { await this.configService.init(); this.storageService.init(); await this.serverService.init(); + await this.versionService.init(); this.logger.log(`Feature Flags: ${JSON.stringify(await this.serverService.getFeatures(), null, 2)}`); } diff --git a/server/src/services/index.ts b/server/src/services/index.ts index f130da2349..c9331c00c7 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -32,6 +32,7 @@ import { TagService } from 'src/services/tag.service'; import { TimelineService } from 'src/services/timeline.service'; import { TrashService } from 'src/services/trash.service'; import { UserService } from 'src/services/user.service'; +import { VersionService } from 'src/services/version.service'; export const services = [ ApiService, @@ -68,4 +69,5 @@ export const services = [ TimelineService, TrashService, UserService, + VersionService, ]; diff --git a/server/src/services/microservices.service.ts b/server/src/services/microservices.service.ts index 2c8302fb1d..1b6abe68f4 100644 --- a/server/src/services/microservices.service.ts +++ b/server/src/services/microservices.service.ts @@ -16,6 +16,7 @@ import { StorageTemplateService } from 'src/services/storage-template.service'; import { StorageService } from 'src/services/storage.service'; import { SystemConfigService } from 'src/services/system-config.service'; import { UserService } from 'src/services/user.service'; +import { VersionService } from 'src/services/version.service'; import { otelSDK } from 'src/utils/instrumentation'; @Injectable() @@ -37,6 +38,7 @@ export class MicroservicesService { private storageService: StorageService, private userService: UserService, private duplicateService: DuplicateService, + private versionService: VersionService, ) {} async init() { @@ -89,6 +91,7 @@ export class MicroservicesService { [JobName.LIBRARY_QUEUE_CLEANUP]: () => this.libraryService.handleQueueCleanup(), [JobName.SEND_EMAIL]: (data) => this.notificationService.handleSendEmail(data), [JobName.NOTIFY_SIGNUP]: (data) => this.notificationService.handleUserSignup(data), + [JobName.VERSION_CHECK]: () => this.versionService.handleVersionCheck(), }); await this.metadataService.init(); diff --git a/server/src/services/server-info.service.spec.ts b/server/src/services/server-info.service.spec.ts index ff1a73c216..41f2e95e22 100644 --- a/server/src/services/server-info.service.spec.ts +++ b/server/src/services/server-info.service.spec.ts @@ -1,37 +1,28 @@ -import { serverVersion } from 'src/constants'; -import { IEventRepository } from 'src/interfaces/event.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; -import { IServerInfoRepository } from 'src/interfaces/server-info.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; import { ServerInfoService } from 'src/services/server-info.service'; -import { newEventRepositoryMock } from 'test/repositories/event.repository.mock'; import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock'; -import { newServerInfoRepositoryMock } from 'test/repositories/system-info.repository.mock'; import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; import { newUserRepositoryMock } from 'test/repositories/user.repository.mock'; import { Mocked } from 'vitest'; describe(ServerInfoService.name, () => { let sut: ServerInfoService; - let eventMock: Mocked; - let serverInfoMock: Mocked; let storageMock: Mocked; let userMock: Mocked; let systemMock: Mocked; let loggerMock: Mocked; beforeEach(() => { - eventMock = newEventRepositoryMock(); - serverInfoMock = newServerInfoRepositoryMock(); storageMock = newStorageRepositoryMock(); userMock = newUserRepositoryMock(); systemMock = newSystemMetadataRepositoryMock(); loggerMock = newLoggerRepositoryMock(); - sut = new ServerInfoService(eventMock, userMock, serverInfoMock, storageMock, systemMock, loggerMock); + sut = new ServerInfoService(userMock, storageMock, systemMock, loggerMock); }); it('should work', () => { @@ -154,12 +145,6 @@ describe(ServerInfoService.name, () => { }); }); - describe('getVersion', () => { - it('should respond the server version', () => { - expect(sut.getVersion()).toEqual(serverVersion); - }); - }); - describe('getFeatures', () => { it('should respond the server features', async () => { await expect(sut.getFeatures()).resolves.toEqual({ diff --git a/server/src/services/server-info.service.ts b/server/src/services/server-info.service.ts index 7531b326b2..9e27e9d7ac 100644 --- a/server/src/services/server-info.service.ts +++ b/server/src/services/server-info.service.ts @@ -1,9 +1,6 @@ import { Inject, Injectable } from '@nestjs/common'; -import { DateTime } from 'luxon'; -import { isDev, serverVersion } from 'src/constants'; import { StorageCore, StorageFolder } from 'src/cores/storage.core'; import { SystemConfigCore } from 'src/cores/system-config.core'; -import { OnServerEvent } from 'src/decorators'; import { ServerConfigDto, ServerFeaturesDto, @@ -14,27 +11,20 @@ import { UsageByUserDto, } from 'src/dtos/server-info.dto'; import { SystemMetadataKey } from 'src/entities/system-metadata.entity'; -import { ClientEvent, IEventRepository, ServerEvent, ServerEventMap } from 'src/interfaces/event.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; -import { IServerInfoRepository } from 'src/interfaces/server-info.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { IUserRepository, UserStatsQueryResponse } from 'src/interfaces/user.interface'; import { asHumanReadable } from 'src/utils/bytes'; import { mimeTypes } from 'src/utils/mime-types'; import { isDuplicateDetectionEnabled, isFacialRecognitionEnabled, isSmartSearchEnabled } from 'src/utils/misc'; -import { Version } from 'src/utils/version'; @Injectable() export class ServerInfoService { private configCore: SystemConfigCore; - private releaseVersion = serverVersion; - private releaseVersionCheckedAt: DateTime | null = null; constructor( - @Inject(IEventRepository) private eventRepository: IEventRepository, @Inject(IUserRepository) private userRepository: IUserRepository, - @Inject(IServerInfoRepository) private repository: IServerInfoRepository, @Inject(IStorageRepository) private storageRepository: IStorageRepository, @Inject(ISystemMetadataRepository) private systemMetadataRepository: ISystemMetadataRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository, @@ -43,11 +33,7 @@ export class ServerInfoService { this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); } - onConnect() {} - async init(): Promise { - await this.handleVersionCheck(); - const featureFlags = await this.getFeatures(); if (featureFlags.configFile) { await this.systemMetadataRepository.set(SystemMetadataKey.ADMIN_ONBOARDING, { @@ -77,10 +63,6 @@ export class ServerInfoService { return { res: 'pong' }; } - getVersion() { - return serverVersion; - } - async getFeatures(): Promise { const { reverseGeocoding, map, machineLearning, trash, oauth, passwordLogin, notifications } = await this.configCore.getConfig(); @@ -152,57 +134,4 @@ export class ServerInfoService { sidecar: Object.keys(mimeTypes.sidecar), }; } - - async handleVersionCheck(): Promise { - try { - if (isDev) { - return true; - } - - const { newVersionCheck } = await this.configCore.getConfig(); - if (!newVersionCheck.enabled) { - return true; - } - - // check once per hour (max) - if (this.releaseVersionCheckedAt && DateTime.now().diff(this.releaseVersionCheckedAt).as('minutes') < 60) { - return true; - } - - const githubRelease = await this.repository.getGitHubRelease(); - const githubVersion = Version.fromString(githubRelease.tag_name); - const publishedAt = new Date(githubRelease.published_at); - this.releaseVersion = githubVersion; - this.releaseVersionCheckedAt = DateTime.now(); - - if (githubVersion.isNewerThan(serverVersion)) { - this.logger.log(`Found ${githubVersion.toString()}, released at ${publishedAt.toLocaleString()}`); - this.newReleaseNotification(); - } - } catch (error: Error | any) { - this.logger.warn(`Unable to run version check: ${error}`, error?.stack); - } - - return true; - } - - @OnServerEvent(ServerEvent.WEBSOCKET_CONNECT) - onWebsocketConnection({ userId }: ServerEventMap[ServerEvent.WEBSOCKET_CONNECT]) { - this.eventRepository.clientSend(ClientEvent.SERVER_VERSION, userId, serverVersion); - this.newReleaseNotification(userId); - } - - private newReleaseNotification(userId?: string) { - const event = ClientEvent.NEW_RELEASE; - const payload = { - isAvailable: this.releaseVersion.isNewerThan(serverVersion), - checkedAt: this.releaseVersionCheckedAt, - serverVersion, - releaseVersion: this.releaseVersion, - }; - - userId - ? this.eventRepository.clientSend(event, userId, payload) - : this.eventRepository.clientBroadcast(event, payload); - } } diff --git a/server/src/services/version.service.spec.ts b/server/src/services/version.service.spec.ts new file mode 100644 index 0000000000..35bb260628 --- /dev/null +++ b/server/src/services/version.service.spec.ts @@ -0,0 +1,110 @@ +import { DateTime } from 'luxon'; +import { serverVersion } from 'src/constants'; +import { SystemMetadataKey } from 'src/entities/system-metadata.entity'; +import { IEventRepository } from 'src/interfaces/event.interface'; +import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; +import { ILoggerRepository } from 'src/interfaces/logger.interface'; +import { IServerInfoRepository } from 'src/interfaces/server-info.interface'; +import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +import { VersionService } from 'src/services/version.service'; +import { newEventRepositoryMock } from 'test/repositories/event.repository.mock'; +import { newJobRepositoryMock } from 'test/repositories/job.repository.mock'; +import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; +import { newServerInfoRepositoryMock } from 'test/repositories/system-info.repository.mock'; +import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; +import { Mocked } from 'vitest'; + +const mockRelease = (version = '100.0.0') => ({ + id: 1, + url: 'https://api.github.com/repos/owner/repo/releases/1', + tag_name: 'v' + version, + name: 'Release 1000', + created_at: DateTime.utc().toISO(), + published_at: DateTime.utc().toISO(), + body: '', +}); + +describe(VersionService.name, () => { + let sut: VersionService; + let eventMock: Mocked; + let jobMock: Mocked; + let serverMock: Mocked; + let systemMock: Mocked; + let loggerMock: Mocked; + + beforeEach(() => { + eventMock = newEventRepositoryMock(); + jobMock = newJobRepositoryMock(); + serverMock = newServerInfoRepositoryMock(); + systemMock = newSystemMetadataRepositoryMock(); + loggerMock = newLoggerRepositoryMock(); + + sut = new VersionService(eventMock, jobMock, serverMock, systemMock, loggerMock); + }); + + it('should work', () => { + expect(sut).toBeDefined(); + }); + + describe('getVersion', () => { + it('should respond the server version', () => { + expect(sut.getVersion()).toEqual(serverVersion); + }); + }); + + describe('handQueueVersionCheck', () => { + it('should queue a version check job', async () => { + await expect(sut.handleQueueVersionCheck()).resolves.toBeUndefined(); + expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.VERSION_CHECK, data: {} }); + }); + }); + + describe('handVersionCheck', () => { + beforeEach(() => { + process.env.NODE_ENV = 'production'; + }); + + it('should not run in dev mode', async () => { + process.env.NODE_ENV = 'development'; + await expect(sut.handleVersionCheck()).resolves.toEqual(JobStatus.SKIPPED); + }); + + it('should not run if the last check was < 60 minutes ago', async () => { + systemMock.get.mockResolvedValue({ + checkedAt: DateTime.utc().minus({ minutes: 5 }).toISO(), + releaseVersion: '1.0.0', + }); + await expect(sut.handleVersionCheck()).resolves.toEqual(JobStatus.SKIPPED); + }); + + it('should run if it has been > 60 minutes', async () => { + serverMock.getGitHubRelease.mockResolvedValue(mockRelease()); + systemMock.get.mockResolvedValue({ + checkedAt: DateTime.utc().minus({ minutes: 65 }).toISO(), + releaseVersion: '1.0.0', + }); + await expect(sut.handleVersionCheck()).resolves.toEqual(JobStatus.SUCCESS); + expect(systemMock.set).toHaveBeenCalled(); + expect(loggerMock.log).toHaveBeenCalled(); + expect(eventMock.clientBroadcast).toHaveBeenCalled(); + }); + + it('should not notify if the version is equal', async () => { + serverMock.getGitHubRelease.mockResolvedValue(mockRelease(serverVersion.toString())); + await expect(sut.handleVersionCheck()).resolves.toEqual(JobStatus.SUCCESS); + expect(systemMock.set).toHaveBeenCalledWith(SystemMetadataKey.VERSION_CHECK_STATE, { + checkedAt: expect.any(String), + releaseVersion: serverVersion.toString(), + }); + expect(eventMock.clientBroadcast).not.toHaveBeenCalled(); + }); + + it('should handle a github error', async () => { + serverMock.getGitHubRelease.mockRejectedValue(new Error('GitHub is down')); + await expect(sut.handleVersionCheck()).resolves.toEqual(JobStatus.FAILED); + expect(systemMock.set).not.toHaveBeenCalled(); + expect(eventMock.clientBroadcast).not.toHaveBeenCalled(); + expect(loggerMock.warn).toHaveBeenCalled(); + }); + }); +}); diff --git a/server/src/services/version.service.ts b/server/src/services/version.service.ts new file mode 100644 index 0000000000..5c3a15f622 --- /dev/null +++ b/server/src/services/version.service.ts @@ -0,0 +1,105 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DateTime } from 'luxon'; +import { isDev, serverVersion } from 'src/constants'; +import { SystemConfigCore } from 'src/cores/system-config.core'; +import { OnServerEvent } from 'src/decorators'; +import { ReleaseNotification } from 'src/dtos/server-info.dto'; +import { SystemMetadataKey, VersionCheckMetadata } from 'src/entities/system-metadata.entity'; +import { ClientEvent, IEventRepository, ServerEvent, ServerEventMap } from 'src/interfaces/event.interface'; +import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; +import { ILoggerRepository } from 'src/interfaces/logger.interface'; +import { IServerInfoRepository } from 'src/interfaces/server-info.interface'; +import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +import { Version } from 'src/utils/version'; + +const asNotification = ({ releaseVersion, checkedAt }: VersionCheckMetadata): ReleaseNotification => { + const version = Version.fromString(releaseVersion); + return { + isAvailable: version.isNewerThan(serverVersion) !== 0, + checkedAt, + serverVersion, + releaseVersion: version, + }; +}; + +@Injectable() +export class VersionService { + private configCore: SystemConfigCore; + + constructor( + @Inject(IEventRepository) private eventRepository: IEventRepository, + @Inject(IJobRepository) private jobRepository: IJobRepository, + @Inject(IServerInfoRepository) private repository: IServerInfoRepository, + @Inject(ISystemMetadataRepository) private systemMetadataRepository: ISystemMetadataRepository, + @Inject(ILoggerRepository) private logger: ILoggerRepository, + ) { + this.logger.setContext(VersionService.name); + this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); + } + + async init(): Promise { + await this.handleVersionCheck(); + } + + getVersion() { + return serverVersion; + } + + async handleQueueVersionCheck() { + await this.jobRepository.queue({ name: JobName.VERSION_CHECK, data: {} }); + } + + async handleVersionCheck(): Promise { + try { + this.logger.debug('Running version check'); + + if (isDev()) { + return JobStatus.SKIPPED; + } + + const { newVersionCheck } = await this.configCore.getConfig(); + if (!newVersionCheck.enabled) { + return JobStatus.SKIPPED; + } + + const versionCheck = await this.systemMetadataRepository.get(SystemMetadataKey.VERSION_CHECK_STATE); + if (versionCheck?.checkedAt) { + const lastUpdate = DateTime.fromISO(versionCheck.checkedAt); + const elapsedTime = DateTime.now().diff(lastUpdate).as('minutes'); + // check once per hour (max) + if (elapsedTime < 60) { + return JobStatus.SKIPPED; + } + } + + const githubRelease = await this.repository.getGitHubRelease(); + const githubVersion = Version.fromString(githubRelease.tag_name); + const metadata: VersionCheckMetadata = { + checkedAt: DateTime.utc().toISO(), + releaseVersion: githubVersion.toString(), + }; + + await this.systemMetadataRepository.set(SystemMetadataKey.VERSION_CHECK_STATE, metadata); + + if (githubVersion.isNewerThan(serverVersion)) { + const publishedAt = new Date(githubRelease.published_at); + this.logger.log(`Found ${githubVersion.toString()}, released at ${publishedAt.toLocaleString()}`); + this.eventRepository.clientBroadcast(ClientEvent.NEW_RELEASE, asNotification(metadata)); + } + } catch (error: Error | any) { + this.logger.warn(`Unable to run version check: ${error}`, error?.stack); + return JobStatus.FAILED; + } + + return JobStatus.SUCCESS; + } + + @OnServerEvent(ServerEvent.WEBSOCKET_CONNECT) + async onWebsocketConnection({ userId }: ServerEventMap[ServerEvent.WEBSOCKET_CONNECT]) { + this.eventRepository.clientSend(ClientEvent.SERVER_VERSION, userId, serverVersion); + const metadata = await this.systemMetadataRepository.get(SystemMetadataKey.VERSION_CHECK_STATE); + if (metadata) { + this.eventRepository.clientSend(ClientEvent.NEW_RELEASE, userId, asNotification(metadata)); + } + } +} diff --git a/server/src/utils/misc.ts b/server/src/utils/misc.ts index db4687c514..e34a19112d 100644 --- a/server/src/utils/misc.ts +++ b/server/src/utils/misc.ts @@ -11,7 +11,7 @@ import _ from 'lodash'; import { writeFileSync } from 'node:fs'; import path from 'node:path'; import { SystemConfig } from 'src/config'; -import { CLIP_MODEL_INFO, serverVersion } from 'src/constants'; +import { CLIP_MODEL_INFO, isDev, serverVersion } from 'src/constants'; import { ImmichCookie, ImmichHeader } from 'src/dtos/auth.dto'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { Metadata } from 'src/middleware/auth.guard'; @@ -174,7 +174,7 @@ const patchOpenAPI = (document: OpenAPIObject) => { return document; }; -export const useSwagger = (app: INestApplication, isDevelopment: boolean) => { +export const useSwagger = (app: INestApplication) => { const config = new DocumentBuilder() .setTitle('Immich') .setDescription('Immich API') @@ -211,7 +211,7 @@ export const useSwagger = (app: INestApplication, isDevelopment: boolean) => { SwaggerModule.setup('doc', app, specification, customOptions); - if (isDevelopment) { + if (isDev()) { // Generate API Documentation only in development mode const outputPath = path.resolve(process.cwd(), '../open-api/immich-openapi-specs.json'); writeFileSync(outputPath, JSON.stringify(patchOpenAPI(specification), null, 2), { encoding: 'utf8' }); diff --git a/server/src/workers/api.ts b/server/src/workers/api.ts index 2423eac072..b6ad3a28fd 100644 --- a/server/src/workers/api.ts +++ b/server/src/workers/api.ts @@ -29,11 +29,11 @@ async function bootstrap() { app.set('etag', 'strong'); app.use(cookieParser()); app.use(json({ limit: '10mb' })); - if (isDev) { + if (isDev()) { app.enableCors(); } app.useWebSocketAdapter(new WebSocketAdapter(app)); - useSwagger(app, isDev); + useSwagger(app); app.setGlobalPrefix('api', { exclude: excludePaths }); if (existsSync(WEB_ROOT)) { diff --git a/web/src/lib/stores/websocket.ts b/web/src/lib/stores/websocket.ts index c0244027e2..9142b3174e 100644 --- a/web/src/lib/stores/websocket.ts +++ b/web/src/lib/stores/websocket.ts @@ -6,7 +6,8 @@ import { user } from './user.store'; export interface ReleaseEvent { isAvailable: boolean; - checkedAt: Date; + /** ISO8601 */ + checkedAt: string; serverVersion: ServerVersionResponseDto; releaseVersion: ServerVersionResponseDto; } From d61418886f647c8dbcdd929a7eaf24a11dd2d672 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Fri, 17 May 2024 12:59:05 -0400 Subject: [PATCH 083/163] refactor!: port env (#9559) refactor: port env --- docs/docs/install/environment-variables.md | 11 ++++------- e2e/src/setup.ts | 2 +- machine-learning/start.sh | 8 ++++---- server/src/config.ts | 3 +-- server/src/workers/api.ts | 2 +- server/src/workers/microservices.ts | 7 ++----- 6 files changed, 13 insertions(+), 20 deletions(-) diff --git a/docs/docs/install/environment-variables.md b/docs/docs/install/environment-variables.md index d4b5664fbc..4c163ac226 100644 --- a/docs/docs/install/environment-variables.md +++ b/docs/docs/install/environment-variables.md @@ -59,13 +59,10 @@ It only need to be set if the Immich deployment method is changing. ## Ports -| Variable | Description | Default | Services | -| :---------------------- | :-------------------- | :-------: | :-------------------- | -| `HOST` | Host | `0.0.0.0` | server, microservices | -| `SERVER_PORT` | Server Port | `3001` | server | -| `MICROSERVICES_PORT` | Microservices Port | `3002` | microservices | -| `MACHINE_LEARNING_HOST` | Machine Learning Host | `0.0.0.0` | machine learning | -| `MACHINE_LEARNING_PORT` | Machine Learning Port | `3003` | machine learning | +| Variable | Description | Default | +| :------------ | :------------- | :------------------------------------: | +| `IMMICH_HOST` | Listening host | `0.0.0.0` | +| `IMMICH_PORT` | Listening port | 3001 (server), 3003 (machine learning) | ## Database diff --git a/e2e/src/setup.ts b/e2e/src/setup.ts index e9395ddd35..3ae87417a2 100644 --- a/e2e/src/setup.ts +++ b/e2e/src/setup.ts @@ -17,7 +17,7 @@ const setup = async () => { child.stdout.on('data', (data) => { const input = data.toString(); console.log(input); - if (input.includes('Immich Microservices is listening')) { + if (input.includes('Immich Microservices is running')) { _resolve(); } }); diff --git a/machine-learning/start.sh b/machine-learning/start.sh index 7a5cb919a6..9d0a505c0c 100755 --- a/machine-learning/start.sh +++ b/machine-learning/start.sh @@ -2,20 +2,20 @@ lib_path="/usr/lib/$(arch)-linux-gnu/libmimalloc.so.2" # mimalloc seems to increase memory usage dramatically with openvino, need to investigate -if ! [ "$DEVICE" = "openvino" ]; then +if ! [ "$DEVICE" = "openvino" ]; then export LD_PRELOAD="$lib_path" export LD_BIND_NOW=1 fi -: "${MACHINE_LEARNING_HOST:=[::]}" -: "${MACHINE_LEARNING_PORT:=3003}" +: "${IMMICH_HOST:=[::]}" +: "${IMMICH_PORT:=3003}" : "${MACHINE_LEARNING_WORKERS:=1}" : "${MACHINE_LEARNING_WORKER_TIMEOUT:=120}" gunicorn app.main:app \ -k app.config.CustomUvicornWorker \ + -b "$IMMICH_HOST":"$IMMICH_PORT" \ -w "$MACHINE_LEARNING_WORKERS" \ - -b "$MACHINE_LEARNING_HOST":"$MACHINE_LEARNING_PORT" \ -t "$MACHINE_LEARNING_WORKER_TIMEOUT" \ --log-config-json log_conf.json \ --graceful-timeout 0 diff --git a/server/src/config.ts b/server/src/config.ts index 1f4ed9f0eb..3e3a42c760 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -372,8 +372,7 @@ export const immichAppConfig: ConfigModuleOptions = { DB_VECTOR_EXTENSION: Joi.string().optional().valid('pgvector', 'pgvecto.rs').default('pgvecto.rs'), DB_SKIP_MIGRATIONS: Joi.boolean().optional().default(false), - MACHINE_LEARNING_PORT: Joi.number().optional(), - MICROSERVICES_PORT: Joi.number().optional(), + IMMICH_PORT: Joi.number().optional(), IMMICH_METRICS_PORT: Joi.number().optional(), IMMICH_METRICS: Joi.boolean().optional().default(false), diff --git a/server/src/workers/api.ts b/server/src/workers/api.ts index b6ad3a28fd..96f06ffc9c 100644 --- a/server/src/workers/api.ts +++ b/server/src/workers/api.ts @@ -18,7 +18,7 @@ const host = process.env.HOST; async function bootstrap() { otelSDK.start(); - const port = Number(process.env.SERVER_PORT) || 3001; + const port = Number(process.env.IMMICH_PORT) || 3001; const app = await NestFactory.create(ApiModule, { bufferLogs: true }); const logger = await app.resolve(ILoggerRepository); diff --git a/server/src/workers/microservices.ts b/server/src/workers/microservices.ts index 2400e62fc8..de198d2e27 100644 --- a/server/src/workers/microservices.ts +++ b/server/src/workers/microservices.ts @@ -6,12 +6,9 @@ import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { WebSocketAdapter } from 'src/middleware/websocket.adapter'; import { otelSDK } from 'src/utils/instrumentation'; -const host = process.env.HOST; - export async function bootstrap() { otelSDK.start(); - const port = Number(process.env.MICROSERVICES_PORT) || 3002; const app = await NestFactory.create(MicroservicesModule, { bufferLogs: true }); const logger = await app.resolve(ILoggerRepository); logger.setAppName('ImmichMicroservices'); @@ -19,9 +16,9 @@ export async function bootstrap() { app.useLogger(logger); app.useWebSocketAdapter(new WebSocketAdapter(app)); - await (host ? app.listen(port, host) : app.listen(port)); + await app.listen(0); - logger.log(`Immich Microservices is listening on ${await app.getUrl()} [v${serverVersion}] [${envName}] `); + logger.log(`Immich Microservices is running [v${serverVersion}] [${envName}] `); } if (!isMainThread) { From 2689178a355f9f513ab36fe6c0f389eaf95555b3 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 17 May 2024 12:05:23 -0500 Subject: [PATCH 084/163] chore(server): openapi generation for Duplicate controller (#9560) --- mobile/openapi/.openapi-generator/FILES | 3 + mobile/openapi/README.md | 1 + mobile/openapi/doc/DuplicateApi.md | 65 +++++++++++++++++++++ mobile/openapi/lib/api.dart | 1 + mobile/openapi/lib/api/duplicate_api.dart | 62 ++++++++++++++++++++ mobile/openapi/test/duplicate_api_test.dart | 26 +++++++++ open-api/immich-openapi-specs.json | 35 +++++++++++ open-api/typescript-sdk/src/fetch-client.ts | 8 +++ server/src/controllers/index.ts | 2 + 9 files changed, 203 insertions(+) create mode 100644 mobile/openapi/doc/DuplicateApi.md create mode 100644 mobile/openapi/lib/api/duplicate_api.dart create mode 100644 mobile/openapi/test/duplicate_api_test.dart diff --git a/mobile/openapi/.openapi-generator/FILES b/mobile/openapi/.openapi-generator/FILES index 222e6c1111..ae72e70d5b 100644 --- a/mobile/openapi/.openapi-generator/FILES +++ b/mobile/openapi/.openapi-generator/FILES @@ -68,6 +68,7 @@ doc/DownloadApi.md doc/DownloadArchiveInfo.md doc/DownloadInfoDto.md doc/DownloadResponseDto.md +doc/DuplicateApi.md doc/DuplicateDetectionConfig.md doc/EntityType.md doc/ExifResponseDto.md @@ -224,6 +225,7 @@ lib/api/asset_api.dart lib/api/audit_api.dart lib/api/authentication_api.dart lib/api/download_api.dart +lib/api/duplicate_api.dart lib/api/face_api.dart lib/api/file_report_api.dart lib/api/job_api.dart @@ -503,6 +505,7 @@ test/download_api_test.dart test/download_archive_info_test.dart test/download_info_dto_test.dart test/download_response_dto_test.dart +test/duplicate_api_test.dart test/duplicate_detection_config_test.dart test/entity_type_test.dart test/exif_response_dto_test.dart diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index b5a49aa26c..3cce4635cc 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -119,6 +119,7 @@ Class | Method | HTTP request | Description *DownloadApi* | [**downloadArchive**](doc//DownloadApi.md#downloadarchive) | **POST** /download/archive | *DownloadApi* | [**downloadFile**](doc//DownloadApi.md#downloadfile) | **POST** /download/asset/{id} | *DownloadApi* | [**getDownloadInfo**](doc//DownloadApi.md#getdownloadinfo) | **POST** /download/info | +*DuplicateApi* | [**getAssetDuplicates**](doc//DuplicateApi.md#getassetduplicates) | **GET** /duplicates | *FaceApi* | [**getFaces**](doc//FaceApi.md#getfaces) | **GET** /face | *FaceApi* | [**reassignFacesById**](doc//FaceApi.md#reassignfacesbyid) | **PUT** /face/{id} | *FileReportApi* | [**fixAuditFiles**](doc//FileReportApi.md#fixauditfiles) | **POST** /report/fix | diff --git a/mobile/openapi/doc/DuplicateApi.md b/mobile/openapi/doc/DuplicateApi.md new file mode 100644 index 0000000000..4dfbe55d3d --- /dev/null +++ b/mobile/openapi/doc/DuplicateApi.md @@ -0,0 +1,65 @@ +# openapi.api.DuplicateApi + +## Load the API package +```dart +import 'package:openapi/api.dart'; +``` + +All URIs are relative to */api* + +Method | HTTP request | Description +------------- | ------------- | ------------- +[**getAssetDuplicates**](DuplicateApi.md#getassetduplicates) | **GET** /duplicates | + + +# **getAssetDuplicates** +> List getAssetDuplicates() + + + +### Example +```dart +import 'package:openapi/api.dart'; +// TODO Configure API key authorization: cookie +//defaultApiClient.getAuthentication('cookie').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('cookie').apiKeyPrefix = 'Bearer'; +// TODO Configure API key authorization: api_key +//defaultApiClient.getAuthentication('api_key').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('api_key').apiKeyPrefix = 'Bearer'; +// TODO Configure HTTP Bearer authorization: bearer +// Case 1. Use String Token +//defaultApiClient.getAuthentication('bearer').setAccessToken('YOUR_ACCESS_TOKEN'); +// Case 2. Use Function which generate token. +// String yourTokenGeneratorFunction() { ... } +//defaultApiClient.getAuthentication('bearer').setAccessToken(yourTokenGeneratorFunction); + +final api_instance = DuplicateApi(); + +try { + final result = api_instance.getAssetDuplicates(); + print(result); +} catch (e) { + print('Exception when calling DuplicateApi->getAssetDuplicates: $e\n'); +} +``` + +### Parameters +This endpoint does not need any parameter. + +### Return type + +[**List**](AssetResponseDto.md) + +### Authorization + +[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer) + +### HTTP request headers + + - **Content-Type**: Not defined + - **Accept**: application/json + +[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) + diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 917959d84b..69be2f8a95 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -36,6 +36,7 @@ part 'api/asset_api.dart'; part 'api/audit_api.dart'; part 'api/authentication_api.dart'; part 'api/download_api.dart'; +part 'api/duplicate_api.dart'; part 'api/face_api.dart'; part 'api/file_report_api.dart'; part 'api/job_api.dart'; diff --git a/mobile/openapi/lib/api/duplicate_api.dart b/mobile/openapi/lib/api/duplicate_api.dart new file mode 100644 index 0000000000..2833d091e9 --- /dev/null +++ b/mobile/openapi/lib/api/duplicate_api.dart @@ -0,0 +1,62 @@ +// +// 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 DuplicateApi { + DuplicateApi([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient; + + final ApiClient apiClient; + + /// Performs an HTTP 'GET /duplicates' operation and returns the [Response]. + Future getAssetDuplicatesWithHttpInfo() async { + // ignore: prefer_const_declarations + final path = r'/duplicates'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + path, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + Future?> getAssetDuplicates() async { + final response = await getAssetDuplicatesWithHttpInfo(); + 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/test/duplicate_api_test.dart b/mobile/openapi/test/duplicate_api_test.dart new file mode 100644 index 0000000000..8e22a52533 --- /dev/null +++ b/mobile/openapi/test/duplicate_api_test.dart @@ -0,0 +1,26 @@ +// +// 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 + +import 'package:openapi/api.dart'; +import 'package:test/test.dart'; + + +/// tests for DuplicateApi +void main() { + // final instance = DuplicateApi(); + + group('tests for DuplicateApi', () { + //Future> getAssetDuplicates() async + test('test getAssetDuplicates', () async { + // TODO + }); + + }); +} diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index f59bc7a0ad..7fbf5f8302 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -2221,6 +2221,41 @@ ] } }, + "/duplicates": { + "get": { + "operationId": "getAssetDuplicates", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/AssetResponseDto" + }, + "type": "array" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Duplicate" + ] + } + }, "/face": { "get": { "operationId": "getFaces", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 996f69de83..92396c360f 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -1695,6 +1695,14 @@ export function getDownloadInfo({ key, downloadInfoDto }: { body: downloadInfoDto }))); } +export function getAssetDuplicates(opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: AssetResponseDto[]; + }>("/duplicates", { + ...opts + })); +} export function getFaces({ id }: { id: string; }, opts?: Oazapfts.RequestOpts) { diff --git a/server/src/controllers/index.ts b/server/src/controllers/index.ts index df1a44a157..feec616f21 100644 --- a/server/src/controllers/index.ts +++ b/server/src/controllers/index.ts @@ -7,6 +7,7 @@ import { AssetController } from 'src/controllers/asset.controller'; import { AuditController } from 'src/controllers/audit.controller'; import { AuthController } from 'src/controllers/auth.controller'; import { DownloadController } from 'src/controllers/download.controller'; +import { DuplicateController } from 'src/controllers/duplicate.controller'; import { FaceController } from 'src/controllers/face.controller'; import { ReportController } from 'src/controllers/file-report.controller'; import { JobController } from 'src/controllers/job.controller'; @@ -37,6 +38,7 @@ export const controllers = [ AuditController, AuthController, DownloadController, + DuplicateController, FaceController, JobController, LibraryController, From 2e62c7b4170bdf96ad2bc659f89cd9b13ed29f56 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Fri, 17 May 2024 13:30:05 -0400 Subject: [PATCH 085/163] refactor: node_env => immich_env (#9561) --- docs/docs/install/environment-variables.md | 2 +- machine-learning/Dockerfile | 3 +-- machine-learning/export/Dockerfile | 3 +-- server/Dockerfile | 2 +- server/src/config.ts | 2 +- server/src/constants.ts | 4 ++-- server/src/services/version.service.spec.ts | 4 ++-- 7 files changed, 9 insertions(+), 11 deletions(-) diff --git a/docs/docs/install/environment-variables.md b/docs/docs/install/environment-variables.md index 4c163ac226..a3c08f2c70 100644 --- a/docs/docs/install/environment-variables.md +++ b/docs/docs/install/environment-variables.md @@ -41,7 +41,7 @@ Regardless of filesystem, it is not recommended to use a network share for your | Variable | Description | Default | Services | | :------------------------------ | :------------------------------------------- | :----------------------: | :-------------------------------------- | | `TZ` | Timezone | | microservices | -| `NODE_ENV` | Environment (production, development) | `production` | server, microservices, machine learning | +| `IMMICH_ENV` | Environment (production, development) | `production` | server, microservices, machine learning | | `IMMICH_LOG_LEVEL` | Log Level (verbose, debug, log, warn, error) | `log` | server, microservices, machine learning | | `IMMICH_MEDIA_LOCATION` | Media Location | `./upload`\*1 | server, microservices | | `IMMICH_CONFIG_FILE` | Path to config file | | server, microservices | diff --git a/machine-learning/Dockerfile b/machine-learning/Dockerfile index 001bba2b63..cd1f2b2d99 100644 --- a/machine-learning/Dockerfile +++ b/machine-learning/Dockerfile @@ -74,8 +74,7 @@ RUN apt-get update && \ rm -rf /var/lib/apt/lists/* WORKDIR /usr/src/app -ENV NODE_ENV=production \ - TRANSFORMERS_CACHE=/cache \ +ENV TRANSFORMERS_CACHE=/cache \ PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 \ PATH="/opt/venv/bin:$PATH" \ diff --git a/machine-learning/export/Dockerfile b/machine-learning/export/Dockerfile index 26584c68a3..cbd1b418ef 100644 --- a/machine-learning/export/Dockerfile +++ b/machine-learning/export/Dockerfile @@ -1,7 +1,6 @@ FROM mambaorg/micromamba:bookworm-slim@sha256:abcb3ae7e3521d08e1fdeaff63131765b34e4f29b6a8a2c28660036b53841569 as builder -ENV NODE_ENV=production \ - TRANSFORMERS_CACHE=/cache \ +ENV TRANSFORMERS_CACHE=/cache \ PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 \ PATH="/opt/venv/bin:$PATH" \ diff --git a/server/Dockerfile b/server/Dockerfile index 8acdf91d05..d31ed90ad6 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -11,7 +11,7 @@ RUN npm ci && \ rm -rf node_modules/@img/sharp-linuxmusl-x64 COPY server . ENV PATH="${PATH}:/usr/src/app/bin" \ - NODE_ENV=development \ + IMMICH_ENV=development \ NVIDIA_DRIVER_CAPABILITIES=all \ NVIDIA_VISIBLE_DEVICES=all ENTRYPOINT ["tini", "--", "/bin/sh"] diff --git a/server/src/config.ts b/server/src/config.ts index 3e3a42c760..0f8a645005 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -360,7 +360,7 @@ export const immichAppConfig: ConfigModuleOptions = { envFilePath: '.env', isGlobal: true, validationSchema: Joi.object({ - NODE_ENV: Joi.string().optional().valid('development', 'production', 'staging').default('development'), + IMMICH_ENV: Joi.string().optional().valid('development', 'production').default('production'), IMMICH_LOG_LEVEL: Joi.string() .optional() .valid(...Object.values(LogLevel)), diff --git a/server/src/constants.ts b/server/src/constants.ts index 937dcf373a..41759c2763 100644 --- a/server/src/constants.ts +++ b/server/src/constants.ts @@ -16,8 +16,8 @@ export const serverVersion = Version.fromString(version); export const AUDIT_LOG_MAX_DURATION = Duration.fromObject({ days: 100 }); export const ONE_HOUR = Duration.fromObject({ hours: 1 }); -export const envName = (process.env.NODE_ENV || 'development').toUpperCase(); -export const isDev = () => process.env.NODE_ENV === 'development'; +export const envName = (process.env.IMMICH_ENV || 'production').toUpperCase(); +export const isDev = () => process.env.IMMICH_ENV === 'development'; export const APP_MEDIA_LOCATION = process.env.IMMICH_MEDIA_LOCATION || './upload'; export const WEB_ROOT = process.env.IMMICH_WEB_ROOT || '/usr/src/app/www'; diff --git a/server/src/services/version.service.spec.ts b/server/src/services/version.service.spec.ts index 35bb260628..e6a2284b1a 100644 --- a/server/src/services/version.service.spec.ts +++ b/server/src/services/version.service.spec.ts @@ -61,11 +61,11 @@ describe(VersionService.name, () => { describe('handVersionCheck', () => { beforeEach(() => { - process.env.NODE_ENV = 'production'; + process.env.IMMICH_ENV = 'production'; }); it('should not run in dev mode', async () => { - process.env.NODE_ENV = 'development'; + process.env.IMMICH_ENV = 'development'; await expect(sut.handleVersionCheck()).resolves.toEqual(JobStatus.SKIPPED); }); From 975f2351ec22f8403192302e995bb49eeb7e67c4 Mon Sep 17 00:00:00 2001 From: Nicholas Flamy <30300649+NicholasFlamy@users.noreply.github.com> Date: Fri, 17 May 2024 16:37:26 -0400 Subject: [PATCH 086/163] fix(server): Disable duplicate detection when smart search disabled (#9565) --- server/src/utils/misc.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/utils/misc.ts b/server/src/utils/misc.ts index e34a19112d..910bc0962a 100644 --- a/server/src/utils/misc.ts +++ b/server/src/utils/misc.ts @@ -63,7 +63,7 @@ export const isSmartSearchEnabled = (machineLearning: SystemConfig['machineLearn export const isFacialRecognitionEnabled = (machineLearning: SystemConfig['machineLearning']) => isMachineLearningEnabled(machineLearning) && machineLearning.facialRecognition.enabled; export const isDuplicateDetectionEnabled = (machineLearning: SystemConfig['machineLearning']) => - isMachineLearningEnabled(machineLearning) && machineLearning.duplicateDetection.enabled; + isSmartSearchEnabled(machineLearning) && machineLearning.duplicateDetection.enabled; export const isConnectionAborted = (error: Error | any) => error.code === 'ECONNABORTED'; From 136bb69bd021a1f1b5954ea288404de72164bf4b Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Fri, 17 May 2024 16:48:29 -0400 Subject: [PATCH 087/163] refactor: sdk init (#9563) --- cli/src/utils.ts | 5 ++--- e2e/src/utils.ts | 8 ++++---- e2e/src/web/specs/auth.e2e-spec.ts | 2 +- e2e/src/web/specs/shared-link.e2e-spec.ts | 2 +- open-api/typescript-sdk/README.md | 5 ++--- open-api/typescript-sdk/src/index.ts | 23 +++++++++++++++++++++++ web/src/lib/utils.ts | 4 ++-- web/src/lib/utils/asset-utils.ts | 6 +++--- web/src/lib/utils/file-uploader.ts | 4 ++-- 9 files changed, 40 insertions(+), 19 deletions(-) diff --git a/cli/src/utils.ts b/cli/src/utils.ts index e63dc5ea4a..3b239bacc4 100644 --- a/cli/src/utils.ts +++ b/cli/src/utils.ts @@ -1,4 +1,4 @@ -import { defaults, getMyUserInfo, isHttpError } from '@immich/sdk'; +import { getMyUserInfo, init, isHttpError } from '@immich/sdk'; import { glob } from 'fast-glob'; import { createHash } from 'node:crypto'; import { createReadStream } from 'node:fs'; @@ -46,8 +46,7 @@ export const connect = async (url: string, key: string) => { // noop } - defaults.baseUrl = url; - defaults.headers = { 'x-api-key': key }; + init({ baseUrl: url, apiKey: key }); const [error] = await withError(getMyUserInfo()); if (isHttpError(error)) { diff --git a/e2e/src/utils.ts b/e2e/src/utils.ts index 12dbac2f0a..9f001910fe 100644 --- a/e2e/src/utils.ts +++ b/e2e/src/utils.ts @@ -16,7 +16,6 @@ import { createPerson, createSharedLink, createUser, - defaults, deleteAssets, getAllAssets, getAllJobsStatus, @@ -24,6 +23,7 @@ import { getConfigDefaults, login, searchMetadata, + setBaseUrl, signUpAdmin, updateAdminOnboarding, updateAlbumUser, @@ -255,8 +255,8 @@ export const utils = { }); }, - setApiEndpoint: () => { - defaults.baseUrl = app; + initSdk: () => { + setBaseUrl(app); }, adminSetup: async (options?: AdminSetupOptions) => { @@ -462,7 +462,7 @@ export const utils = { }, }; -utils.setApiEndpoint(); +utils.initSdk(); if (!existsSync(`${testAssetDir}/albums`)) { throw new Error( diff --git a/e2e/src/web/specs/auth.e2e-spec.ts b/e2e/src/web/specs/auth.e2e-spec.ts index 73d62f1b10..ebafbf1f67 100644 --- a/e2e/src/web/specs/auth.e2e-spec.ts +++ b/e2e/src/web/specs/auth.e2e-spec.ts @@ -3,7 +3,7 @@ import { utils } from 'src/utils'; test.describe('Registration', () => { test.beforeAll(() => { - utils.setApiEndpoint(); + utils.initSdk(); }); test.beforeEach(async () => { diff --git a/e2e/src/web/specs/shared-link.e2e-spec.ts b/e2e/src/web/specs/shared-link.e2e-spec.ts index 3540ed72e2..8687306615 100644 --- a/e2e/src/web/specs/shared-link.e2e-spec.ts +++ b/e2e/src/web/specs/shared-link.e2e-spec.ts @@ -17,7 +17,7 @@ test.describe('Shared Links', () => { let sharedLinkPassword: SharedLinkResponseDto; test.beforeAll(async () => { - utils.setApiEndpoint(); + utils.initSdk(); await utils.resetDatabase(); admin = await utils.adminSetup(); asset = await utils.createAsset(admin.accessToken); diff --git a/open-api/typescript-sdk/README.md b/open-api/typescript-sdk/README.md index 53a10eefc5..9d74360b20 100644 --- a/open-api/typescript-sdk/README.md +++ b/open-api/typescript-sdk/README.md @@ -13,12 +13,11 @@ npm i --save @immich/sdk For a more detailed example, check out the [`@immich/cli`](https://github.com/immich-app/immich/tree/main/cli). ```typescript -import { defaults, getAllAlbums, getAllAssets, getMyUserInfo } from "@immich/sdk"; +import { getAllAlbums, getAllAssets, getMyUserInfo, init } from "@immich/sdk"; const API_KEY = ""; // process.env.IMMICH_API_KEY -defaults.baseUrl = "https://demo.immich.app/api"; -defaults.headers = { "x-api-key": API_KEY }; +init({ baseUrl: "https://demo.immich.app/api", apiKey: API_KEY }); const user = await getMyUserInfo(); const assets = await getAllAssets({ take: 1000 }); diff --git a/open-api/typescript-sdk/src/index.ts b/open-api/typescript-sdk/src/index.ts index d81c7282ac..0a715c564b 100644 --- a/open-api/typescript-sdk/src/index.ts +++ b/open-api/typescript-sdk/src/index.ts @@ -1,2 +1,25 @@ +import { defaults } from './fetch-client.js'; + export * from './fetch-client.js'; export * from './fetch-errors.js'; + +export interface InitOptions { + baseUrl: string; + apiKey: string; +} + +export const init = ({ baseUrl, apiKey }: InitOptions) => { + setBaseUrl(baseUrl); + setApiKey(apiKey); +}; + +export const getBaseUrl = () => defaults.baseUrl; + +export const setBaseUrl = (baseUrl: string) => { + defaults.baseUrl = baseUrl; +}; + +export const setApiKey = (apiKey: string) => { + defaults.headers = defaults.headers || {}; + defaults.headers['x-api-key'] = apiKey; +}; diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts index fb61842b48..87cef15737 100644 --- a/web/src/lib/utils.ts +++ b/web/src/lib/utils.ts @@ -5,8 +5,8 @@ import { AssetJobName, JobName, ThumbnailFormat, - defaults, finishOAuth, + getBaseUrl, linkOAuthAccount, startOAuth, unlinkOAuthAccount, @@ -155,7 +155,7 @@ const createUrl = (path: string, parameters?: Record) => { const url = new URL(path, 'https://example.com'); url.search = searchParameters.toString(); - return defaults.baseUrl + url.pathname + url.search + url.hash; + return getBaseUrl() + url.pathname + url.search + url.hash; }; export const getAssetFileUrl = (...[assetId, isWeb, isThumb]: [string, boolean, boolean]) => { diff --git a/web/src/lib/utils/asset-utils.ts b/web/src/lib/utils/asset-utils.ts index bda0bb6ffe..7a8bff68b2 100644 --- a/web/src/lib/utils/asset-utils.ts +++ b/web/src/lib/utils/asset-utils.ts @@ -10,7 +10,7 @@ import { createAlbum } from '$lib/utils/album-utils'; import { encodeHTMLSpecialChars } from '$lib/utils/string-utils'; import { addAssetsToAlbum as addAssets, - defaults, + getBaseUrl, getDownloadInfo, updateAssets, type AlbumResponseDto, @@ -121,7 +121,7 @@ export const downloadArchive = async (fileName: string, options: DownloadInfoDto // TODO use sdk once it supports progress events const { data } = await downloadRequest({ method: 'POST', - url: defaults.baseUrl + '/download/archive' + (key ? `?key=${key}` : ''), + url: getBaseUrl() + '/download/archive' + (key ? `?key=${key}` : ''), data: { assetIds: archive.assetIds }, signal: abort.signal, onDownloadProgress: (event) => downloadManager.update(downloadKey, event.loaded), @@ -177,7 +177,7 @@ export const downloadFile = async (asset: AssetResponseDto) => { // TODO use sdk once it supports progress events const { data } = await downloadRequest({ method: 'POST', - url: defaults.baseUrl + `/download/asset/${id}` + (key ? `?key=${key}` : ''), + url: getBaseUrl() + `/download/asset/${id}` + (key ? `?key=${key}` : ''), signal: abort.signal, onDownloadProgress: (event) => downloadManager.update(downloadKey, event.loaded, event.total), }); diff --git a/web/src/lib/utils/file-uploader.ts b/web/src/lib/utils/file-uploader.ts index ce7b18c2de..9472364d09 100644 --- a/web/src/lib/utils/file-uploader.ts +++ b/web/src/lib/utils/file-uploader.ts @@ -6,7 +6,7 @@ import { ExecutorQueue } from '$lib/utils/executor-queue'; import { Action, checkBulkUpload, - defaults, + getBaseUrl, getSupportedMediaTypes, type AssetFileUploadResponseDto, } from '@immich/sdk'; @@ -119,7 +119,7 @@ async function fileUploader(asset: File, albumId: string | undefined = undefined if (!responseData) { uploadAssetsStore.updateAsset(deviceAssetId, { message: 'Uploading...' }); const response = await uploadRequest({ - url: defaults.baseUrl + '/asset/upload' + (key ? `?key=${key}` : ''), + url: getBaseUrl() + '/asset/upload' + (key ? `?key=${key}` : ''), data: formData, onUploadProgress: (event) => uploadAssetsStore.updateProgress(deviceAssetId, event.loaded, event.total), }); From 6b369e84fcad985224cdeb17fd4802de1e55cb75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robert=20Sch=C3=A4fer?= Date: Sat, 18 May 2024 02:19:23 +0530 Subject: [PATCH 088/163] docs: use `npm` in README (#9566) Motivation ---------- Looks like `npm` is being used, `package-lock.json` is checked in whereas `yarn.lock` is gitignored. So, let's use `npm` in the README. How to test ----------- 1. Follow the README. 2. It behaves just like before. --- docs/README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/README.md b/docs/README.md index 0c6c2c27be..cdf0733949 100644 --- a/docs/README.md +++ b/docs/README.md @@ -5,13 +5,13 @@ This website is built using [Docusaurus](https://docusaurus.io/), a modern stati ### Installation ``` -$ yarn +$ npm install ``` ### Local Development ``` -$ yarn start +$ npm run start ``` This command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server. @@ -19,7 +19,7 @@ This command starts a local development server and opens up a browser window. Mo ### Build ``` -$ yarn build +$ npm run build ``` This command generates static content into the `build` directory and can be served using any static contents hosting service. @@ -29,13 +29,13 @@ This command generates static content into the `build` directory and can be serv Using SSH: ``` -$ USE_SSH=true yarn deploy +$ USE_SSH=true npm run deploy ``` Not using SSH: ``` -$ GIT_USER= yarn deploy +$ GIT_USER= npm run deploy ``` If you are using GitHub pages for hosting, this command is a convenient way to build the website and push to the `gh-pages` branch. From 9aed73691159365ced3e5f18f1173bc9001a6f8c Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Fri, 17 May 2024 20:00:20 -0400 Subject: [PATCH 089/163] fix(ml): openvino not working with kernel 6.7.5 or later (#9541) * add envs * move to Dockerfile --- machine-learning/Dockerfile | 3 +++ 1 file changed, 3 insertions(+) diff --git a/machine-learning/Dockerfile b/machine-learning/Dockerfile index cd1f2b2d99..afd793b033 100644 --- a/machine-learning/Dockerfile +++ b/machine-learning/Dockerfile @@ -40,6 +40,9 @@ FROM python:3.11-slim-bookworm@sha256:fc39d2e68b554c3f0a5cb8a776280c0b3d73b4c04b FROM openvino/ubuntu22_runtime:2023.3.0@sha256:176646df619032ea6c10faf842867119c393e7497b7f88b5e307e932a0fd5aa8 as prod-openvino USER root +# TODO: remove this once the image has the fix for https://github.com/intel/compute-runtime/issues/710 +ENV NEOReadDebugKeys=1 \ + OverrideGpuAddressSpace=48 FROM nvidia/cuda:12.2.2-cudnn8-runtime-ubuntu22.04@sha256:2d913b09e6be8387e1a10976933642c73c840c0b735f0bf3c28d97fc9bc422e0 as prod-cuda From 60427f18ce514b46a5377714a35ecf83d20fc100 Mon Sep 17 00:00:00 2001 From: Alex Date: Sat, 18 May 2024 13:15:56 -0500 Subject: [PATCH 090/163] chore(server): return duplicate assets as group (#9576) * chore(server): return duplicate assets as group * file name --- .../src/controllers/duplicate.controller.ts | 4 ++-- server/src/dtos/asset-response.dto.ts | 2 ++ server/src/dtos/duplicate.dto.ts | 19 +++++++++++++++++++ server/src/services/duplicate.service.ts | 8 +++++--- 4 files changed, 28 insertions(+), 5 deletions(-) create mode 100644 server/src/dtos/duplicate.dto.ts diff --git a/server/src/controllers/duplicate.controller.ts b/server/src/controllers/duplicate.controller.ts index ecabc0ee74..57a200ac39 100644 --- a/server/src/controllers/duplicate.controller.ts +++ b/server/src/controllers/duplicate.controller.ts @@ -1,7 +1,7 @@ import { Controller, Get } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; -import { AssetResponseDto } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; +import { DuplicateResponseDto } from 'src/dtos/duplicate.dto'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { DuplicateService } from 'src/services/duplicate.service'; @@ -12,7 +12,7 @@ export class DuplicateController { @Get() @Authenticated() - getAssetDuplicates(@Auth() auth: AuthDto): Promise { + getAssetDuplicates(@Auth() auth: AuthDto): Promise { return this.service.getDuplicates(auth); } } diff --git a/server/src/dtos/asset-response.dto.ts b/server/src/dtos/asset-response.dto.ts index 0afc906c95..879d358e8e 100644 --- a/server/src/dtos/asset-response.dto.ts +++ b/server/src/dtos/asset-response.dto.ts @@ -50,6 +50,7 @@ export class AssetResponseDto extends SanitizedAssetResponseDto { stack?: AssetResponseDto[]; @ApiProperty({ type: 'integer' }) stackCount!: number | null; + duplicateId?: string | null; } export type AssetMapOptions = { @@ -130,6 +131,7 @@ export function mapAsset(entity: AssetEntity, options: AssetMapOptions = {}): As isExternal: false, isReadOnly: false, hasMetadata: true, + duplicateId: entity.duplicateId, }; } diff --git a/server/src/dtos/duplicate.dto.ts b/server/src/dtos/duplicate.dto.ts new file mode 100644 index 0000000000..cdfeed056f --- /dev/null +++ b/server/src/dtos/duplicate.dto.ts @@ -0,0 +1,19 @@ +import { groupBy } from 'lodash'; +import { AssetResponseDto } from 'src/dtos/asset-response.dto'; + +export class DuplicateResponseDto { + duplicateId!: string; + assets!: AssetResponseDto[]; +} + +export function mapDuplicateResponse(assets: AssetResponseDto[]): DuplicateResponseDto[] { + const result = []; + + const grouped = groupBy(assets, (a) => a.duplicateId); + + for (const [duplicateId, assets] of Object.entries(grouped)) { + result.push({ duplicateId, assets }); + } + + return result; +} diff --git a/server/src/services/duplicate.service.ts b/server/src/services/duplicate.service.ts index a01e1b866a..95a12bd18e 100644 --- a/server/src/services/duplicate.service.ts +++ b/server/src/services/duplicate.service.ts @@ -1,7 +1,8 @@ import { Inject, Injectable } from '@nestjs/common'; import { SystemConfigCore } from 'src/cores/system-config.core'; -import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto'; +import { mapAsset } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; +import { DuplicateResponseDto, mapDuplicateResponse } from 'src/dtos/duplicate.dto'; import { AssetEntity } from 'src/entities/asset.entity'; import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; @@ -35,9 +36,10 @@ export class DuplicateService { this.configCore = SystemConfigCore.create(systemMetadataRepository, logger); } - async getDuplicates(auth: AuthDto): Promise { + async getDuplicates(auth: AuthDto): Promise { const res = await this.assetRepository.getDuplicates({ userIds: [auth.user.id] }); - return res.map((a) => mapAsset(a, { auth })); + + return mapDuplicateResponse(res.map((a) => mapAsset(a, { auth }))); } async handleQueueSearchDuplicates({ force }: IBaseJob): Promise { From 1ad04f0b179d746a52fb17ca9fafc1f859ca98dc Mon Sep 17 00:00:00 2001 From: Alex Date: Sat, 18 May 2024 13:50:28 -0500 Subject: [PATCH 091/163] chore(server): openapi generation (#9585) --- mobile/openapi/.openapi-generator/FILES | 3 + mobile/openapi/README.md | 1 + mobile/openapi/doc/AssetResponseDto.md | 1 + mobile/openapi/doc/DuplicateApi.md | 4 +- mobile/openapi/doc/DuplicateResponseDto.md | 16 +++ mobile/openapi/lib/api.dart | 1 + mobile/openapi/lib/api/duplicate_api.dart | 6 +- mobile/openapi/lib/api_client.dart | 2 + .../openapi/lib/model/asset_response_dto.dart | 13 ++- .../lib/model/duplicate_response_dto.dart | 106 ++++++++++++++++++ .../openapi/test/asset_response_dto_test.dart | 5 + mobile/openapi/test/duplicate_api_test.dart | 2 +- .../test/duplicate_response_dto_test.dart | 32 ++++++ open-api/immich-openapi-specs.json | 24 +++- open-api/typescript-sdk/src/fetch-client.ts | 7 +- 15 files changed, 214 insertions(+), 9 deletions(-) create mode 100644 mobile/openapi/doc/DuplicateResponseDto.md create mode 100644 mobile/openapi/lib/model/duplicate_response_dto.dart create mode 100644 mobile/openapi/test/duplicate_response_dto_test.dart diff --git a/mobile/openapi/.openapi-generator/FILES b/mobile/openapi/.openapi-generator/FILES index ae72e70d5b..69172bd977 100644 --- a/mobile/openapi/.openapi-generator/FILES +++ b/mobile/openapi/.openapi-generator/FILES @@ -70,6 +70,7 @@ doc/DownloadInfoDto.md doc/DownloadResponseDto.md doc/DuplicateApi.md doc/DuplicateDetectionConfig.md +doc/DuplicateResponseDto.md doc/EntityType.md doc/ExifResponseDto.md doc/FaceApi.md @@ -312,6 +313,7 @@ lib/model/download_archive_info.dart lib/model/download_info_dto.dart lib/model/download_response_dto.dart lib/model/duplicate_detection_config.dart +lib/model/duplicate_response_dto.dart lib/model/entity_type.dart lib/model/exif_response_dto.dart lib/model/face_dto.dart @@ -507,6 +509,7 @@ test/download_info_dto_test.dart test/download_response_dto_test.dart test/duplicate_api_test.dart test/duplicate_detection_config_test.dart +test/duplicate_response_dto_test.dart test/entity_type_test.dart test/exif_response_dto_test.dart test/face_api_test.dart diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 3cce4635cc..048d5b00a0 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -284,6 +284,7 @@ Class | Method | HTTP request | Description - [DownloadInfoDto](doc//DownloadInfoDto.md) - [DownloadResponseDto](doc//DownloadResponseDto.md) - [DuplicateDetectionConfig](doc//DuplicateDetectionConfig.md) + - [DuplicateResponseDto](doc//DuplicateResponseDto.md) - [EntityType](doc//EntityType.md) - [ExifResponseDto](doc//ExifResponseDto.md) - [FaceDto](doc//FaceDto.md) diff --git a/mobile/openapi/doc/AssetResponseDto.md b/mobile/openapi/doc/AssetResponseDto.md index 98290b3745..41a628bd54 100644 --- a/mobile/openapi/doc/AssetResponseDto.md +++ b/mobile/openapi/doc/AssetResponseDto.md @@ -11,6 +11,7 @@ Name | Type | Description | Notes **checksum** | **String** | base64 encoded sha1 hash | **deviceAssetId** | **String** | | **deviceId** | **String** | | +**duplicateId** | **String** | | [optional] **duration** | **String** | | **exifInfo** | [**ExifResponseDto**](ExifResponseDto.md) | | [optional] **fileCreatedAt** | [**DateTime**](DateTime.md) | | diff --git a/mobile/openapi/doc/DuplicateApi.md b/mobile/openapi/doc/DuplicateApi.md index 4dfbe55d3d..cdf279b69a 100644 --- a/mobile/openapi/doc/DuplicateApi.md +++ b/mobile/openapi/doc/DuplicateApi.md @@ -13,7 +13,7 @@ Method | HTTP request | Description # **getAssetDuplicates** -> List getAssetDuplicates() +> List getAssetDuplicates() @@ -50,7 +50,7 @@ This endpoint does not need any parameter. ### Return type -[**List**](AssetResponseDto.md) +[**List**](DuplicateResponseDto.md) ### Authorization diff --git a/mobile/openapi/doc/DuplicateResponseDto.md b/mobile/openapi/doc/DuplicateResponseDto.md new file mode 100644 index 0000000000..f982569996 --- /dev/null +++ b/mobile/openapi/doc/DuplicateResponseDto.md @@ -0,0 +1,16 @@ +# openapi.model.DuplicateResponseDto + +## Load the model package +```dart +import 'package:openapi/api.dart'; +``` + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**assets** | [**List**](AssetResponseDto.md) | | [default to const []] +**duplicateId** | **String** | | + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 69be2f8a95..110c4f757e 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -116,6 +116,7 @@ part 'model/download_archive_info.dart'; part 'model/download_info_dto.dart'; part 'model/download_response_dto.dart'; part 'model/duplicate_detection_config.dart'; +part 'model/duplicate_response_dto.dart'; part 'model/entity_type.dart'; part 'model/exif_response_dto.dart'; part 'model/face_dto.dart'; diff --git a/mobile/openapi/lib/api/duplicate_api.dart b/mobile/openapi/lib/api/duplicate_api.dart index 2833d091e9..ef71108b86 100644 --- a/mobile/openapi/lib/api/duplicate_api.dart +++ b/mobile/openapi/lib/api/duplicate_api.dart @@ -42,7 +42,7 @@ class DuplicateApi { ); } - Future?> getAssetDuplicates() async { + Future?> getAssetDuplicates() async { final response = await getAssetDuplicatesWithHttpInfo(); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); @@ -52,8 +52,8 @@ class DuplicateApi { // 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() + return (await apiClient.deserializeAsync(responseBody, 'List') as List) + .cast() .toList(growable: false); } diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 537d63db33..6256d0c487 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -300,6 +300,8 @@ class ApiClient { return DownloadResponseDto.fromJson(value); case 'DuplicateDetectionConfig': return DuplicateDetectionConfig.fromJson(value); + case 'DuplicateResponseDto': + return DuplicateResponseDto.fromJson(value); case 'EntityType': return EntityTypeTypeTransformer().decode(value); case 'ExifResponseDto': diff --git a/mobile/openapi/lib/model/asset_response_dto.dart b/mobile/openapi/lib/model/asset_response_dto.dart index 86dec6392f..8802bb03ab 100644 --- a/mobile/openapi/lib/model/asset_response_dto.dart +++ b/mobile/openapi/lib/model/asset_response_dto.dart @@ -16,6 +16,7 @@ class AssetResponseDto { required this.checksum, required this.deviceAssetId, required this.deviceId, + this.duplicateId, required this.duration, this.exifInfo, required this.fileCreatedAt, @@ -54,6 +55,8 @@ class AssetResponseDto { String deviceId; + String? duplicateId; + String duration; /// @@ -149,6 +152,7 @@ class AssetResponseDto { other.checksum == checksum && other.deviceAssetId == deviceAssetId && other.deviceId == deviceId && + other.duplicateId == duplicateId && other.duration == duration && other.exifInfo == exifInfo && other.fileCreatedAt == fileCreatedAt && @@ -185,6 +189,7 @@ class AssetResponseDto { (checksum.hashCode) + (deviceAssetId.hashCode) + (deviceId.hashCode) + + (duplicateId == null ? 0 : duplicateId!.hashCode) + (duration.hashCode) + (exifInfo == null ? 0 : exifInfo!.hashCode) + (fileCreatedAt.hashCode) + @@ -216,13 +221,18 @@ class AssetResponseDto { (updatedAt.hashCode); @override - String toString() => 'AssetResponseDto[checksum=$checksum, deviceAssetId=$deviceAssetId, deviceId=$deviceId, duration=$duration, exifInfo=$exifInfo, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, hasMetadata=$hasMetadata, id=$id, isArchived=$isArchived, isExternal=$isExternal, isFavorite=$isFavorite, isOffline=$isOffline, isReadOnly=$isReadOnly, isTrashed=$isTrashed, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, originalPath=$originalPath, owner=$owner, ownerId=$ownerId, people=$people, resized=$resized, smartInfo=$smartInfo, stack=$stack, stackCount=$stackCount, stackParentId=$stackParentId, tags=$tags, thumbhash=$thumbhash, type=$type, updatedAt=$updatedAt]'; + String toString() => 'AssetResponseDto[checksum=$checksum, deviceAssetId=$deviceAssetId, deviceId=$deviceId, duplicateId=$duplicateId, duration=$duration, exifInfo=$exifInfo, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, hasMetadata=$hasMetadata, id=$id, isArchived=$isArchived, isExternal=$isExternal, isFavorite=$isFavorite, isOffline=$isOffline, isReadOnly=$isReadOnly, isTrashed=$isTrashed, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, originalPath=$originalPath, owner=$owner, ownerId=$ownerId, people=$people, resized=$resized, smartInfo=$smartInfo, stack=$stack, stackCount=$stackCount, stackParentId=$stackParentId, tags=$tags, thumbhash=$thumbhash, type=$type, updatedAt=$updatedAt]'; Map toJson() { final json = {}; json[r'checksum'] = this.checksum; json[r'deviceAssetId'] = this.deviceAssetId; json[r'deviceId'] = this.deviceId; + if (this.duplicateId != null) { + json[r'duplicateId'] = this.duplicateId; + } else { + // json[r'duplicateId'] = null; + } json[r'duration'] = this.duration; if (this.exifInfo != null) { json[r'exifInfo'] = this.exifInfo; @@ -302,6 +312,7 @@ class AssetResponseDto { checksum: mapValueOfType(json, r'checksum')!, deviceAssetId: mapValueOfType(json, r'deviceAssetId')!, deviceId: mapValueOfType(json, r'deviceId')!, + duplicateId: mapValueOfType(json, r'duplicateId'), duration: mapValueOfType(json, r'duration')!, exifInfo: ExifResponseDto.fromJson(json[r'exifInfo']), fileCreatedAt: mapDateTime(json, r'fileCreatedAt', r'')!, diff --git a/mobile/openapi/lib/model/duplicate_response_dto.dart b/mobile/openapi/lib/model/duplicate_response_dto.dart new file mode 100644 index 0000000000..b93ecfe5f5 --- /dev/null +++ b/mobile/openapi/lib/model/duplicate_response_dto.dart @@ -0,0 +1,106 @@ +// +// 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 DuplicateResponseDto { + /// Returns a new [DuplicateResponseDto] instance. + DuplicateResponseDto({ + this.assets = const [], + required this.duplicateId, + }); + + List assets; + + String duplicateId; + + @override + bool operator ==(Object other) => identical(this, other) || other is DuplicateResponseDto && + _deepEquality.equals(other.assets, assets) && + other.duplicateId == duplicateId; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (assets.hashCode) + + (duplicateId.hashCode); + + @override + String toString() => 'DuplicateResponseDto[assets=$assets, duplicateId=$duplicateId]'; + + Map toJson() { + final json = {}; + json[r'assets'] = this.assets; + json[r'duplicateId'] = this.duplicateId; + return json; + } + + /// Returns a new [DuplicateResponseDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static DuplicateResponseDto? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return DuplicateResponseDto( + assets: AssetResponseDto.listFromJson(json[r'assets']), + duplicateId: mapValueOfType(json, r'duplicateId')!, + ); + } + 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 = DuplicateResponseDto.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 = DuplicateResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of DuplicateResponseDto-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] = DuplicateResponseDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'assets', + 'duplicateId', + }; +} + diff --git a/mobile/openapi/test/asset_response_dto_test.dart b/mobile/openapi/test/asset_response_dto_test.dart index 6e927e6014..e666a3bb7e 100644 --- a/mobile/openapi/test/asset_response_dto_test.dart +++ b/mobile/openapi/test/asset_response_dto_test.dart @@ -32,6 +32,11 @@ void main() { // TODO }); + // String duplicateId + test('to test the property `duplicateId`', () async { + // TODO + }); + // String duration test('to test the property `duration`', () async { // TODO diff --git a/mobile/openapi/test/duplicate_api_test.dart b/mobile/openapi/test/duplicate_api_test.dart index 8e22a52533..50a090bc3b 100644 --- a/mobile/openapi/test/duplicate_api_test.dart +++ b/mobile/openapi/test/duplicate_api_test.dart @@ -17,7 +17,7 @@ void main() { // final instance = DuplicateApi(); group('tests for DuplicateApi', () { - //Future> getAssetDuplicates() async + //Future> getAssetDuplicates() async test('test getAssetDuplicates', () async { // TODO }); diff --git a/mobile/openapi/test/duplicate_response_dto_test.dart b/mobile/openapi/test/duplicate_response_dto_test.dart new file mode 100644 index 0000000000..a531c133cc --- /dev/null +++ b/mobile/openapi/test/duplicate_response_dto_test.dart @@ -0,0 +1,32 @@ +// +// 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 + +import 'package:openapi/api.dart'; +import 'package:test/test.dart'; + +// tests for DuplicateResponseDto +void main() { + // final instance = DuplicateResponseDto(); + + group('test DuplicateResponseDto', () { + // List assets (default value: const []) + test('to test the property `assets`', () async { + // TODO + }); + + // String duplicateId + test('to test the property `duplicateId`', () async { + // TODO + }); + + + }); + +} diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 7fbf5f8302..8fc5378edf 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -2231,7 +2231,7 @@ "application/json": { "schema": { "items": { - "$ref": "#/components/schemas/AssetResponseDto" + "$ref": "#/components/schemas/DuplicateResponseDto" }, "type": "array" } @@ -7318,6 +7318,10 @@ "deviceId": { "type": "string" }, + "duplicateId": { + "nullable": true, + "type": "string" + }, "duration": { "type": "string" }, @@ -7930,6 +7934,24 @@ ], "type": "object" }, + "DuplicateResponseDto": { + "properties": { + "assets": { + "items": { + "$ref": "#/components/schemas/AssetResponseDto" + }, + "type": "array" + }, + "duplicateId": { + "type": "string" + } + }, + "required": [ + "assets", + "duplicateId" + ], + "type": "object" + }, "EntityType": { "enum": [ "ASSET", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 92396c360f..f0af90a8dd 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -115,6 +115,7 @@ export type AssetResponseDto = { checksum: string; deviceAssetId: string; deviceId: string; + duplicateId?: string | null; duration: string; exifInfo?: ExifResponseDto; fileCreatedAt: string; @@ -372,6 +373,10 @@ export type DownloadResponseDto = { archives: DownloadArchiveInfo[]; totalSize: number; }; +export type DuplicateResponseDto = { + assets: AssetResponseDto[]; + duplicateId: string; +}; export type PersonResponseDto = { birthDate: string | null; id: string; @@ -1698,7 +1703,7 @@ export function getDownloadInfo({ key, downloadInfoDto }: { export function getAssetDuplicates(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; - data: AssetResponseDto[]; + data: DuplicateResponseDto[]; }>("/duplicates", { ...opts })); From 470c95d614fa5cfcd9d40436a6089e279d5c8a55 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Sat, 18 May 2024 14:50:53 -0400 Subject: [PATCH 092/163] chore: vs code formatter settings (#9562) --- .vscode/settings.json | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 3267c67132..a8661326a0 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,6 +1,16 @@ { "editor.formatOnSave": true, - "[javascript][typescript][css]": { + "[javascript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.tabSize": 2, + "editor.formatOnSave": true + }, + "[typescript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.tabSize": 2, + "editor.formatOnSave": true + }, + "[css]": { "editor.defaultFormatter": "esbenp.prettier-vscode", "editor.tabSize": 2, "editor.formatOnSave": true @@ -31,4 +41,4 @@ "explorer.fileNesting.patterns": { "*.ts": "${capture}.spec.ts,${capture}.mock.ts" } -} \ No newline at end of file +} From 1e56352b041c313184f34894ea12b5e77af1a5cc Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 19 May 2024 12:34:09 +0200 Subject: [PATCH 093/163] chore(deps): update registry.hub.docker.com/library/redis:6.2-alpine docker digest to c0634a0 (#9578) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- docker/docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 16d628258e..6023150704 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -40,7 +40,7 @@ services: redis: container_name: immich_redis - image: registry.hub.docker.com/library/redis:6.2-alpine@sha256:84882e87b54734154586e5f8abd4dce69fe7311315e2fc6d67c29614c8de2672 + image: registry.hub.docker.com/library/redis:6.2-alpine@sha256:c0634a08e74a4bb576d02d1ee993dc05dba10e8b7b9492dfa28a7af100d46c01 restart: always database: From e39ee8a16f63789c9d9c2fa3798086626e226641 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robert=20Sch=C3=A4fer?= Date: Sun, 19 May 2024 18:13:40 +0530 Subject: [PATCH 094/163] docs: Add pgadmin4 to docker-compose.yml (#9556) * docs: Add pgadmin4 to docker-compose.yml Motivation ---------- The current documentation encourages to install pgAdmin3 on the host system. It's much simpler to add `pgAdmin4` to the `docker-compose.yml`: 1. No configuration needs to be modified, just added. 2. Better security because no additional ports need to be opened on the host. 3. Easier installation. E.g. on Archlinux there is no package pgAdmin3 anymore. 4. `pgAdmin3` does not seem to be maintained. How to test ----------- 1. Follow the documentation. 2. See if you can connect to the immich database * docs: better use separate config file I assume most users will not edit the `docker-compose.yml` and forget about `pgAdmin` once they're done. So, `pgAdmin` might get exposed to the internet with default credentials, which is not good. Better to leave a hint to change the credentials and keep the configuration separate, so users start `pgAdmin` knowingly and turn it off once they're done. --- docs/docs/guides/database-gui.md | 72 +++++++++--------- docs/docs/guides/img/Connection-Pgadmin.png | Bin 36800 -> 0 bytes .../docs/guides/img/add-new-server-option.png | Bin 39500 -> 0 bytes .../guides/img/pgadmin-add-new-server.png | Bin 0 -> 69674 bytes 4 files changed, 38 insertions(+), 34 deletions(-) delete mode 100644 docs/docs/guides/img/Connection-Pgadmin.png delete mode 100644 docs/docs/guides/img/add-new-server-option.png create mode 100644 docs/docs/guides/img/pgadmin-add-new-server.png diff --git a/docs/docs/guides/database-gui.md b/docs/docs/guides/database-gui.md index 1b0bd7931c..e0d6da1cbf 100644 --- a/docs/docs/guides/database-gui.md +++ b/docs/docs/guides/database-gui.md @@ -2,48 +2,52 @@ A short guide on connecting [pgAdmin](https://www.pgadmin.org/) to Immich. -:::note - -In order to connect to the database the immich_postgres container **must be running**. - -The passwords and usernames used below match the ones specified in the example `.env` file. If changed, please use actual values instead. - -**Optional:** To connect to the database **outside** of your Docker's network: - -- Expose port 5432 in your `docker-compose.yml` file. -- Edit the PostgreSQL [`pg_hba.conf`](https://www.postgresql.org/docs/current/auth-pg-hba-conf.html) file. -- Make sure your firewall does not block access to port 5432. - Note that exposing the database port increases the risk of getting attacked by hackers. - Make sure to remove the binding port after finishing the database's tasks. - -::: - ## 1. Install pgAdmin -Download and install [pgAdmin](https://www.pgadmin.org/download/) following the official documentation. +Add a file `docker-compose-pgadmin.yml` next to your `docker-compose.yml` with the following content: + +``` +name: immich + +services: + pgadmin: + image: dpage/pgadmin4 + container_name: pgadmin4_container + restart: always + ports: + - "8888:80" + environment: + PGADMIN_DEFAULT_EMAIL: user-name@domain-name.com + PGADMIN_DEFAULT_PASSWORD: strong-password + volumes: + - pgadmin-data:/var/lib/pgadmin + +volumes: + pgadmin-data: +``` + +Change the values of `PGADMIN_DEFAULT_EMAIL` and `PGADMIN_DEFAULT_PASSWORD` in this file. + +Run `docker compose -f docker-compose.yml -f docker-compose-pgadmin.yml up` to start immich along with `pgAdmin`. ## 2. Add a Server -Open pgAdmin and click "Add New Server". +Open [localhost:8888](http://localhost:8888) and login with the default credentials from above. - +Right click on `Servers` and click on `Register >> Server..` then enter the values below in the `Connection` tab. -## 3. Enter Connection Details + -| Name | Value | -| -------------------- | ----------- | -| Host name/address | `localhost` | -| Port | `5432` | -| Maintenance database | `immich` | -| Username | `postgres` | -| Password | `postgres` | +:::note +The parameters used here match those specified in the example `.env` file. If you have changed your `.env` file, you'll need to adjust accordingly. +::: - - -## 4. Save Connection +| Name | Value | +| -------------------- | ----------------- | +| Host name/address | `immich_postgres` | +| Port | `5432` | +| Maintenance database | `immich` | +| Username | `postgres` | +| Password | `postgres` | Click on "Save" to connect to the Immich database. - -:::tip -View [Database Queries](/docs/guides/database-queries/) for common database queries. -::: diff --git a/docs/docs/guides/img/Connection-Pgadmin.png b/docs/docs/guides/img/Connection-Pgadmin.png deleted file mode 100644 index a5bcb2f91d29d0ebfd3af64ecc57d6efecb1f260..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 36800 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 diff --git a/docs/docs/guides/img/add-new-server-option.png b/docs/docs/guides/img/add-new-server-option.png deleted file mode 100644 index 934c8c792fee721a1cecd3fd1770ba043c3ad123..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 39500 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 diff --git a/docs/docs/guides/img/pgadmin-add-new-server.png b/docs/docs/guides/img/pgadmin-add-new-server.png new file mode 100644 index 0000000000000000000000000000000000000000..978602e04d29236d33b943b5235a17dc720588af GIT binary patch literal 69674 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 literal 0 HcmV?d00001 From 451416ec88e85c0a74b62723cf9529e91172b05d Mon Sep 17 00:00:00 2001 From: Matthew Momjian <50788000+mmomjian@users.noreply.github.com> Date: Sun, 19 May 2024 15:50:46 -0400 Subject: [PATCH 095/163] docs: community docs typos (#9604) typos --- docs/src/components/community-guides.tsx | 8 ++++---- docs/src/components/community-projects.tsx | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/src/components/community-guides.tsx b/docs/src/components/community-guides.tsx index 98e52bfcae..d69d7ef92c 100644 --- a/docs/src/components/community-guides.tsx +++ b/docs/src/components/community-guides.tsx @@ -10,17 +10,17 @@ interface CommunityGuidesProps { const guides: CommunityGuidesProps[] = [ { title: 'Cloudflare Tunnels with SSO/OAuth', - description: `Setting up Cloudflare Tunnels and a SaaS App for immich.`, + description: `Setting up Cloudflare Tunnels and a SaaS App for Immich.`, url: 'https://github.com/immich-app/immich/discussions/8299', }, { - title: 'Database backup in Truenas', - description: `Create a database backup with pgAdmin in Truenas.`, + title: 'Database backup in TrueNAS', + description: `Create a database backup with pgAdmin in TrueNAS.`, url: 'https://github.com/immich-app/immich/discussions/8809', }, { title: 'Unraid backup scripts', - description: `Back up your assets in Unarid with a pre-prepared script.`, + description: `Back up your assets in Unraid with a pre-prepared script.`, url: 'https://github.com/immich-app/immich/discussions/8416', }, { diff --git a/docs/src/components/community-projects.tsx b/docs/src/components/community-projects.tsx index d5796ebfb7..9b602f4e08 100644 --- a/docs/src/components/community-projects.tsx +++ b/docs/src/components/community-projects.tsx @@ -10,7 +10,7 @@ interface CommunityProjectProps { const projects: CommunityProjectProps[] = [ { title: 'immich-go', - description: `An alternative to the immich-CLI command that doesn't depend on nodejs installation. It tries its best for importing google photos takeout archives.`, + description: `An alternative to the immich-CLI that doesn't depend on nodejs. It specializes in importing Google Photos Takeout archives.`, url: 'https://github.com/simulot/immich-go', }, { From 39129721fa30985f354e2c3562dd4e6527cde017 Mon Sep 17 00:00:00 2001 From: Snowknight26 Date: Mon, 20 May 2024 07:20:08 -0500 Subject: [PATCH 096/163] fix(web): fix add to album modal text spacing (#9606) * fix(web): fix add to album modal text spacing * Fix formatting --- web/src/lib/components/asset-viewer/album-list-item.svelte | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/web/src/lib/components/asset-viewer/album-list-item.svelte b/web/src/lib/components/asset-viewer/album-list-item.svelte index 7261b3910a..23c06e1bd5 100644 --- a/web/src/lib/components/asset-viewer/album-list-item.svelte +++ b/web/src/lib/components/asset-viewer/album-list-item.svelte @@ -50,8 +50,7 @@ {#if variant === 'simple'} {album.shared ? 'Shared' : ''} {:else} - {album.assetCount} items - {album.shared ? ' · Shared' : ''} + {album.assetCount} items{album.shared ? ' - Shared' : ''} {/if}

From 5c8c0b2f5b8965873412e28d28c87031b1dc0dfd Mon Sep 17 00:00:00 2001 From: Snowknight26 Date: Mon, 20 May 2024 07:21:01 -0500 Subject: [PATCH 097/163] fix(web): prevent asset grid dates from being truncated (#9603) --- web/src/lib/components/photos-page/asset-date-group.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 a42f8dc786..c6b9998d85 100644 --- a/web/src/lib/components/photos-page/asset-date-group.svelte +++ b/web/src/lib/components/photos-page/asset-date-group.svelte @@ -153,7 +153,7 @@
{/if} - + {groupTitle}
From c37bf9d5d0ec53e729b32e6a8f6ba49a38146338 Mon Sep 17 00:00:00 2001 From: Parsa <59599626+parsapoorsh@users.noreply.github.com> Date: Mon, 20 May 2024 18:28:47 +0330 Subject: [PATCH 098/163] fix: docker compose pull rate limit (#9600) * fix: docker compose pull rate limit with "registry.hub.docker.com/" behind the image name, there was an issue where "docker compose up -d" would throw a rate-limiting error, even when logged in using a docker account. it doesn't really matter where the image is downloaded from as long as it has the same sha256 hash in docker-compose.yml * fix: use `docker.io/` for image reference in docker-compose.yml --- docker/docker-compose.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 6023150704..6884636756 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -40,12 +40,12 @@ services: redis: container_name: immich_redis - image: registry.hub.docker.com/library/redis:6.2-alpine@sha256:c0634a08e74a4bb576d02d1ee993dc05dba10e8b7b9492dfa28a7af100d46c01 + image: docker.io/redis:6.2-alpine@sha256:c0634a08e74a4bb576d02d1ee993dc05dba10e8b7b9492dfa28a7af100d46c01 restart: always database: container_name: immich_postgres - image: registry.hub.docker.com/tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:90724186f0a3517cf6914295b5ab410db9ce23190a2d9d0b9dd6463e3fa298f0 + image: docker.io/tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:90724186f0a3517cf6914295b5ab410db9ce23190a2d9d0b9dd6463e3fa298f0 environment: POSTGRES_PASSWORD: ${DB_PASSWORD} POSTGRES_USER: ${DB_USERNAME} From 4353153fe64fe5ec6282369c7a4e3265ebf51064 Mon Sep 17 00:00:00 2001 From: Eric Barch Date: Mon, 20 May 2024 10:59:27 -0400 Subject: [PATCH 099/163] fix(web): render failed upload buttons correctly on mobile (#9601) fix(web): Failed upload buttons render correctly on mobile --- .../upload-asset-preview.svelte | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/web/src/lib/components/shared-components/upload-asset-preview.svelte b/web/src/lib/components/shared-components/upload-asset-preview.svelte index a442fefcd9..a517d66abc 100644 --- a/web/src/lib/components/shared-components/upload-asset-preview.svelte +++ b/web/src/lib/components/shared-components/upload-asset-preview.svelte @@ -24,8 +24,8 @@ out:fade={{ duration: 100 }} class="flex flex-col rounded-lg bg-immich-bg text-xs dark:bg-immich-dark-bg" > -
-
+
+
@@ -83,18 +83,14 @@
{#if uploadAsset.state === UploadState.ERROR} -
- @@ -104,7 +100,7 @@ {#if uploadAsset.state === UploadState.ERROR}
-

+

{uploadAsset.error}

From 84d824d6a7e16ee813eca186cff6bdd434190e96 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Mon, 20 May 2024 18:09:10 -0400 Subject: [PATCH 100/163] refactor: library type (#9525) --- docs/docs/administration/repair-page.md | 2 +- docs/docs/features/libraries.md | 4 - e2e/src/api/specs/asset.e2e-spec.ts | 21 -- e2e/src/api/specs/library.e2e-spec.ts | 158 +-------------- e2e/src/responses.ts | 5 - mobile/openapi/.openapi-generator/FILES | 3 - mobile/openapi/README.md | 1 - mobile/openapi/doc/AssetApi.md | 6 +- mobile/openapi/doc/AssetResponseDto.md | 2 +- mobile/openapi/doc/CreateLibraryDto.md | 1 - mobile/openapi/doc/LibraryApi.md | 10 +- mobile/openapi/doc/LibraryResponseDto.md | 1 - mobile/openapi/doc/LibraryType.md | 14 -- mobile/openapi/lib/api.dart | 1 - mobile/openapi/lib/api/asset_api.dart | 14 +- mobile/openapi/lib/api/library_api.dart | 16 +- mobile/openapi/lib/api_client.dart | 2 - mobile/openapi/lib/api_helper.dart | 3 - .../openapi/lib/model/asset_response_dto.dart | 14 +- .../openapi/lib/model/create_library_dto.dart | 14 +- .../lib/model/library_response_dto.dart | 10 +- mobile/openapi/lib/model/library_type.dart | 85 -------- mobile/openapi/test/asset_api_test.dart | 2 +- .../openapi/test/asset_response_dto_test.dart | 1 + .../openapi/test/create_library_dto_test.dart | 5 - mobile/openapi/test/library_api_test.dart | 2 +- .../test/library_response_dto_test.dart | 5 - mobile/openapi/test/library_type_test.dart | 21 -- open-api/immich-openapi-specs.json | 36 +--- open-api/typescript-sdk/src/fetch-client.ts | 18 +- server/src/controllers/library.controller.ts | 7 +- server/src/cores/access.core.ts | 2 +- server/src/cores/user.core.ts | 12 +- server/src/dtos/asset-response.dto.ts | 3 +- server/src/dtos/asset-v1.dto.ts | 5 +- server/src/dtos/library.dto.ts | 19 +- server/src/entities/asset.entity.ts | 17 +- server/src/entities/library.entity.ts | 8 - server/src/interfaces/access.interface.ts | 4 - server/src/interfaces/asset.interface.ts | 2 +- server/src/interfaces/library.interface.ts | 6 +- .../1715804005643-RemoveLibraryType.ts | 29 +++ server/src/queries/access.repository.sql | 14 -- server/src/queries/asset.repository.sql | 26 +-- server/src/queries/library.repository.sql | 46 ----- server/src/queries/user.repository.sql | 4 +- server/src/repositories/access.repository.ts | 25 --- server/src/repositories/asset.repository.ts | 14 +- server/src/repositories/library.repository.ts | 31 +-- server/src/repositories/user.repository.ts | 4 +- server/src/services/asset-v1.service.spec.ts | 4 - server/src/services/asset-v1.service.ts | 38 ++-- server/src/services/asset.service.ts | 5 +- server/src/services/download.service.spec.ts | 3 - server/src/services/library.service.spec.ts | 186 +++--------------- server/src/services/library.service.ts | 59 ++---- server/src/services/metadata.service.ts | 2 +- server/test/fixtures/asset.stub.ts | 40 ---- server/test/fixtures/error.stub.ts | 5 - server/test/fixtures/library.stub.ts | 22 +-- server/test/fixtures/shared-link.stub.ts | 3 - .../repositories/access.repository.mock.ts | 5 - .../repositories/library.repository.mock.ts | 2 - .../forms/library-scan-settings-form.svelte | 6 +- .../admin/library-management/+page.svelte | 30 +-- web/src/routes/admin/repair/+page.svelte | 2 +- 66 files changed, 183 insertions(+), 984 deletions(-) delete mode 100644 mobile/openapi/doc/LibraryType.md delete mode 100644 mobile/openapi/lib/model/library_type.dart delete mode 100644 mobile/openapi/test/library_type_test.dart create mode 100644 server/src/migrations/1715804005643-RemoveLibraryType.ts diff --git a/docs/docs/administration/repair-page.md b/docs/docs/administration/repair-page.md index e5022970a2..f230c6d582 100644 --- a/docs/docs/administration/repair-page.md +++ b/docs/docs/administration/repair-page.md @@ -18,7 +18,7 @@ In any other situation, there are 3 different options that can appear: - MATCHES - These files are matched by their checksums. -- OFFLINE PATHS - These files are the result of manually deleting files in the upload library or a failed file move in the past (losing track of a file). +- OFFLINE PATHS - These files are the result of manually deleting files from immich or a failed file move in the past (losing track of a file). - UNTRACKED FILES - These files are not tracked by the application. They can be the result of failed moves, interrupted uploads, or left behind due to a bug. diff --git a/docs/docs/features/libraries.md b/docs/docs/features/libraries.md index 40b4b56b09..782dfdac14 100644 --- a/docs/docs/features/libraries.md +++ b/docs/docs/features/libraries.md @@ -4,10 +4,6 @@ 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. -## The Upload Library - -Immich comes preconfigured with an upload library for each user. All assets uploaded to Immich are added to this library. This library can be renamed, but not deleted. The upload library is the only library that can be synced with a mobile device. No items in an upload library is allowed to have the same sha1 hash as another item in the same library in order to prevent duplicates. - ## External Libraries 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. diff --git a/e2e/src/api/specs/asset.e2e-spec.ts b/e2e/src/api/specs/asset.e2e-spec.ts index 050fa9b1e2..50b84fd9b0 100644 --- a/e2e/src/api/specs/asset.e2e-spec.ts +++ b/e2e/src/api/specs/asset.e2e-spec.ts @@ -2,10 +2,8 @@ import { AssetFileUploadResponseDto, AssetResponseDto, AssetTypeEnum, - LibraryResponseDto, LoginResponseDto, SharedLinkType, - getAllLibraries, getAssetInfo, updateAssets, } from '@immich/sdk'; @@ -819,25 +817,6 @@ describe('/asset', () => { expect(duplicate).toBe(true); }); - it("should not upload to another user's library", async () => { - const libraries = await getAllLibraries({}, { headers: asBearerAuth(admin.accessToken) }); - const library = libraries.find((library) => library.ownerId === user1.userId) as LibraryResponseDto; - - const { body, status } = await request(app) - .post('/asset/upload') - .set('Authorization', `Bearer ${admin.accessToken}`) - .field('libraryId', library.id) - .field('deviceAssetId', 'example-image') - .field('deviceId', 'e2e') - .field('fileCreatedAt', new Date().toISOString()) - .field('fileModifiedAt', new Date().toISOString()) - .field('duration', '0:00:00.000000') - .attach('assetData', makeRandomImage(), 'example.png'); - - expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest('Not found or no asset.upload access')); - }); - it('should update the used quota', async () => { const { body, status } = await request(app) .post('/asset/upload') diff --git a/e2e/src/api/specs/library.e2e-spec.ts b/e2e/src/api/specs/library.e2e-spec.ts index 18becec770..f31a20e27c 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, - LibraryType, - LoginResponseDto, - ScanLibraryDto, - getAllLibraries, - scanLibrary, -} from '@immich/sdk'; +import { LibraryResponseDto, LoginResponseDto, ScanLibraryDto, getAllLibraries, scanLibrary } from '@immich/sdk'; import { cpSync, existsSync } from 'node:fs'; import { Socket } from 'socket.io-client'; import { userDto, uuidDto } from 'src/fixtures'; @@ -29,7 +22,7 @@ describe('/library', () => { admin = await utils.adminSetup(); await utils.resetAdminConfig(admin.accessToken); user = await utils.userSetup(admin.accessToken, userDto.user1); - library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId, type: LibraryType.External }); + library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId }); websocket = await utils.connectWebsocket(admin.accessToken); utils.createImageFile(`${testAssetDir}/temp/directoryA/assetA.png`); utils.createImageFile(`${testAssetDir}/temp/directoryB/assetB.png`); @@ -50,24 +43,6 @@ describe('/library', () => { expect(status).toBe(401); expect(body).toEqual(errorDto.unauthorized); }); - - it('should start with a default upload library', async () => { - const { status, body } = await request(app).get('/library').set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toBe(200); - expect(body).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - ownerId: admin.userId, - type: LibraryType.Upload, - name: 'Default Library', - refreshedAt: null, - assetCount: 0, - importPaths: [], - exclusionPatterns: [], - }), - ]), - ); - }); }); describe('POST /library', () => { @@ -81,7 +56,7 @@ describe('/library', () => { const { status, body } = await request(app) .post('/library') .set('Authorization', `Bearer ${user.accessToken}`) - .send({ ownerId: admin.userId, type: LibraryType.External }); + .send({ ownerId: admin.userId }); expect(status).toBe(403); expect(body).toEqual(errorDto.forbidden); @@ -91,13 +66,12 @@ describe('/library', () => { const { status, body } = await request(app) .post('/library') .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ ownerId: admin.userId, type: LibraryType.External }); + .send({ ownerId: admin.userId }); expect(status).toBe(201); expect(body).toEqual( expect.objectContaining({ ownerId: admin.userId, - type: LibraryType.External, name: 'New External Library', refreshedAt: null, assetCount: 0, @@ -113,7 +87,6 @@ describe('/library', () => { .set('Authorization', `Bearer ${admin.accessToken}`) .send({ ownerId: admin.userId, - type: LibraryType.External, name: 'My Awesome Library', importPaths: ['/path/to/import'], exclusionPatterns: ['**/Raw/**'], @@ -134,7 +107,6 @@ describe('/library', () => { .set('Authorization', `Bearer ${admin.accessToken}`) .send({ ownerId: admin.userId, - type: LibraryType.External, name: 'My Awesome Library', importPaths: ['/path', '/path'], exclusionPatterns: ['**/Raw/**'], @@ -150,7 +122,6 @@ describe('/library', () => { .set('Authorization', `Bearer ${admin.accessToken}`) .send({ ownerId: admin.userId, - type: LibraryType.External, name: 'My Awesome Library', importPaths: ['/path/to/import'], exclusionPatterns: ['**/Raw/**', '**/Raw/**'], @@ -159,60 +130,6 @@ describe('/library', () => { expect(status).toBe(400); expect(body).toEqual(errorDto.badRequest(["All exclusionPatterns's elements must be unique"])); }); - - it('should create an upload library with defaults', async () => { - const { status, body } = await request(app) - .post('/library') - .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ ownerId: admin.userId, type: LibraryType.Upload }); - - expect(status).toBe(201); - expect(body).toEqual( - expect.objectContaining({ - ownerId: admin.userId, - type: LibraryType.Upload, - name: 'New Upload Library', - refreshedAt: null, - assetCount: 0, - importPaths: [], - exclusionPatterns: [], - }), - ); - }); - - it('should create an upload library with options', async () => { - const { status, body } = await request(app) - .post('/library') - .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ ownerId: admin.userId, type: LibraryType.Upload, name: 'My Awesome Library' }); - - expect(status).toBe(201); - expect(body).toEqual( - expect.objectContaining({ - name: 'My Awesome Library', - }), - ); - }); - - it('should not allow upload libraries to have import paths', async () => { - const { status, body } = await request(app) - .post('/library') - .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ ownerId: admin.userId, type: LibraryType.Upload, importPaths: ['/path/to/import'] }); - - expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest('Upload libraries cannot have import paths')); - }); - - it('should not allow upload libraries to have exclusion patterns', async () => { - const { status, body } = await request(app) - .post('/library') - .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ ownerId: admin.userId, type: LibraryType.Upload, exclusionPatterns: ['**/Raw/**'] }); - - expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest('Upload libraries cannot have exclusion patterns')); - }); }); describe('PUT /library/:id', () => { @@ -332,10 +249,7 @@ describe('/library', () => { }); it('should get library by id', async () => { - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - type: LibraryType.External, - }); + const library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId }); const { status, body } = await request(app) .get(`/library/${library.id}`) @@ -345,7 +259,6 @@ describe('/library', () => { expect(body).toEqual( expect.objectContaining({ ownerId: admin.userId, - type: LibraryType.External, name: 'New External Library', refreshedAt: null, assetCount: 0, @@ -373,24 +286,9 @@ describe('/library', () => { expect(body).toEqual(errorDto.unauthorized); }); - it('should not scan an upload library', async () => { - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - type: LibraryType.Upload, - }); - - const { status, body } = await request(app) - .post(`/library/${library.id}/scan`) - .set('Authorization', `Bearer ${admin.accessToken}`); - - expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest('Can only refresh external libraries')); - }); - it('should scan external library', async () => { const library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId, - type: LibraryType.External, importPaths: [`${testAssetDirInternal}/temp/directoryA`], }); @@ -406,7 +304,6 @@ describe('/library', () => { it('should scan external library with exclusion pattern', async () => { const library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId, - type: LibraryType.External, importPaths: [`${testAssetDirInternal}/temp`], exclusionPatterns: ['**/directoryA'], }); @@ -423,7 +320,6 @@ describe('/library', () => { it('should scan multiple import paths', async () => { const library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId, - type: LibraryType.External, importPaths: [`${testAssetDirInternal}/temp/directoryA`, `${testAssetDirInternal}/temp/directoryB`], }); @@ -440,7 +336,6 @@ describe('/library', () => { it('should pick up new files', async () => { const library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId, - type: LibraryType.External, importPaths: [`${testAssetDirInternal}/temp`], }); @@ -466,7 +361,6 @@ describe('/library', () => { utils.createImageFile(`${testAssetDir}/temp/directoryA/assetB.png`); const library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId, - type: LibraryType.External, importPaths: [`${testAssetDirInternal}/temp`], }); @@ -493,7 +387,6 @@ describe('/library', () => { it('should scan new files', async () => { const library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId, - type: LibraryType.External, importPaths: [`${testAssetDirInternal}/temp`], }); @@ -521,7 +414,6 @@ describe('/library', () => { it('should reimport modified files', async () => { const library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId, - type: LibraryType.External, importPaths: [`${testAssetDirInternal}/temp`], }); @@ -549,7 +441,6 @@ describe('/library', () => { it('should not reimport unmodified files', async () => { const library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId, - type: LibraryType.External, importPaths: [`${testAssetDirInternal}/temp`], }); @@ -579,7 +470,6 @@ describe('/library', () => { it('should reimport all files', async () => { const library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId, - type: LibraryType.External, importPaths: [`${testAssetDirInternal}/temp`], }); @@ -617,7 +507,6 @@ describe('/library', () => { it('should remove offline files', async () => { const library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId, - type: LibraryType.External, importPaths: [`${testAssetDirInternal}/temp`], }); @@ -658,7 +547,6 @@ describe('/library', () => { it('should not remove online files', async () => { const library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId, - type: LibraryType.External, importPaths: [`${testAssetDirInternal}/temp`], }); @@ -737,37 +625,8 @@ describe('/library', () => { expect(body).toEqual(errorDto.unauthorized); }); - it('should not delete the last upload library', async () => { - const libraries = await getAllLibraries( - { $type: LibraryType.Upload }, - { headers: asBearerAuth(admin.accessToken) }, - ); - - const adminLibraries = libraries.filter((library) => library.ownerId === admin.userId); - expect(adminLibraries.length).toBeGreaterThanOrEqual(1); - const lastLibrary = adminLibraries.pop() as LibraryResponseDto; - - // delete all but the last upload library - for (const library of adminLibraries) { - const { status } = await request(app) - .delete(`/library/${library.id}`) - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toBe(204); - } - - const { status, body } = await request(app) - .delete(`/library/${lastLibrary.id}`) - .set('Authorization', `Bearer ${admin.accessToken}`); - - expect(body).toEqual(errorDto.noDeleteUploadLibrary); - expect(status).toBe(400); - }); - it('should delete an external library', async () => { - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - type: LibraryType.External, - }); + const library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId }); const { status, body } = await request(app) .delete(`/library/${library.id}`) @@ -776,7 +635,7 @@ describe('/library', () => { expect(status).toBe(204); expect(body).toEqual({}); - const libraries = await getAllLibraries({}, { headers: asBearerAuth(admin.accessToken) }); + const libraries = await getAllLibraries({ headers: asBearerAuth(admin.accessToken) }); expect(libraries).not.toEqual( expect.arrayContaining([ expect.objectContaining({ @@ -789,7 +648,6 @@ describe('/library', () => { it('should delete an external library with assets', async () => { const library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId, - type: LibraryType.External, importPaths: [`${testAssetDirInternal}/temp`], }); @@ -803,7 +661,7 @@ describe('/library', () => { expect(status).toBe(204); expect(body).toEqual({}); - const libraries = await getAllLibraries({}, { headers: asBearerAuth(admin.accessToken) }); + const libraries = await getAllLibraries({ headers: asBearerAuth(admin.accessToken) }); expect(libraries).not.toEqual( expect.arrayContaining([ expect.objectContaining({ diff --git a/e2e/src/responses.ts b/e2e/src/responses.ts index 37892be0c8..afe3334a7f 100644 --- a/e2e/src/responses.ts +++ b/e2e/src/responses.ts @@ -51,11 +51,6 @@ export const errorDto = { statusCode: 400, message: 'The server already has an admin', }, - noDeleteUploadLibrary: { - error: 'Bad Request', - statusCode: 400, - message: 'Cannot delete the last upload library', - }, }; export const signupResponseDto = { diff --git a/mobile/openapi/.openapi-generator/FILES b/mobile/openapi/.openapi-generator/FILES index 69172bd977..74f3ac6215 100644 --- a/mobile/openapi/.openapi-generator/FILES +++ b/mobile/openapi/.openapi-generator/FILES @@ -92,7 +92,6 @@ doc/JobStatusDto.md doc/LibraryApi.md doc/LibraryResponseDto.md doc/LibraryStatsResponseDto.md -doc/LibraryType.md doc/LogLevel.md doc/LoginCredentialDto.md doc/LoginResponseDto.md @@ -331,7 +330,6 @@ lib/model/job_settings_dto.dart lib/model/job_status_dto.dart lib/model/library_response_dto.dart lib/model/library_stats_response_dto.dart -lib/model/library_type.dart lib/model/log_level.dart lib/model/login_credential_dto.dart lib/model/login_response_dto.dart @@ -531,7 +529,6 @@ test/job_status_dto_test.dart test/library_api_test.dart test/library_response_dto_test.dart test/library_stats_response_dto_test.dart -test/library_type_test.dart test/log_level_test.dart test/login_credential_dto_test.dart test/login_response_dto_test.dart diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 048d5b00a0..090182fc39 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -302,7 +302,6 @@ Class | Method | HTTP request | Description - [JobStatusDto](doc//JobStatusDto.md) - [LibraryResponseDto](doc//LibraryResponseDto.md) - [LibraryStatsResponseDto](doc//LibraryStatsResponseDto.md) - - [LibraryType](doc//LibraryType.md) - [LogLevel](doc//LogLevel.md) - [LoginCredentialDto](doc//LoginCredentialDto.md) - [LoginResponseDto](doc//LoginResponseDto.md) diff --git a/mobile/openapi/doc/AssetApi.md b/mobile/openapi/doc/AssetApi.md index a1491c79a2..1ea86f26f7 100644 --- a/mobile/openapi/doc/AssetApi.md +++ b/mobile/openapi/doc/AssetApi.md @@ -957,7 +957,7 @@ void (empty response body) [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) # **uploadFile** -> AssetFileUploadResponseDto uploadFile(assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, key, xImmichChecksum, duration, isArchived, isFavorite, isOffline, isVisible, libraryId, livePhotoData, sidecarData) +> AssetFileUploadResponseDto uploadFile(assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, key, xImmichChecksum, duration, isArchived, isFavorite, isOffline, isVisible, livePhotoData, sidecarData) @@ -992,12 +992,11 @@ final isArchived = true; // bool | final isFavorite = true; // bool | final isOffline = true; // bool | final isVisible = true; // bool | -final libraryId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | final livePhotoData = BINARY_DATA_HERE; // MultipartFile | final sidecarData = BINARY_DATA_HERE; // MultipartFile | try { - final result = api_instance.uploadFile(assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, key, xImmichChecksum, duration, isArchived, isFavorite, isOffline, isVisible, libraryId, livePhotoData, sidecarData); + final result = api_instance.uploadFile(assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, key, xImmichChecksum, duration, isArchived, isFavorite, isOffline, isVisible, livePhotoData, sidecarData); print(result); } catch (e) { print('Exception when calling AssetApi->uploadFile: $e\n'); @@ -1020,7 +1019,6 @@ Name | Type | Description | Notes **isFavorite** | **bool**| | [optional] **isOffline** | **bool**| | [optional] **isVisible** | **bool**| | [optional] - **libraryId** | **String**| | [optional] **livePhotoData** | **MultipartFile**| | [optional] **sidecarData** | **MultipartFile**| | [optional] diff --git a/mobile/openapi/doc/AssetResponseDto.md b/mobile/openapi/doc/AssetResponseDto.md index 41a628bd54..3ff4db3402 100644 --- a/mobile/openapi/doc/AssetResponseDto.md +++ b/mobile/openapi/doc/AssetResponseDto.md @@ -24,7 +24,7 @@ Name | Type | Description | Notes **isOffline** | **bool** | | **isReadOnly** | **bool** | This property was deprecated in v1.104.0 | [optional] **isTrashed** | **bool** | | -**libraryId** | **String** | | +**libraryId** | **String** | This property was deprecated in v1.106.0 | [optional] **livePhotoVideoId** | **String** | | [optional] **localDateTime** | [**DateTime**](DateTime.md) | | **originalFileName** | **String** | | diff --git a/mobile/openapi/doc/CreateLibraryDto.md b/mobile/openapi/doc/CreateLibraryDto.md index f7d5c0ecfe..662c9b0d4b 100644 --- a/mobile/openapi/doc/CreateLibraryDto.md +++ b/mobile/openapi/doc/CreateLibraryDto.md @@ -12,7 +12,6 @@ Name | Type | Description | Notes **importPaths** | **List** | | [optional] [default to const []] **name** | **String** | | [optional] **ownerId** | **String** | | -**type** | [**LibraryType**](LibraryType.md) | | [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) diff --git a/mobile/openapi/doc/LibraryApi.md b/mobile/openapi/doc/LibraryApi.md index 8a204788bb..ca67ab5304 100644 --- a/mobile/openapi/doc/LibraryApi.md +++ b/mobile/openapi/doc/LibraryApi.md @@ -130,7 +130,7 @@ void (empty response body) [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) # **getAllLibraries** -> List getAllLibraries(type) +> List getAllLibraries() @@ -153,10 +153,9 @@ import 'package:openapi/api.dart'; //defaultApiClient.getAuthentication('bearer').setAccessToken(yourTokenGeneratorFunction); final api_instance = LibraryApi(); -final type = ; // LibraryType | try { - final result = api_instance.getAllLibraries(type); + final result = api_instance.getAllLibraries(); print(result); } catch (e) { print('Exception when calling LibraryApi->getAllLibraries: $e\n'); @@ -164,10 +163,7 @@ try { ``` ### Parameters - -Name | Type | Description | Notes -------------- | ------------- | ------------- | ------------- - **type** | [**LibraryType**](.md)| | [optional] +This endpoint does not need any parameter. ### Return type diff --git a/mobile/openapi/doc/LibraryResponseDto.md b/mobile/openapi/doc/LibraryResponseDto.md index e7283c11b6..6e9af87db2 100644 --- a/mobile/openapi/doc/LibraryResponseDto.md +++ b/mobile/openapi/doc/LibraryResponseDto.md @@ -16,7 +16,6 @@ Name | Type | Description | Notes **name** | **String** | | **ownerId** | **String** | | **refreshedAt** | [**DateTime**](DateTime.md) | | -**type** | [**LibraryType**](LibraryType.md) | | **updatedAt** | [**DateTime**](DateTime.md) | | [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) diff --git a/mobile/openapi/doc/LibraryType.md b/mobile/openapi/doc/LibraryType.md deleted file mode 100644 index 0bd5a3a14b..0000000000 --- a/mobile/openapi/doc/LibraryType.md +++ /dev/null @@ -1,14 +0,0 @@ -# openapi.model.LibraryType - -## Load the model package -```dart -import 'package:openapi/api.dart'; -``` - -## Properties -Name | Type | Description | Notes ------------- | ------------- | ------------- | ------------- - -[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) - - diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 110c4f757e..8cd0f4365c 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -134,7 +134,6 @@ part 'model/job_settings_dto.dart'; part 'model/job_status_dto.dart'; part 'model/library_response_dto.dart'; part 'model/library_stats_response_dto.dart'; -part 'model/library_type.dart'; part 'model/log_level.dart'; part 'model/login_credential_dto.dart'; part 'model/login_response_dto.dart'; diff --git a/mobile/openapi/lib/api/asset_api.dart b/mobile/openapi/lib/api/asset_api.dart index dba33fc181..f3c8389ab4 100644 --- a/mobile/openapi/lib/api/asset_api.dart +++ b/mobile/openapi/lib/api/asset_api.dart @@ -977,12 +977,10 @@ class AssetApi { /// /// * [bool] isVisible: /// - /// * [String] libraryId: - /// /// * [MultipartFile] livePhotoData: /// /// * [MultipartFile] sidecarData: - Future uploadFileWithHttpInfo(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? libraryId, MultipartFile? livePhotoData, MultipartFile? sidecarData, }) async { + Future uploadFileWithHttpInfo(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? xImmichChecksum, String? duration, bool? isArchived, bool? isFavorite, bool? isOffline, bool? isVisible, MultipartFile? livePhotoData, MultipartFile? sidecarData, }) async { // ignore: prefer_const_declarations final path = r'/asset/upload'; @@ -1046,10 +1044,6 @@ class AssetApi { hasFields = true; mp.fields[r'isVisible'] = parameterToString(isVisible); } - if (libraryId != null) { - hasFields = true; - mp.fields[r'libraryId'] = parameterToString(libraryId); - } if (livePhotoData != null) { hasFields = true; mp.fields[r'livePhotoData'] = livePhotoData.field; @@ -1102,13 +1096,11 @@ class AssetApi { /// /// * [bool] isVisible: /// - /// * [String] libraryId: - /// /// * [MultipartFile] livePhotoData: /// /// * [MultipartFile] sidecarData: - Future uploadFile(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? libraryId, MultipartFile? livePhotoData, MultipartFile? sidecarData, }) async { - final response = await uploadFileWithHttpInfo(assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, key: key, xImmichChecksum: xImmichChecksum, duration: duration, isArchived: isArchived, isFavorite: isFavorite, isOffline: isOffline, isVisible: isVisible, libraryId: libraryId, livePhotoData: livePhotoData, sidecarData: sidecarData, ); + Future uploadFile(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? xImmichChecksum, String? duration, bool? isArchived, bool? isFavorite, bool? isOffline, bool? isVisible, MultipartFile? livePhotoData, MultipartFile? sidecarData, }) async { + final response = await uploadFileWithHttpInfo(assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, key: key, xImmichChecksum: xImmichChecksum, duration: duration, isArchived: isArchived, isFavorite: isFavorite, isOffline: isOffline, isVisible: isVisible, livePhotoData: livePhotoData, sidecarData: sidecarData, ); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } diff --git a/mobile/openapi/lib/api/library_api.dart b/mobile/openapi/lib/api/library_api.dart index 7a980535fa..48f46e6e1b 100644 --- a/mobile/openapi/lib/api/library_api.dart +++ b/mobile/openapi/lib/api/library_api.dart @@ -104,10 +104,7 @@ class LibraryApi { } /// Performs an HTTP 'GET /library' operation and returns the [Response]. - /// Parameters: - /// - /// * [LibraryType] type: - Future getAllLibrariesWithHttpInfo({ LibraryType? type, }) async { + Future getAllLibrariesWithHttpInfo() async { // ignore: prefer_const_declarations final path = r'/library'; @@ -118,10 +115,6 @@ class LibraryApi { final headerParams = {}; final formParams = {}; - if (type != null) { - queryParams.addAll(_queryParams('', 'type', type)); - } - const contentTypes = []; @@ -136,11 +129,8 @@ class LibraryApi { ); } - /// Parameters: - /// - /// * [LibraryType] type: - Future?> getAllLibraries({ LibraryType? type, }) async { - final response = await getAllLibrariesWithHttpInfo( type: type, ); + Future?> getAllLibraries() async { + final response = await getAllLibrariesWithHttpInfo(); 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 6256d0c487..2bebf46b1c 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -336,8 +336,6 @@ class ApiClient { return LibraryResponseDto.fromJson(value); case 'LibraryStatsResponseDto': return LibraryStatsResponseDto.fromJson(value); - case 'LibraryType': - return LibraryTypeTypeTransformer().decode(value); case 'LogLevel': return LogLevelTypeTransformer().decode(value); case 'LoginCredentialDto': diff --git a/mobile/openapi/lib/api_helper.dart b/mobile/openapi/lib/api_helper.dart index a43ed6ecbf..f945dbb115 100644 --- a/mobile/openapi/lib/api_helper.dart +++ b/mobile/openapi/lib/api_helper.dart @@ -91,9 +91,6 @@ String parameterToString(dynamic value) { if (value is JobName) { return JobNameTypeTransformer().encode(value).toString(); } - if (value is LibraryType) { - return LibraryTypeTypeTransformer().encode(value).toString(); - } if (value is LogLevel) { return LogLevelTypeTransformer().encode(value).toString(); } diff --git a/mobile/openapi/lib/model/asset_response_dto.dart b/mobile/openapi/lib/model/asset_response_dto.dart index 8802bb03ab..b9c31e29cb 100644 --- a/mobile/openapi/lib/model/asset_response_dto.dart +++ b/mobile/openapi/lib/model/asset_response_dto.dart @@ -29,7 +29,7 @@ class AssetResponseDto { required this.isOffline, this.isReadOnly, required this.isTrashed, - required this.libraryId, + this.libraryId, this.livePhotoVideoId, required this.localDateTime, required this.originalFileName, @@ -101,7 +101,8 @@ class AssetResponseDto { bool isTrashed; - String libraryId; + /// This property was deprecated in v1.106.0 + String? libraryId; String? livePhotoVideoId; @@ -202,7 +203,7 @@ class AssetResponseDto { (isOffline.hashCode) + (isReadOnly == null ? 0 : isReadOnly!.hashCode) + (isTrashed.hashCode) + - (libraryId.hashCode) + + (libraryId == null ? 0 : libraryId!.hashCode) + (livePhotoVideoId == null ? 0 : livePhotoVideoId!.hashCode) + (localDateTime.hashCode) + (originalFileName.hashCode) + @@ -257,7 +258,11 @@ class AssetResponseDto { // json[r'isReadOnly'] = null; } json[r'isTrashed'] = this.isTrashed; + if (this.libraryId != null) { json[r'libraryId'] = this.libraryId; + } else { + // json[r'libraryId'] = null; + } if (this.livePhotoVideoId != null) { json[r'livePhotoVideoId'] = this.livePhotoVideoId; } else { @@ -325,7 +330,7 @@ class AssetResponseDto { isOffline: mapValueOfType(json, r'isOffline')!, isReadOnly: mapValueOfType(json, r'isReadOnly'), isTrashed: mapValueOfType(json, r'isTrashed')!, - libraryId: mapValueOfType(json, r'libraryId')!, + libraryId: mapValueOfType(json, r'libraryId'), livePhotoVideoId: mapValueOfType(json, r'livePhotoVideoId'), localDateTime: mapDateTime(json, r'localDateTime', r'')!, originalFileName: mapValueOfType(json, r'originalFileName')!, @@ -401,7 +406,6 @@ class AssetResponseDto { 'isFavorite', 'isOffline', 'isTrashed', - 'libraryId', 'localDateTime', 'originalFileName', 'originalPath', diff --git a/mobile/openapi/lib/model/create_library_dto.dart b/mobile/openapi/lib/model/create_library_dto.dart index 7d1ce0eea6..65ceec8e8a 100644 --- a/mobile/openapi/lib/model/create_library_dto.dart +++ b/mobile/openapi/lib/model/create_library_dto.dart @@ -17,7 +17,6 @@ class CreateLibraryDto { this.importPaths = const [], this.name, required this.ownerId, - required this.type, }); List exclusionPatterns; @@ -34,15 +33,12 @@ class CreateLibraryDto { String ownerId; - LibraryType type; - @override bool operator ==(Object other) => identical(this, other) || other is CreateLibraryDto && _deepEquality.equals(other.exclusionPatterns, exclusionPatterns) && _deepEquality.equals(other.importPaths, importPaths) && other.name == name && - other.ownerId == ownerId && - other.type == type; + other.ownerId == ownerId; @override int get hashCode => @@ -50,11 +46,10 @@ class CreateLibraryDto { (exclusionPatterns.hashCode) + (importPaths.hashCode) + (name == null ? 0 : name!.hashCode) + - (ownerId.hashCode) + - (type.hashCode); + (ownerId.hashCode); @override - String toString() => 'CreateLibraryDto[exclusionPatterns=$exclusionPatterns, importPaths=$importPaths, name=$name, ownerId=$ownerId, type=$type]'; + String toString() => 'CreateLibraryDto[exclusionPatterns=$exclusionPatterns, importPaths=$importPaths, name=$name, ownerId=$ownerId]'; Map toJson() { final json = {}; @@ -66,7 +61,6 @@ class CreateLibraryDto { // json[r'name'] = null; } json[r'ownerId'] = this.ownerId; - json[r'type'] = this.type; return json; } @@ -86,7 +80,6 @@ class CreateLibraryDto { : const [], name: mapValueOfType(json, r'name'), ownerId: mapValueOfType(json, r'ownerId')!, - type: LibraryType.fromJson(json[r'type'])!, ); } return null; @@ -135,7 +128,6 @@ class CreateLibraryDto { /// The list of required keys that must be present in a JSON. static const requiredKeys = { 'ownerId', - 'type', }; } diff --git a/mobile/openapi/lib/model/library_response_dto.dart b/mobile/openapi/lib/model/library_response_dto.dart index c2a25913c5..e27b489104 100644 --- a/mobile/openapi/lib/model/library_response_dto.dart +++ b/mobile/openapi/lib/model/library_response_dto.dart @@ -21,7 +21,6 @@ class LibraryResponseDto { required this.name, required this.ownerId, required this.refreshedAt, - required this.type, required this.updatedAt, }); @@ -41,8 +40,6 @@ class LibraryResponseDto { DateTime? refreshedAt; - LibraryType type; - DateTime updatedAt; @override @@ -55,7 +52,6 @@ class LibraryResponseDto { other.name == name && other.ownerId == ownerId && other.refreshedAt == refreshedAt && - other.type == type && other.updatedAt == updatedAt; @override @@ -69,11 +65,10 @@ class LibraryResponseDto { (name.hashCode) + (ownerId.hashCode) + (refreshedAt == null ? 0 : refreshedAt!.hashCode) + - (type.hashCode) + (updatedAt.hashCode); @override - String toString() => 'LibraryResponseDto[assetCount=$assetCount, createdAt=$createdAt, exclusionPatterns=$exclusionPatterns, id=$id, importPaths=$importPaths, name=$name, ownerId=$ownerId, refreshedAt=$refreshedAt, type=$type, updatedAt=$updatedAt]'; + String toString() => 'LibraryResponseDto[assetCount=$assetCount, createdAt=$createdAt, exclusionPatterns=$exclusionPatterns, id=$id, importPaths=$importPaths, name=$name, ownerId=$ownerId, refreshedAt=$refreshedAt, updatedAt=$updatedAt]'; Map toJson() { final json = {}; @@ -89,7 +84,6 @@ class LibraryResponseDto { } else { // json[r'refreshedAt'] = null; } - json[r'type'] = this.type; json[r'updatedAt'] = this.updatedAt.toUtc().toIso8601String(); return json; } @@ -114,7 +108,6 @@ class LibraryResponseDto { name: mapValueOfType(json, r'name')!, ownerId: mapValueOfType(json, r'ownerId')!, refreshedAt: mapDateTime(json, r'refreshedAt', r''), - type: LibraryType.fromJson(json[r'type'])!, updatedAt: mapDateTime(json, r'updatedAt', r'')!, ); } @@ -171,7 +164,6 @@ class LibraryResponseDto { 'name', 'ownerId', 'refreshedAt', - 'type', 'updatedAt', }; } diff --git a/mobile/openapi/lib/model/library_type.dart b/mobile/openapi/lib/model/library_type.dart deleted file mode 100644 index 0e5f56b518..0000000000 --- a/mobile/openapi/lib/model/library_type.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 LibraryType { - /// Instantiate a new enum with the provided [value]. - const LibraryType._(this.value); - - /// The underlying value of this enum member. - final String value; - - @override - String toString() => value; - - String toJson() => value; - - static const UPLOAD = LibraryType._(r'UPLOAD'); - static const EXTERNAL = LibraryType._(r'EXTERNAL'); - - /// List of all possible values in this [enum][LibraryType]. - static const values = [ - UPLOAD, - EXTERNAL, - ]; - - static LibraryType? fromJson(dynamic value) => LibraryTypeTypeTransformer().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 = LibraryType.fromJson(row); - if (value != null) { - result.add(value); - } - } - } - return result.toList(growable: growable); - } -} - -/// Transformation class that can [encode] an instance of [LibraryType] to String, -/// and [decode] dynamic data back to [LibraryType]. -class LibraryTypeTypeTransformer { - factory LibraryTypeTypeTransformer() => _instance ??= const LibraryTypeTypeTransformer._(); - - const LibraryTypeTypeTransformer._(); - - String encode(LibraryType data) => data.value; - - /// Decodes a [dynamic value][data] to a LibraryType. - /// - /// 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. - LibraryType? decode(dynamic data, {bool allowNull = true}) { - if (data != null) { - switch (data) { - case r'UPLOAD': return LibraryType.UPLOAD; - case r'EXTERNAL': return LibraryType.EXTERNAL; - default: - if (!allowNull) { - throw ArgumentError('Unknown enum value to decode: $data'); - } - } - } - return null; - } - - /// Singleton [LibraryTypeTypeTransformer] instance. - static LibraryTypeTypeTransformer? _instance; -} - diff --git a/mobile/openapi/test/asset_api_test.dart b/mobile/openapi/test/asset_api_test.dart index de84e53546..a1ecd2cc30 100644 --- a/mobile/openapi/test/asset_api_test.dart +++ b/mobile/openapi/test/asset_api_test.dart @@ -105,7 +105,7 @@ void main() { // TODO }); - //Future uploadFile(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 libraryId, MultipartFile livePhotoData, MultipartFile sidecarData }) async + //Future uploadFile(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, { String key, String xImmichChecksum, String duration, bool isArchived, bool isFavorite, bool isOffline, bool isVisible, MultipartFile livePhotoData, MultipartFile sidecarData }) async test('test uploadFile', () async { // TODO }); diff --git a/mobile/openapi/test/asset_response_dto_test.dart b/mobile/openapi/test/asset_response_dto_test.dart index e666a3bb7e..533ec8805f 100644 --- a/mobile/openapi/test/asset_response_dto_test.dart +++ b/mobile/openapi/test/asset_response_dto_test.dart @@ -99,6 +99,7 @@ void main() { // TODO }); + // This property was deprecated in v1.106.0 // String libraryId test('to test the property `libraryId`', () async { // TODO diff --git a/mobile/openapi/test/create_library_dto_test.dart b/mobile/openapi/test/create_library_dto_test.dart index a85578d6ab..c094e44da2 100644 --- a/mobile/openapi/test/create_library_dto_test.dart +++ b/mobile/openapi/test/create_library_dto_test.dart @@ -36,11 +36,6 @@ void main() { // TODO }); - // LibraryType type - test('to test the property `type`', () async { - // TODO - }); - }); diff --git a/mobile/openapi/test/library_api_test.dart b/mobile/openapi/test/library_api_test.dart index d34286f992..f23d118979 100644 --- a/mobile/openapi/test/library_api_test.dart +++ b/mobile/openapi/test/library_api_test.dart @@ -27,7 +27,7 @@ void main() { // TODO }); - //Future> getAllLibraries({ LibraryType type }) async + //Future> getAllLibraries() async test('test getAllLibraries', () async { // TODO }); diff --git a/mobile/openapi/test/library_response_dto_test.dart b/mobile/openapi/test/library_response_dto_test.dart index d07c464d90..51528bb674 100644 --- a/mobile/openapi/test/library_response_dto_test.dart +++ b/mobile/openapi/test/library_response_dto_test.dart @@ -56,11 +56,6 @@ void main() { // TODO }); - // LibraryType type - test('to test the property `type`', () async { - // TODO - }); - // DateTime updatedAt test('to test the property `updatedAt`', () async { // TODO diff --git a/mobile/openapi/test/library_type_test.dart b/mobile/openapi/test/library_type_test.dart deleted file mode 100644 index 3101173c59..0000000000 --- a/mobile/openapi/test/library_type_test.dart +++ /dev/null @@ -1,21 +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 - -import 'package:openapi/api.dart'; -import 'package:test/test.dart'; - -// tests for LibraryType -void main() { - - group('test LibraryType', () { - - }); - -} diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 8fc5378edf..f2d30f1537 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -2439,16 +2439,7 @@ "/library": { "get": { "operationId": "getAllLibraries", - "parameters": [ - { - "name": "type", - "required": false, - "in": "query", - "schema": { - "$ref": "#/components/schemas/LibraryType" - } - } - ], + "parameters": [], "responses": { "200": { "content": { @@ -7365,6 +7356,9 @@ "type": "boolean" }, "libraryId": { + "deprecated": true, + "description": "This property was deprecated in v1.106.0", + "nullable": true, "type": "string" }, "livePhotoVideoId": { @@ -7444,7 +7438,6 @@ "isFavorite", "isOffline", "isTrashed", - "libraryId", "localDateTime", "originalFileName", "originalPath", @@ -7715,10 +7708,6 @@ "isVisible": { "type": "boolean" }, - "libraryId": { - "format": "uuid", - "type": "string" - }, "livePhotoData": { "format": "binary", "type": "string" @@ -7757,14 +7746,10 @@ "ownerId": { "format": "uuid", "type": "string" - }, - "type": { - "$ref": "#/components/schemas/LibraryType" } }, "required": [ - "ownerId", - "type" + "ownerId" ], "type": "object" }, @@ -8319,9 +8304,6 @@ "nullable": true, "type": "string" }, - "type": { - "$ref": "#/components/schemas/LibraryType" - }, "updatedAt": { "format": "date-time", "type": "string" @@ -8336,7 +8318,6 @@ "name", "ownerId", "refreshedAt", - "type", "updatedAt" ], "type": "object" @@ -8369,13 +8350,6 @@ ], "type": "object" }, - "LibraryType": { - "enum": [ - "UPLOAD", - "EXTERNAL" - ], - "type": "string" - }, "LogLevel": { "enum": [ "verbose", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index f0af90a8dd..d3ab222d77 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -130,7 +130,8 @@ export type AssetResponseDto = { /** This property was deprecated in v1.104.0 */ isReadOnly?: boolean; isTrashed: boolean; - libraryId: string; + /** This property was deprecated in v1.106.0 */ + libraryId?: string | null; livePhotoVideoId?: string | null; localDateTime: string; originalFileName: string; @@ -307,7 +308,6 @@ export type CreateAssetDto = { isFavorite?: boolean; isOffline?: boolean; isVisible?: boolean; - libraryId?: string; livePhotoData?: Blob; sidecarData?: Blob; }; @@ -442,7 +442,6 @@ export type LibraryResponseDto = { name: string; ownerId: string; refreshedAt: string | null; - "type": LibraryType; updatedAt: string; }; export type CreateLibraryDto = { @@ -450,7 +449,6 @@ export type CreateLibraryDto = { importPaths?: string[]; name?: string; ownerId: string; - "type": LibraryType; }; export type UpdateLibraryDto = { exclusionPatterns?: string[]; @@ -1754,15 +1752,11 @@ export function sendJobCommand({ id, jobCommandDto }: { body: jobCommandDto }))); } -export function getAllLibraries({ $type }: { - $type?: LibraryType; -}, opts?: Oazapfts.RequestOpts) { +export function getAllLibraries(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; data: LibraryResponseDto[]; - }>(`/library${QS.query(QS.explode({ - "type": $type - }))}`, { + }>("/library", { ...opts })); } @@ -2913,10 +2907,6 @@ export enum JobCommand { Empty = "empty", ClearFailed = "clear-failed" } -export enum LibraryType { - Upload = "UPLOAD", - External = "EXTERNAL" -} export enum Type2 { OnThisDay = "on_this_day" } diff --git a/server/src/controllers/library.controller.ts b/server/src/controllers/library.controller.ts index 74a4f73d2a..adbae8af0f 100644 --- a/server/src/controllers/library.controller.ts +++ b/server/src/controllers/library.controller.ts @@ -1,11 +1,10 @@ -import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common'; +import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { CreateLibraryDto, LibraryResponseDto, LibraryStatsResponseDto, ScanLibraryDto, - SearchLibraryDto, UpdateLibraryDto, ValidateLibraryDto, ValidateLibraryResponseDto, @@ -21,8 +20,8 @@ export class LibraryController { @Get() @Authenticated({ admin: true }) - getAllLibraries(@Query() dto: SearchLibraryDto): Promise { - return this.service.getAll(dto); + getAllLibraries(): Promise { + return this.service.getAll(); } @Post() diff --git a/server/src/cores/access.core.ts b/server/src/cores/access.core.ts index 6f8930d05a..ae666562cf 100644 --- a/server/src/cores/access.core.ts +++ b/server/src/cores/access.core.ts @@ -274,7 +274,7 @@ export class AccessCore { } case Permission.ASSET_UPLOAD: { - return await this.repository.library.checkOwnerAccess(auth.user.id, ids); + return ids.has(auth.user.id) ? new Set([auth.user.id]) : new Set(); } case Permission.ARCHIVE_READ: { diff --git a/server/src/cores/user.core.ts b/server/src/cores/user.core.ts index db2a9c780c..4628c83834 100644 --- a/server/src/cores/user.core.ts +++ b/server/src/cores/user.core.ts @@ -2,7 +2,6 @@ import { BadRequestException, ForbiddenException } from '@nestjs/common'; import sanitize from 'sanitize-filename'; import { SALT_ROUNDS } from 'src/constants'; import { UserResponseDto } from 'src/dtos/user.dto'; -import { LibraryType } from 'src/entities/library.entity'; import { UserEntity } from 'src/entities/user.entity'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { ILibraryRepository } from 'src/interfaces/library.interface'; @@ -93,16 +92,7 @@ export class UserCore { if (payload.storageLabel) { payload.storageLabel = sanitize(payload.storageLabel.replaceAll('.', '')); } - const userEntity = await this.userRepository.create(payload); - await this.libraryRepository.create({ - owner: { id: userEntity.id } as UserEntity, - name: 'Default Library', - assets: [], - type: LibraryType.UPLOAD, - importPaths: [], - exclusionPatterns: [], - }); - return userEntity; + return this.userRepository.create(payload); } } diff --git a/server/src/dtos/asset-response.dto.ts b/server/src/dtos/asset-response.dto.ts index 879d358e8e..2cf12061d1 100644 --- a/server/src/dtos/asset-response.dto.ts +++ b/server/src/dtos/asset-response.dto.ts @@ -26,7 +26,8 @@ export class AssetResponseDto extends SanitizedAssetResponseDto { deviceId!: string; ownerId!: string; owner?: UserResponseDto; - libraryId!: string; + @PropertyLifecycle({ deprecatedAt: 'v1.106.0' }) + libraryId?: string | null; originalPath!: string; originalFileName!: string; fileCreatedAt!: Date; diff --git a/server/src/dtos/asset-v1.dto.ts b/server/src/dtos/asset-v1.dto.ts index 131d28ca4a..83c34ab0f7 100644 --- a/server/src/dtos/asset-v1.dto.ts +++ b/server/src/dtos/asset-v1.dto.ts @@ -2,7 +2,7 @@ import { ApiProperty } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { ArrayNotEmpty, IsArray, IsEnum, IsInt, IsNotEmpty, IsString, IsUUID, ValidateNested } from 'class-validator'; import { UploadFieldName } from 'src/dtos/asset.dto'; -import { Optional, ValidateBoolean, ValidateDate, ValidateUUID } from 'src/validation'; +import { Optional, ValidateBoolean, ValidateDate } from 'src/validation'; export class AssetBulkUploadCheckItem { @IsString() @@ -64,9 +64,6 @@ export class CheckExistingAssetsDto { } export class CreateAssetDto { - @ValidateUUID({ optional: true }) - libraryId?: string; - @IsNotEmpty() @IsString() deviceAssetId!: string; diff --git a/server/src/dtos/library.dto.ts b/server/src/dtos/library.dto.ts index 045aaecf54..b9578a2c37 100644 --- a/server/src/dtos/library.dto.ts +++ b/server/src/dtos/library.dto.ts @@ -1,13 +1,9 @@ import { ApiProperty } from '@nestjs/swagger'; -import { ArrayMaxSize, ArrayUnique, IsEnum, IsNotEmpty, IsString } from 'class-validator'; -import { LibraryEntity, LibraryType } from 'src/entities/library.entity'; +import { ArrayMaxSize, ArrayUnique, IsNotEmpty, IsString } from 'class-validator'; +import { LibraryEntity } from 'src/entities/library.entity'; import { Optional, ValidateBoolean, ValidateUUID } from 'src/validation'; export class CreateLibraryDto { - @IsEnum(LibraryType) - @ApiProperty({ enumName: 'LibraryType', enum: LibraryType }) - type!: LibraryType; - @ValidateUUID() ownerId!: string; @@ -97,21 +93,11 @@ export class ScanLibraryDto { refreshAllFiles?: boolean; } -export class SearchLibraryDto { - @IsEnum(LibraryType) - @ApiProperty({ enumName: 'LibraryType', enum: LibraryType }) - @Optional() - type?: LibraryType; -} - export class LibraryResponseDto { id!: string; ownerId!: string; name!: string; - @ApiProperty({ enumName: 'LibraryType', enum: LibraryType }) - type!: LibraryType; - @ApiProperty({ type: 'integer' }) assetCount!: number; @@ -146,7 +132,6 @@ export function mapLibrary(entity: LibraryEntity): LibraryResponseDto { return { id: entity.id, ownerId: entity.ownerId, - type: entity.type, name: entity.name, createdAt: entity.createdAt, updatedAt: entity.updatedAt, diff --git a/server/src/entities/asset.entity.ts b/server/src/entities/asset.entity.ts index 7169ee9070..2189d5389a 100644 --- a/server/src/entities/asset.entity.ts +++ b/server/src/entities/asset.entity.ts @@ -25,12 +25,17 @@ import { UpdateDateColumn, } from 'typeorm'; -export const ASSET_CHECKSUM_CONSTRAINT = 'UQ_assets_owner_library_checksum'; +export const ASSET_CHECKSUM_CONSTRAINT = 'UQ_assets_owner_checksum'; @Entity('assets') // Checksums must be unique per user and library -@Index(ASSET_CHECKSUM_CONSTRAINT, ['owner', 'library', 'checksum'], { +@Index(ASSET_CHECKSUM_CONSTRAINT, ['owner', 'checksum'], { unique: true, + where: '"libraryId" IS NULL', +}) +@Index('UQ_assets_owner_library_checksum' + '', ['owner', 'library', 'checksum'], { + unique: true, + where: '"libraryId" IS NOT NULL', }) @Index('IDX_day_of_month', { synchronize: false }) @Index('IDX_month', { synchronize: false }) @@ -51,11 +56,11 @@ export class AssetEntity { @Column() ownerId!: string; - @ManyToOne(() => LibraryEntity, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false }) - library!: LibraryEntity; + @ManyToOne(() => LibraryEntity, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) + library?: LibraryEntity | null; - @Column() - libraryId!: string; + @Column({ nullable: true }) + libraryId?: string | null; @Column() deviceId!: string; diff --git a/server/src/entities/library.entity.ts b/server/src/entities/library.entity.ts index 56e62dd062..a6053e4213 100644 --- a/server/src/entities/library.entity.ts +++ b/server/src/entities/library.entity.ts @@ -30,9 +30,6 @@ export class LibraryEntity { @Column() ownerId!: string; - @Column() - type!: LibraryType; - @Column('text', { array: true }) importPaths!: string[]; @@ -51,8 +48,3 @@ export class LibraryEntity { @Column({ type: 'timestamptz', nullable: true }) refreshedAt!: Date | null; } - -export enum LibraryType { - UPLOAD = 'UPLOAD', - EXTERNAL = 'EXTERNAL', -} diff --git a/server/src/interfaces/access.interface.ts b/server/src/interfaces/access.interface.ts index e07b877b66..6b408c263e 100644 --- a/server/src/interfaces/access.interface.ts +++ b/server/src/interfaces/access.interface.ts @@ -26,10 +26,6 @@ export interface IAccessRepository { checkSharedLinkAccess(sharedLinkId: string, albumIds: Set): Promise>; }; - library: { - checkOwnerAccess(userId: string, libraryIds: Set): Promise>; - }; - timeline: { checkPartnerAccess(userId: string, partnerIds: Set): Promise>; }; diff --git a/server/src/interfaces/asset.interface.ts b/server/src/interfaces/asset.interface.ts index 4b9ff031e5..0a8437818e 100644 --- a/server/src/interfaces/asset.interface.ts +++ b/server/src/interfaces/asset.interface.ts @@ -164,7 +164,7 @@ export interface IAssetRepository { ): Promise; getByIdsWithAllRelations(ids: string[]): Promise; getByDayOfYear(ownerIds: string[], monthDay: MonthDay): Promise; - getByChecksum(libraryId: string, checksum: Buffer): Promise; + getByChecksum(libraryId: string | null, checksum: Buffer): Promise; getUploadAssetIdByChecksum(ownerId: string, checksum: Buffer): Promise; getByAlbumId(pagination: PaginationOptions, albumId: string): Paginated; getByUserId(pagination: PaginationOptions, userId: string, options?: AssetSearchOptions): Paginated; diff --git a/server/src/interfaces/library.interface.ts b/server/src/interfaces/library.interface.ts index dbc7fab812..30b8b39a0f 100644 --- a/server/src/interfaces/library.interface.ts +++ b/server/src/interfaces/library.interface.ts @@ -1,18 +1,16 @@ import { LibraryStatsResponseDto } from 'src/dtos/library.dto'; -import { LibraryEntity, LibraryType } from 'src/entities/library.entity'; +import { LibraryEntity } from 'src/entities/library.entity'; export const ILibraryRepository = 'ILibraryRepository'; export interface ILibraryRepository { getCountForUser(ownerId: string): Promise; - getAll(withDeleted?: boolean, type?: LibraryType): Promise; + getAll(withDeleted?: boolean): Promise; getAllDeleted(): Promise; get(id: string, withDeleted?: boolean): Promise; create(library: Partial): Promise; delete(id: string): Promise; softDelete(id: string): Promise; - getDefaultUploadLibrary(ownerId: string): Promise; - getUploadLibraryCount(ownerId: string): Promise; update(library: Partial): Promise; getStatistics(id: string): Promise; getAssetIds(id: string, withDeleted?: boolean): Promise; diff --git a/server/src/migrations/1715804005643-RemoveLibraryType.ts b/server/src/migrations/1715804005643-RemoveLibraryType.ts new file mode 100644 index 0000000000..d42ba4ec73 --- /dev/null +++ b/server/src/migrations/1715804005643-RemoveLibraryType.ts @@ -0,0 +1,29 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class RemoveLibraryType1715804005643 implements MigrationInterface { + name = 'RemoveLibraryType1715804005643'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "assets" DROP CONSTRAINT "FK_9977c3c1de01c3d848039a6b90c"`); + await queryRunner.query(`DROP INDEX "public"."UQ_assets_owner_library_checksum"`); + await queryRunner.query(`DROP INDEX "public"."IDX_originalPath_libraryId"`); + await queryRunner.query(`ALTER TABLE "assets" ALTER COLUMN "libraryId" DROP NOT NULL`); + await queryRunner.query(` + UPDATE "assets" + SET "libraryId" = NULL + FROM "libraries" + WHERE "assets"."libraryId" = "libraries"."id" + AND "libraries"."type" = 'UPLOAD' +`); + await queryRunner.query(`DELETE FROM "libraries" WHERE "type" = 'UPLOAD'`); + await queryRunner.query(`ALTER TABLE "libraries" DROP COLUMN "type"`); + await queryRunner.query(`CREATE INDEX "IDX_originalPath_libraryId" ON "assets" ("originalPath", "libraryId")`); + await queryRunner.query(`CREATE UNIQUE INDEX "UQ_assets_owner_checksum" ON "assets" ("ownerId", "checksum") WHERE "libraryId" IS NULL`); + await queryRunner.query(`CREATE UNIQUE INDEX "UQ_assets_owner_library_checksum" ON "assets" ("ownerId", "libraryId", "checksum") WHERE "libraryId" IS NOT NULL`); + await queryRunner.query(`ALTER TABLE "assets" ADD CONSTRAINT "FK_9977c3c1de01c3d848039a6b90c" FOREIGN KEY ("libraryId") REFERENCES "libraries"("id") ON DELETE CASCADE ON UPDATE CASCADE`); + } + + public async down(): Promise { + // not implemented + } +} diff --git a/server/src/queries/access.repository.sql b/server/src/queries/access.repository.sql index 52cf28c77c..b08638707e 100644 --- a/server/src/queries/access.repository.sql +++ b/server/src/queries/access.repository.sql @@ -191,20 +191,6 @@ WHERE AND ("SessionEntity"."id" IN ($2)) ) --- AccessRepository.library.checkOwnerAccess -SELECT - "LibraryEntity"."id" AS "LibraryEntity_id" -FROM - "libraries" "LibraryEntity" -WHERE - ( - ( - ("LibraryEntity"."id" IN ($1)) - AND ("LibraryEntity"."ownerId" = $2) - ) - ) - AND ("LibraryEntity"."deletedAt" IS NULL) - -- AccessRepository.memory.checkOwnerAccess SELECT "MemoryEntity"."id" AS "MemoryEntity_id" diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index 3c6c83ff1d..6829c1445d 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -483,26 +483,16 @@ LIMIT 1 -- AssetRepository.getUploadAssetIdByChecksum -SELECT DISTINCT - "distinctAlias"."AssetEntity_id" AS "ids_AssetEntity_id" +SELECT + "AssetEntity"."id" AS "AssetEntity_id" FROM + "assets" "AssetEntity" +WHERE ( - SELECT - "AssetEntity"."id" AS "AssetEntity_id" - FROM - "assets" "AssetEntity" - LEFT JOIN "libraries" "AssetEntity__AssetEntity_library" ON "AssetEntity__AssetEntity_library"."id" = "AssetEntity"."libraryId" - WHERE - ( - ("AssetEntity"."ownerId" = $1) - AND ("AssetEntity"."checksum" = $2) - AND ( - (("AssetEntity__AssetEntity_library"."type" = $3)) - ) - ) - ) "distinctAlias" -ORDER BY - "AssetEntity_id" ASC + ("AssetEntity"."ownerId" = $1) + AND ("AssetEntity"."checksum" = $2) + AND ("AssetEntity"."libraryId" IS NULL) + ) LIMIT 1 diff --git a/server/src/queries/library.repository.sql b/server/src/queries/library.repository.sql index 3e655d6506..700a71048a 100644 --- a/server/src/queries/library.repository.sql +++ b/server/src/queries/library.repository.sql @@ -9,7 +9,6 @@ FROM "LibraryEntity"."id" AS "LibraryEntity_id", "LibraryEntity"."name" AS "LibraryEntity_name", "LibraryEntity"."ownerId" AS "LibraryEntity_ownerId", - "LibraryEntity"."type" AS "LibraryEntity_type", "LibraryEntity"."importPaths" AS "LibraryEntity_importPaths", "LibraryEntity"."exclusionPatterns" AS "LibraryEntity_exclusionPatterns", "LibraryEntity"."createdAt" AS "LibraryEntity_createdAt", @@ -77,53 +76,11 @@ WHERE ((("LibraryEntity"."ownerId" = $1))) AND ("LibraryEntity"."deletedAt" IS NULL) --- LibraryRepository.getDefaultUploadLibrary -SELECT - "LibraryEntity"."id" AS "LibraryEntity_id", - "LibraryEntity"."name" AS "LibraryEntity_name", - "LibraryEntity"."ownerId" AS "LibraryEntity_ownerId", - "LibraryEntity"."type" AS "LibraryEntity_type", - "LibraryEntity"."importPaths" AS "LibraryEntity_importPaths", - "LibraryEntity"."exclusionPatterns" AS "LibraryEntity_exclusionPatterns", - "LibraryEntity"."createdAt" AS "LibraryEntity_createdAt", - "LibraryEntity"."updatedAt" AS "LibraryEntity_updatedAt", - "LibraryEntity"."deletedAt" AS "LibraryEntity_deletedAt", - "LibraryEntity"."refreshedAt" AS "LibraryEntity_refreshedAt" -FROM - "libraries" "LibraryEntity" -WHERE - ( - ( - ("LibraryEntity"."ownerId" = $1) - AND ("LibraryEntity"."type" = $2) - ) - ) - AND ("LibraryEntity"."deletedAt" IS NULL) -ORDER BY - "LibraryEntity"."createdAt" ASC -LIMIT - 1 - --- LibraryRepository.getUploadLibraryCount -SELECT - COUNT(1) AS "cnt" -FROM - "libraries" "LibraryEntity" -WHERE - ( - ( - ("LibraryEntity"."ownerId" = $1) - AND ("LibraryEntity"."type" = $2) - ) - ) - AND ("LibraryEntity"."deletedAt" IS NULL) - -- LibraryRepository.getAllByUserId SELECT "LibraryEntity"."id" AS "LibraryEntity_id", "LibraryEntity"."name" AS "LibraryEntity_name", "LibraryEntity"."ownerId" AS "LibraryEntity_ownerId", - "LibraryEntity"."type" AS "LibraryEntity_type", "LibraryEntity"."importPaths" AS "LibraryEntity_importPaths", "LibraryEntity"."exclusionPatterns" AS "LibraryEntity_exclusionPatterns", "LibraryEntity"."createdAt" AS "LibraryEntity_createdAt", @@ -163,7 +120,6 @@ SELECT "LibraryEntity"."id" AS "LibraryEntity_id", "LibraryEntity"."name" AS "LibraryEntity_name", "LibraryEntity"."ownerId" AS "LibraryEntity_ownerId", - "LibraryEntity"."type" AS "LibraryEntity_type", "LibraryEntity"."importPaths" AS "LibraryEntity_importPaths", "LibraryEntity"."exclusionPatterns" AS "LibraryEntity_exclusionPatterns", "LibraryEntity"."createdAt" AS "LibraryEntity_createdAt", @@ -202,7 +158,6 @@ SELECT "LibraryEntity"."id" AS "LibraryEntity_id", "LibraryEntity"."name" AS "LibraryEntity_name", "LibraryEntity"."ownerId" AS "LibraryEntity_ownerId", - "LibraryEntity"."type" AS "LibraryEntity_type", "LibraryEntity"."importPaths" AS "LibraryEntity_importPaths", "LibraryEntity"."exclusionPatterns" AS "LibraryEntity_exclusionPatterns", "LibraryEntity"."createdAt" AS "LibraryEntity_createdAt", @@ -238,7 +193,6 @@ SELECT "libraries"."id" AS "libraries_id", "libraries"."name" AS "libraries_name", "libraries"."ownerId" AS "libraries_ownerId", - "libraries"."type" AS "libraries_type", "libraries"."importPaths" AS "libraries_importPaths", "libraries"."exclusionPatterns" AS "libraries_exclusionPatterns", "libraries"."createdAt" AS "libraries_createdAt", diff --git a/server/src/queries/user.repository.sql b/server/src/queries/user.repository.sql index 30464da786..d27b83fdd7 100644 --- a/server/src/queries/user.repository.sql +++ b/server/src/queries/user.repository.sql @@ -167,12 +167,10 @@ SET COALESCE(SUM(exif."fileSizeInByte"), 0) FROM "assets" "assets" - LEFT JOIN "libraries" "library" ON "library"."id" = "assets"."libraryId" - AND ("library"."deletedAt" IS NULL) LEFT JOIN "exif" "exif" ON "exif"."assetId" = "assets"."id" WHERE "assets"."ownerId" = users.id - AND "library"."type" = 'UPLOAD' + AND "assets"."libraryId" IS NULL ), "updatedAt" = CURRENT_TIMESTAMP WHERE diff --git a/server/src/repositories/access.repository.ts b/server/src/repositories/access.repository.ts index 992f8f143f..17746b1556 100644 --- a/server/src/repositories/access.repository.ts +++ b/server/src/repositories/access.repository.ts @@ -20,7 +20,6 @@ type IActivityAccess = IAccessRepository['activity']; type IAlbumAccess = IAccessRepository['album']; type IAssetAccess = IAccessRepository['asset']; type IAuthDeviceAccess = IAccessRepository['authDevice']; -type ILibraryAccess = IAccessRepository['library']; type ITimelineAccess = IAccessRepository['timeline']; type IMemoryAccess = IAccessRepository['memory']; type IPersonAccess = IAccessRepository['person']; @@ -313,28 +312,6 @@ class AuthDeviceAccess implements IAuthDeviceAccess { } } -class LibraryAccess implements ILibraryAccess { - constructor(private libraryRepository: Repository) {} - - @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] }) - @ChunkedSet({ paramIndex: 1 }) - async checkOwnerAccess(userId: string, libraryIds: Set): Promise> { - if (libraryIds.size === 0) { - return new Set(); - } - - return this.libraryRepository - .find({ - select: { id: true }, - where: { - id: In([...libraryIds]), - ownerId: userId, - }, - }) - .then((libraries) => new Set(libraries.map((library) => library.id))); - } -} - class TimelineAccess implements ITimelineAccess { constructor(private partnerRepository: Repository) {} @@ -447,7 +424,6 @@ export class AccessRepository implements IAccessRepository { album: IAlbumAccess; asset: IAssetAccess; authDevice: IAuthDeviceAccess; - library: ILibraryAccess; memory: IMemoryAccess; person: IPersonAccess; partner: IPartnerAccess; @@ -469,7 +445,6 @@ export class AccessRepository implements IAccessRepository { this.album = new AlbumAccess(albumRepository, sharedLinkRepository); this.asset = new AssetAccess(albumRepository, assetRepository, partnerRepository, sharedLinkRepository); this.authDevice = new AuthDeviceAccess(sessionRepository); - this.library = new LibraryAccess(libraryRepository); this.memory = new MemoryAccess(memoryRepository); this.person = new PersonAccess(assetFaceRepository, personRepository); this.partner = new PartnerAccess(partnerRepository); diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index b4869b9fbb..9a68b4e708 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -5,7 +5,6 @@ import { AlbumEntity, AssetOrder } from 'src/entities/album.entity'; import { AssetJobStatusEntity } from 'src/entities/asset-job-status.entity'; import { AssetEntity, AssetType } from 'src/entities/asset.entity'; import { ExifEntity } from 'src/entities/exif.entity'; -import { LibraryType } from 'src/entities/library.entity'; import { PartnerEntity } from 'src/entities/partner.entity'; import { SmartInfoEntity } from 'src/entities/smart-info.entity'; import { @@ -292,8 +291,13 @@ export class AssetRepository implements IAssetRepository { } @GenerateSql({ params: [DummyValue.UUID, DummyValue.BUFFER] }) - getByChecksum(libraryId: string, checksum: Buffer): Promise { - return this.repository.findOne({ where: { libraryId, checksum } }); + getByChecksum(libraryId: string | null, checksum: Buffer): Promise { + return this.repository.findOne({ + where: { + libraryId: libraryId || IsNull(), + checksum, + }, + }); } @GenerateSql({ params: [DummyValue.UUID, DummyValue.BUFFER] }) @@ -303,9 +307,7 @@ export class AssetRepository implements IAssetRepository { where: { ownerId, checksum, - library: { - type: LibraryType.UPLOAD, - }, + library: IsNull(), }, withDeleted: true, }); diff --git a/server/src/repositories/library.repository.ts b/server/src/repositories/library.repository.ts index 25eb010356..ca70780720 100644 --- a/server/src/repositories/library.repository.ts +++ b/server/src/repositories/library.repository.ts @@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { DummyValue, GenerateSql } from 'src/decorators'; import { LibraryStatsResponseDto } from 'src/dtos/library.dto'; -import { LibraryEntity, LibraryType } from 'src/entities/library.entity'; +import { LibraryEntity } from 'src/entities/library.entity'; import { ILibraryRepository } from 'src/interfaces/library.interface'; import { Instrumentation } from 'src/utils/instrumentation'; import { EntityNotFoundError, IsNull, Not } from 'typeorm'; @@ -40,34 +40,10 @@ export class LibraryRepository implements ILibraryRepository { } @GenerateSql({ params: [DummyValue.UUID] }) - getDefaultUploadLibrary(ownerId: string): Promise { - return this.repository.findOne({ - where: { - ownerId: ownerId, - type: LibraryType.UPLOAD, - }, - order: { - createdAt: 'ASC', - }, - }); - } - - @GenerateSql({ params: [DummyValue.UUID] }) - getUploadLibraryCount(ownerId: string): Promise { - return this.repository.count({ - where: { - ownerId: ownerId, - type: LibraryType.UPLOAD, - }, - }); - } - - @GenerateSql({ params: [DummyValue.UUID] }) - getAllByUserId(ownerId: string, type?: LibraryType): Promise { + getAllByUserId(ownerId: string): Promise { return this.repository.find({ where: { ownerId, - type, }, relations: { owner: true, @@ -79,9 +55,8 @@ export class LibraryRepository implements ILibraryRepository { } @GenerateSql({ params: [] }) - getAll(withDeleted = false, type?: LibraryType): Promise { + getAll(withDeleted = false): Promise { return this.repository.find({ - where: { type }, relations: { owner: true, }, diff --git a/server/src/repositories/user.repository.ts b/server/src/repositories/user.repository.ts index 4f62732e7b..6829bb024a 100644 --- a/server/src/repositories/user.repository.ts +++ b/server/src/repositories/user.repository.ts @@ -2,7 +2,6 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { DummyValue, GenerateSql } from 'src/decorators'; import { AssetEntity } from 'src/entities/asset.entity'; -import { LibraryType } from 'src/entities/library.entity'; import { UserEntity } from 'src/entities/user.entity'; import { IUserRepository, @@ -123,10 +122,9 @@ export class UserRepository implements IUserRepository { const subQuery = this.assetRepository .createQueryBuilder('assets') .select('COALESCE(SUM(exif."fileSizeInByte"), 0)') - .leftJoin('assets.library', 'library') .leftJoin('assets.exifInfo', 'exif') .where('assets.ownerId = users.id') - .andWhere(`library.type = '${LibraryType.UPLOAD}'`) + .andWhere(`assets.libraryId IS NULL`) .withDeleted(); const query = this.userRepository diff --git a/server/src/services/asset-v1.service.spec.ts b/server/src/services/asset-v1.service.spec.ts index 40211ee40a..89485d2669 100644 --- a/server/src/services/asset-v1.service.spec.ts +++ b/server/src/services/asset-v1.service.spec.ts @@ -32,7 +32,6 @@ const _getCreateAssetDto = (): CreateAssetDto => { createAssetDto.isFavorite = false; createAssetDto.isArchived = false; createAssetDto.duration = '0:00:00.000000'; - createAssetDto.libraryId = 'libraryId'; return createAssetDto; }; @@ -121,7 +120,6 @@ describe('AssetService', () => { const dto = _getCreateAssetDto(); assetMock.create.mockResolvedValue(assetEntity); - accessMock.library.checkOwnerAccess.mockResolvedValue(new Set([dto.libraryId!])); await expect(sut.uploadFile(authStub.user1, dto, file)).resolves.toEqual({ duplicate: false, id: 'id_1' }); @@ -149,7 +147,6 @@ describe('AssetService', () => { assetMock.create.mockRejectedValue(error); assetRepositoryMockV1.getAssetsByChecksums.mockResolvedValue([_getAsset_1()]); - accessMock.library.checkOwnerAccess.mockResolvedValue(new Set([dto.libraryId!])); await expect(sut.uploadFile(authStub.user1, dto, file)).resolves.toEqual({ duplicate: true, id: 'id_1' }); @@ -167,7 +164,6 @@ describe('AssetService', () => { assetMock.create.mockResolvedValueOnce(assetStub.livePhotoMotionAsset); assetMock.create.mockResolvedValueOnce(assetStub.livePhotoStillAsset); - accessMock.library.checkOwnerAccess.mockResolvedValue(new Set([dto.libraryId!])); await expect( sut.uploadFile(authStub.user1, dto, fileStub.livePhotoStill, fileStub.livePhotoMotion), diff --git a/server/src/services/asset-v1.service.ts b/server/src/services/asset-v1.service.ts index bd6f540061..6868ca2dad 100644 --- a/server/src/services/asset-v1.service.ts +++ b/server/src/services/asset-v1.service.ts @@ -25,7 +25,6 @@ import { } from 'src/dtos/asset-v1.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity, AssetType } from 'src/entities/asset.entity'; -import { LibraryType } from 'src/entities/library.entity'; import { IAccessRepository } from 'src/interfaces/access.interface'; import { IAssetRepositoryV1 } from 'src/interfaces/asset-v1.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface'; @@ -76,15 +75,20 @@ export class AssetServiceV1 { let livePhotoAsset: AssetEntity | null = null; try { - const libraryId = await this.getLibraryId(auth, dto.libraryId); - await this.access.requirePermission(auth, Permission.ASSET_UPLOAD, libraryId); + await this.access.requirePermission( + auth, + Permission.ASSET_UPLOAD, + // do not need an id here, but the interface requires it + auth.user.id, + ); + this.requireQuota(auth, file.size); if (livePhotoFile) { - const livePhotoDto = { ...dto, assetType: AssetType.VIDEO, isVisible: false, libraryId }; + const livePhotoDto = { ...dto, assetType: AssetType.VIDEO, isVisible: false }; livePhotoAsset = await this.create(auth, livePhotoDto, livePhotoFile); } - const asset = await this.create(auth, { ...dto, libraryId }, file, livePhotoAsset?.id, sidecarFile?.originalPath); + const asset = await this.create(auth, dto, file, livePhotoAsset?.id, sidecarFile?.originalPath); await this.userRepository.updateUsage(auth.user.id, (livePhotoFile?.size || 0) + file.size); @@ -245,36 +249,16 @@ export class AssetServiceV1 { return asset.previewPath; } - private async getLibraryId(auth: AuthDto, libraryId?: string) { - if (libraryId) { - return libraryId; - } - - let library = await this.libraryRepository.getDefaultUploadLibrary(auth.user.id); - if (!library) { - library = await this.libraryRepository.create({ - ownerId: auth.user.id, - name: 'Default Library', - assets: [], - type: LibraryType.UPLOAD, - importPaths: [], - exclusionPatterns: [], - }); - } - - return library.id; - } - private async create( auth: AuthDto, - dto: CreateAssetDto & { libraryId: string }, + dto: CreateAssetDto, file: UploadFile, livePhotoAssetId?: string, sidecarPath?: string, ): Promise { const asset = await this.assetRepository.create({ ownerId: auth.user.id, - libraryId: dto.libraryId, + libraryId: null, checksum: file.checksum, originalPath: file.originalPath, diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index d266b1ed2f..c37d8d8b65 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -27,7 +27,6 @@ import { AuthDto } from 'src/dtos/auth.dto'; import { MapMarkerDto, MapMarkerResponseDto, MemoryLaneDto } from 'src/dtos/search.dto'; import { UpdateStackParentDto } from 'src/dtos/stack.dto'; import { AssetEntity } from 'src/entities/asset.entity'; -import { LibraryType } from 'src/entities/library.entity'; import { IAccessRepository } from 'src/interfaces/access.interface'; import { IAlbumRepository } from 'src/interfaces/album.interface'; import { IAssetStackRepository } from 'src/interfaces/asset-stack.interface'; @@ -424,7 +423,7 @@ export class AssetService { } await this.assetRepository.remove(asset); - if (asset.library.type === LibraryType.UPLOAD) { + if (!asset.libraryId) { await this.userRepository.updateUsage(asset.ownerId, -(asset.exifInfo?.fileSizeInByte || 0)); } this.eventRepository.clientSend(ClientEvent.ASSET_DELETE, asset.ownerId, id); @@ -436,7 +435,7 @@ export class AssetService { const files = [asset.thumbnailPath, asset.previewPath, asset.encodedVideoPath]; // skip originals if the user deleted the whole library - if (!asset.library.deletedAt) { + if (!asset.library?.deletedAt) { files.push(asset.sidecarPath, asset.originalPath); } diff --git a/server/src/services/download.service.spec.ts b/server/src/services/download.service.spec.ts index 331ddcaaa7..48231a11a8 100644 --- a/server/src/services/download.service.spec.ts +++ b/server/src/services/download.service.spec.ts @@ -180,7 +180,6 @@ describe(DownloadService.name, () => { }); it('should return a list of archives (userId)', async () => { - accessMock.library.checkOwnerAccess.mockResolvedValue(new Set([authStub.admin.user.id])); assetMock.getByUserId.mockResolvedValue({ items: [assetStub.image, assetStub.video], hasNextPage: false, @@ -196,8 +195,6 @@ describe(DownloadService.name, () => { }); it('should split archives by size', async () => { - accessMock.library.checkOwnerAccess.mockResolvedValue(new Set([authStub.admin.user.id])); - assetMock.getByUserId.mockResolvedValue({ items: [ { ...assetStub.image, id: 'asset-1' }, diff --git a/server/src/services/library.service.spec.ts b/server/src/services/library.service.spec.ts index 9f2cb073c2..b63df692a4 100644 --- a/server/src/services/library.service.spec.ts +++ b/server/src/services/library.service.spec.ts @@ -4,7 +4,6 @@ import { SystemConfig } from 'src/config'; import { SystemConfigCore } from 'src/cores/system-config.core'; import { mapLibrary } from 'src/dtos/library.dto'; import { AssetType } from 'src/entities/asset.entity'; -import { LibraryType } from 'src/entities/library.entity'; import { UserEntity } from 'src/entities/user.entity'; import { IAssetRepository } from 'src/interfaces/asset.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; @@ -213,18 +212,6 @@ describe(LibraryService.name, () => { ]); }); - it('should not scan upload libraries', async () => { - const mockLibraryJob: ILibraryRefreshJob = { - id: libraryStub.externalLibrary1.id, - refreshModifiedFiles: false, - refreshAllFiles: false, - }; - - libraryMock.get.mockResolvedValue(libraryStub.uploadLibrary1); - - await expect(sut.handleQueueAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.FAILED); - }); - it('should ignore import paths that do not exist', async () => { storageMock.stat.mockImplementation((path): Promise => { if (path === libraryStub.externalLibraryWithImportPaths1.importPaths[0]) { @@ -707,7 +694,6 @@ describe(LibraryService.name, () => { describe('delete', () => { it('should delete a library', async () => { assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); - libraryMock.getUploadLibraryCount.mockResolvedValue(2); libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); await sut.delete(libraryStub.externalLibrary1.id); @@ -720,21 +706,8 @@ describe(LibraryService.name, () => { expect(libraryMock.softDelete).toHaveBeenCalledWith(libraryStub.externalLibrary1.id); }); - it('should throw error if the last upload library is deleted', async () => { - assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); - libraryMock.getUploadLibraryCount.mockResolvedValue(1); - libraryMock.get.mockResolvedValue(libraryStub.uploadLibrary1); - - await expect(sut.delete(libraryStub.uploadLibrary1.id)).rejects.toBeInstanceOf(BadRequestException); - - expect(jobMock.queue).not.toHaveBeenCalled(); - expect(jobMock.queueAll).not.toHaveBeenCalled(); - expect(libraryMock.softDelete).not.toHaveBeenCalled(); - }); - it('should allow an external library to be deleted', async () => { assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); - libraryMock.getUploadLibraryCount.mockResolvedValue(1); libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); await sut.delete(libraryStub.externalLibrary1.id); @@ -749,7 +722,6 @@ describe(LibraryService.name, () => { it('should unwatch an external library when deleted', async () => { assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); - libraryMock.getUploadLibraryCount.mockResolvedValue(1); libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); libraryMock.getAll.mockResolvedValue([libraryStub.externalLibraryWithImportPaths1]); @@ -767,37 +739,37 @@ describe(LibraryService.name, () => { describe('get', () => { it('should return a library', async () => { - libraryMock.get.mockResolvedValue(libraryStub.uploadLibrary1); - await expect(sut.get(libraryStub.uploadLibrary1.id)).resolves.toEqual( + libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); + await expect(sut.get(libraryStub.externalLibrary1.id)).resolves.toEqual( expect.objectContaining({ - id: libraryStub.uploadLibrary1.id, - name: libraryStub.uploadLibrary1.name, - ownerId: libraryStub.uploadLibrary1.ownerId, + id: libraryStub.externalLibrary1.id, + name: libraryStub.externalLibrary1.name, + ownerId: libraryStub.externalLibrary1.ownerId, }), ); - expect(libraryMock.get).toHaveBeenCalledWith(libraryStub.uploadLibrary1.id); + expect(libraryMock.get).toHaveBeenCalledWith(libraryStub.externalLibrary1.id); }); it('should throw an error when a library is not found', async () => { libraryMock.get.mockResolvedValue(null); - await expect(sut.get(libraryStub.uploadLibrary1.id)).rejects.toBeInstanceOf(BadRequestException); - expect(libraryMock.get).toHaveBeenCalledWith(libraryStub.uploadLibrary1.id); + await expect(sut.get(libraryStub.externalLibrary1.id)).rejects.toBeInstanceOf(BadRequestException); + expect(libraryMock.get).toHaveBeenCalledWith(libraryStub.externalLibrary1.id); }); }); describe('getStatistics', () => { it('should return library statistics', async () => { - libraryMock.get.mockResolvedValue(libraryStub.uploadLibrary1); + libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); libraryMock.getStatistics.mockResolvedValue({ photos: 10, videos: 0, total: 10, usage: 1337 }); - await expect(sut.getStatistics(libraryStub.uploadLibrary1.id)).resolves.toEqual({ + await expect(sut.getStatistics(libraryStub.externalLibrary1.id)).resolves.toEqual({ photos: 10, videos: 0, total: 10, usage: 1337, }); - expect(libraryMock.getStatistics).toHaveBeenCalledWith(libraryStub.uploadLibrary1.id); + expect(libraryMock.getStatistics).toHaveBeenCalledWith(libraryStub.externalLibrary1.id); }); }); @@ -805,10 +777,9 @@ describe(LibraryService.name, () => { describe('external library', () => { it('should create with default settings', async () => { libraryMock.create.mockResolvedValue(libraryStub.externalLibrary1); - await expect(sut.create({ ownerId: authStub.admin.user.id, type: LibraryType.EXTERNAL })).resolves.toEqual( + await expect(sut.create({ ownerId: authStub.admin.user.id })).resolves.toEqual( expect.objectContaining({ id: libraryStub.externalLibrary1.id, - type: LibraryType.EXTERNAL, name: libraryStub.externalLibrary1.name, ownerId: libraryStub.externalLibrary1.ownerId, assetCount: 0, @@ -823,7 +794,6 @@ describe(LibraryService.name, () => { expect(libraryMock.create).toHaveBeenCalledWith( expect.objectContaining({ name: expect.any(String), - type: LibraryType.EXTERNAL, importPaths: [], exclusionPatterns: [], }), @@ -832,12 +802,9 @@ describe(LibraryService.name, () => { it('should create with name', async () => { libraryMock.create.mockResolvedValue(libraryStub.externalLibrary1); - await expect( - sut.create({ ownerId: authStub.admin.user.id, type: LibraryType.EXTERNAL, name: 'My Awesome Library' }), - ).resolves.toEqual( + await expect(sut.create({ ownerId: authStub.admin.user.id, name: 'My Awesome Library' })).resolves.toEqual( expect.objectContaining({ id: libraryStub.externalLibrary1.id, - type: LibraryType.EXTERNAL, name: libraryStub.externalLibrary1.name, ownerId: libraryStub.externalLibrary1.ownerId, assetCount: 0, @@ -852,7 +819,6 @@ describe(LibraryService.name, () => { expect(libraryMock.create).toHaveBeenCalledWith( expect.objectContaining({ name: 'My Awesome Library', - type: LibraryType.EXTERNAL, importPaths: [], exclusionPatterns: [], }), @@ -864,13 +830,11 @@ describe(LibraryService.name, () => { await expect( sut.create({ ownerId: authStub.admin.user.id, - type: LibraryType.EXTERNAL, importPaths: ['/data/images', '/data/videos'], }), ).resolves.toEqual( expect.objectContaining({ id: libraryStub.externalLibrary1.id, - type: LibraryType.EXTERNAL, name: libraryStub.externalLibrary1.name, ownerId: libraryStub.externalLibrary1.ownerId, assetCount: 0, @@ -885,7 +849,6 @@ describe(LibraryService.name, () => { expect(libraryMock.create).toHaveBeenCalledWith( expect.objectContaining({ name: expect.any(String), - type: LibraryType.EXTERNAL, importPaths: ['/data/images', '/data/videos'], exclusionPatterns: [], }), @@ -901,7 +864,6 @@ describe(LibraryService.name, () => { await sut.init(); await sut.create({ ownerId: authStub.admin.user.id, - type: LibraryType.EXTERNAL, importPaths: libraryStub.externalLibraryWithImportPaths1.importPaths, }); }); @@ -911,13 +873,11 @@ describe(LibraryService.name, () => { await expect( sut.create({ ownerId: authStub.admin.user.id, - type: LibraryType.EXTERNAL, exclusionPatterns: ['*.tmp', '*.bak'], }), ).resolves.toEqual( expect.objectContaining({ id: libraryStub.externalLibrary1.id, - type: LibraryType.EXTERNAL, name: libraryStub.externalLibrary1.name, ownerId: libraryStub.externalLibrary1.ownerId, assetCount: 0, @@ -932,105 +892,22 @@ describe(LibraryService.name, () => { expect(libraryMock.create).toHaveBeenCalledWith( expect.objectContaining({ name: expect.any(String), - type: LibraryType.EXTERNAL, importPaths: [], exclusionPatterns: ['*.tmp', '*.bak'], }), ); }); }); - - describe('upload library', () => { - it('should create with default settings', async () => { - libraryMock.create.mockResolvedValue(libraryStub.uploadLibrary1); - await expect(sut.create({ ownerId: authStub.admin.user.id, type: LibraryType.UPLOAD })).resolves.toEqual( - expect.objectContaining({ - id: libraryStub.uploadLibrary1.id, - type: LibraryType.UPLOAD, - name: libraryStub.uploadLibrary1.name, - ownerId: libraryStub.uploadLibrary1.ownerId, - assetCount: 0, - importPaths: [], - exclusionPatterns: [], - createdAt: libraryStub.uploadLibrary1.createdAt, - updatedAt: libraryStub.uploadLibrary1.updatedAt, - refreshedAt: null, - }), - ); - - expect(libraryMock.create).toHaveBeenCalledWith( - expect.objectContaining({ - name: 'New Upload Library', - type: LibraryType.UPLOAD, - importPaths: [], - exclusionPatterns: [], - }), - ); - }); - - it('should create with name', async () => { - libraryMock.create.mockResolvedValue(libraryStub.uploadLibrary1); - await expect( - sut.create({ ownerId: authStub.admin.user.id, type: LibraryType.UPLOAD, name: 'My Awesome Library' }), - ).resolves.toEqual( - expect.objectContaining({ - id: libraryStub.uploadLibrary1.id, - type: LibraryType.UPLOAD, - name: libraryStub.uploadLibrary1.name, - ownerId: libraryStub.uploadLibrary1.ownerId, - assetCount: 0, - importPaths: [], - exclusionPatterns: [], - createdAt: libraryStub.uploadLibrary1.createdAt, - updatedAt: libraryStub.uploadLibrary1.updatedAt, - refreshedAt: null, - }), - ); - - expect(libraryMock.create).toHaveBeenCalledWith( - expect.objectContaining({ - name: 'My Awesome Library', - type: LibraryType.UPLOAD, - importPaths: [], - exclusionPatterns: [], - }), - ); - }); - - it('should not create with import paths', async () => { - await expect( - sut.create({ - ownerId: authStub.admin.user.id, - type: LibraryType.UPLOAD, - importPaths: ['/data/images', '/data/videos'], - }), - ).rejects.toBeInstanceOf(BadRequestException); - - expect(libraryMock.create).not.toHaveBeenCalled(); - }); - - it('should not create with exclusion patterns', async () => { - await expect( - sut.create({ - ownerId: authStub.admin.user.id, - type: LibraryType.UPLOAD, - exclusionPatterns: ['*.tmp', '*.bak'], - }), - ).rejects.toBeInstanceOf(BadRequestException); - - expect(libraryMock.create).not.toHaveBeenCalled(); - }); - }); }); describe('handleQueueCleanup', () => { it('should queue cleanup jobs', async () => { - libraryMock.getAllDeleted.mockResolvedValue([libraryStub.uploadLibrary1, libraryStub.externalLibrary1]); + libraryMock.getAllDeleted.mockResolvedValue([libraryStub.externalLibrary1, libraryStub.externalLibrary2]); await expect(sut.handleQueueCleanup()).resolves.toBe(JobStatus.SUCCESS); expect(jobMock.queueAll).toHaveBeenCalledWith([ - { name: JobName.LIBRARY_DELETE, data: { id: libraryStub.uploadLibrary1.id } }, { name: JobName.LIBRARY_DELETE, data: { id: libraryStub.externalLibrary1.id } }, + { name: JobName.LIBRARY_DELETE, data: { id: libraryStub.externalLibrary2.id } }, ]); }); }); @@ -1044,9 +921,9 @@ describe(LibraryService.name, () => { }); it('should update library', async () => { - libraryMock.update.mockResolvedValue(libraryStub.uploadLibrary1); - libraryMock.get.mockResolvedValue(libraryStub.uploadLibrary1); - await expect(sut.update('library-id', {})).resolves.toEqual(mapLibrary(libraryStub.uploadLibrary1)); + libraryMock.update.mockResolvedValue(libraryStub.externalLibrary1); + libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); + await expect(sut.update('library-id', {})).resolves.toEqual(mapLibrary(libraryStub.externalLibrary1)); expect(libraryMock.update).toHaveBeenCalledWith(expect.objectContaining({ id: 'library-id' })); }); }); @@ -1109,15 +986,6 @@ describe(LibraryService.name, () => { expect(storageMock.watch).not.toHaveBeenCalled(); }); - it('should throw error when watching upload library', async () => { - libraryMock.get.mockResolvedValue(libraryStub.uploadLibrary1); - libraryMock.getAll.mockResolvedValue([libraryStub.uploadLibrary1]); - - await expect(sut.watchAll()).rejects.toThrow('Can only watch external libraries'); - - expect(storageMock.watch).not.toHaveBeenCalled(); - }); - it('should handle a new file event', async () => { libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); libraryMock.getAll.mockResolvedValue([libraryStub.externalLibraryWithImportPaths1]); @@ -1253,25 +1121,25 @@ describe(LibraryService.name, () => { libraryMock.getAssetIds.mockResolvedValue([]); libraryMock.delete.mockImplementation(async () => {}); - await expect(sut.handleDeleteLibrary({ id: libraryStub.uploadLibrary1.id })).resolves.toBe(JobStatus.FAILED); + await expect(sut.handleDeleteLibrary({ id: libraryStub.externalLibrary1.id })).resolves.toBe(JobStatus.FAILED); }); it('should delete an empty library', async () => { - libraryMock.get.mockResolvedValue(libraryStub.uploadLibrary1); + libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); libraryMock.getAssetIds.mockResolvedValue([]); libraryMock.delete.mockImplementation(async () => {}); - await expect(sut.handleDeleteLibrary({ id: libraryStub.uploadLibrary1.id })).resolves.toBe(JobStatus.SUCCESS); + await expect(sut.handleDeleteLibrary({ id: libraryStub.externalLibrary1.id })).resolves.toBe(JobStatus.SUCCESS); }); it('should delete a library with assets', async () => { - libraryMock.get.mockResolvedValue(libraryStub.uploadLibrary1); + libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); libraryMock.getAssetIds.mockResolvedValue([assetStub.image1.id]); libraryMock.delete.mockImplementation(async () => {}); assetMock.getById.mockResolvedValue(assetStub.image1); - await expect(sut.handleDeleteLibrary({ id: libraryStub.uploadLibrary1.id })).resolves.toBe(JobStatus.SUCCESS); + await expect(sut.handleDeleteLibrary({ id: libraryStub.externalLibrary1.id })).resolves.toBe(JobStatus.SUCCESS); }); }); @@ -1295,14 +1163,6 @@ describe(LibraryService.name, () => { ]); }); - it('should not queue a library scan of upload library', async () => { - libraryMock.get.mockResolvedValue(libraryStub.uploadLibrary1); - - await expect(sut.queueScan(libraryStub.uploadLibrary1.id, {})).rejects.toBeInstanceOf(BadRequestException); - - expect(jobMock.queue).not.toBeCalled(); - }); - it('should queue a library scan of all modified assets', async () => { libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); diff --git a/server/src/services/library.service.ts b/server/src/services/library.service.ts index fb6991d016..3a87c418bd 100644 --- a/server/src/services/library.service.ts +++ b/server/src/services/library.service.ts @@ -12,7 +12,6 @@ import { LibraryResponseDto, LibraryStatsResponseDto, ScanLibraryDto, - SearchLibraryDto, UpdateLibraryDto, ValidateLibraryDto, ValidateLibraryImportPathResponseDto, @@ -20,7 +19,7 @@ import { mapLibrary, } from 'src/dtos/library.dto'; import { AssetType } from 'src/entities/asset.entity'; -import { LibraryEntity, LibraryType } from 'src/entities/library.entity'; +import { LibraryEntity } from 'src/entities/library.entity'; import { IAssetRepository, WithProperty } from 'src/interfaces/asset.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.interface'; @@ -118,10 +117,7 @@ export class LibraryService { } const library = await this.findOrFail(id); - - if (library.type !== LibraryType.EXTERNAL) { - throw new BadRequestException('Can only watch external libraries'); - } else if (library.importPaths.length === 0) { + if (library.importPaths.length === 0) { return false; } @@ -212,8 +208,7 @@ export class LibraryService { return false; } - const libraries = await this.repository.getAll(false, LibraryType.EXTERNAL); - + const libraries = await this.repository.getAll(false); for (const library of libraries) { await this.watch(library.id); } @@ -229,8 +224,8 @@ export class LibraryService { return mapLibrary(library); } - async getAll(dto: SearchLibraryDto): Promise { - const libraries = await this.repository.getAll(false, dto.type); + async getAll(): Promise { + const libraries = await this.repository.getAll(false); return libraries.map((library) => mapLibrary(library)); } @@ -244,37 +239,12 @@ export class LibraryService { } async create(dto: CreateLibraryDto): Promise { - switch (dto.type) { - case LibraryType.EXTERNAL: { - if (!dto.name) { - dto.name = 'New External Library'; - } - break; - } - case LibraryType.UPLOAD: { - if (!dto.name) { - dto.name = 'New Upload Library'; - } - if (dto.importPaths && dto.importPaths.length > 0) { - throw new BadRequestException('Upload libraries cannot have import paths'); - } - if (dto.exclusionPatterns && dto.exclusionPatterns.length > 0) { - throw new BadRequestException('Upload libraries cannot have exclusion patterns'); - } - break; - } - } - const library = await this.repository.create({ ownerId: dto.ownerId, - name: dto.name, - type: dto.type, + name: dto.name ?? 'New External Library', importPaths: dto.importPaths ?? [], exclusionPatterns: dto.exclusionPatterns ?? [], }); - - this.logger.log(`Creating ${dto.type} library for ${dto.ownerId}}`); - return mapLibrary(library); } @@ -362,11 +332,7 @@ export class LibraryService { } async delete(id: string) { - const library = await this.findOrFail(id); - const uploadCount = await this.repository.getUploadLibraryCount(library.ownerId); - if (library.type === LibraryType.UPLOAD && uploadCount <= 1) { - throw new BadRequestException('Cannot delete the last upload library'); - } + await this.findOrFail(id); if (this.watchLibraries) { await this.unwatch(id); @@ -529,10 +495,7 @@ export class LibraryService { } async queueScan(id: string, dto: ScanLibraryDto) { - const library = await this.findOrFail(id); - if (library.type !== LibraryType.EXTERNAL) { - throw new BadRequestException('Can only refresh external libraries'); - } + await this.findOrFail(id); await this.jobRepository.queue({ name: JobName.LIBRARY_SCAN, @@ -556,7 +519,7 @@ export class LibraryService { await this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_CLEANUP, data: {} }); // Queue all library refresh - const libraries = await this.repository.getAll(true, LibraryType.EXTERNAL); + const libraries = await this.repository.getAll(true); await this.jobRepository.queueAll( libraries.map((library) => ({ name: JobName.LIBRARY_SCAN, @@ -587,8 +550,8 @@ export class LibraryService { async handleQueueAssetRefresh(job: ILibraryRefreshJob): Promise { const library = await this.repository.get(job.id); - if (!library || library.type !== LibraryType.EXTERNAL) { - this.logger.warn('Can only refresh external libraries'); + if (!library) { + this.logger.warn('Library not found'); return JobStatus.FAILED; } diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index cca6d9eb19..7d7e2f91ac 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -409,7 +409,7 @@ export class MetadataService { } const checksum = this.cryptoRepository.hashSha1(video); - let motionAsset = await this.assetRepository.getByChecksum(asset.libraryId, checksum); + let motionAsset = await this.assetRepository.getByChecksum(asset.libraryId ?? null, checksum); if (motionAsset) { this.logger.debug( `Asset ${asset.id}'s motion photo video with checksum ${checksum.toString( diff --git a/server/test/fixtures/asset.stub.ts b/server/test/fixtures/asset.stub.ts index 35a1790a3a..e71094f456 100644 --- a/server/test/fixtures/asset.stub.ts +++ b/server/test/fixtures/asset.stub.ts @@ -48,8 +48,6 @@ export const assetStub = { deletedAt: null, isOffline: false, isExternal: false, - libraryId: 'library-id', - library: libraryStub.uploadLibrary1, duplicateId: null, }), @@ -84,8 +82,6 @@ export const assetStub = { sidecarPath: null, isOffline: false, isExternal: false, - libraryId: 'library-id', - library: libraryStub.uploadLibrary1, exifInfo: { fileSizeInByte: 123_000, } as ExifEntity, @@ -114,8 +110,6 @@ export const assetStub = { isFavorite: true, isArchived: false, isOffline: false, - libraryId: 'library-id', - library: libraryStub.uploadLibrary1, duration: null, isVisible: true, isExternal: false, @@ -156,8 +150,6 @@ export const assetStub = { livePhotoVideo: null, livePhotoVideoId: null, isOffline: false, - libraryId: 'library-id', - library: libraryStub.uploadLibrary1, tags: [], sharedLinks: [], originalFileName: 'asset-id.jpg', @@ -203,8 +195,6 @@ export const assetStub = { livePhotoVideo: null, livePhotoVideoId: null, isOffline: false, - libraryId: 'library-id', - library: libraryStub.uploadLibrary1, tags: [], sharedLinks: [], originalFileName: 'asset-id.jpg', @@ -285,8 +275,6 @@ export const assetStub = { livePhotoVideo: null, livePhotoVideoId: null, isOffline: true, - libraryId: 'library-id', - library: libraryStub.uploadLibrary1, tags: [], sharedLinks: [], originalFileName: 'asset-id.jpg', @@ -364,8 +352,6 @@ export const assetStub = { isVisible: true, livePhotoVideo: null, livePhotoVideoId: null, - libraryId: 'library-id', - library: libraryStub.uploadLibrary1, isExternal: false, isOffline: false, tags: [], @@ -401,8 +387,6 @@ export const assetStub = { isArchived: false, isExternal: false, isOffline: false, - libraryId: 'library-id', - library: libraryStub.uploadLibrary1, duration: null, isVisible: true, livePhotoVideo: null, @@ -442,8 +426,6 @@ export const assetStub = { isArchived: false, isExternal: false, isOffline: false, - libraryId: 'library-id', - library: libraryStub.uploadLibrary1, duration: null, isVisible: true, livePhotoVideo: null, @@ -467,8 +449,6 @@ export const assetStub = { isVisible: false, fileModifiedAt: new Date('2022-06-19T23:41:36.910Z'), fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'), - libraryId: 'library-id', - library: libraryStub.uploadLibrary1, exifInfo: { fileSizeInByte: 100_000, timeZone: `America/New_York`, @@ -483,8 +463,6 @@ export const assetStub = { isVisible: false, fileModifiedAt: new Date('2022-06-19T23:41:36.910Z'), fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'), - libraryId: 'library-id', - library: libraryStub.uploadLibrary1, previewPath: '/uploads/user-id/thumbs/path.ext', thumbnailPath: '/uploads/user-id/webp/path.ext', exifInfo: { @@ -502,8 +480,6 @@ export const assetStub = { isVisible: true, fileModifiedAt: new Date('2022-06-19T23:41:36.910Z'), fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'), - libraryId: 'library-id', - library: libraryStub.uploadLibrary1, exifInfo: { fileSizeInByte: 25_000, timeZone: `America/New_York`, @@ -533,8 +509,6 @@ export const assetStub = { isArchived: false, isExternal: false, isOffline: false, - libraryId: 'library-id', - library: libraryStub.uploadLibrary1, duration: null, isVisible: true, livePhotoVideo: null, @@ -576,8 +550,6 @@ export const assetStub = { isArchived: false, isExternal: false, isOffline: false, - libraryId: 'library-id', - library: libraryStub.uploadLibrary1, duration: null, isVisible: true, livePhotoVideo: null, @@ -612,8 +584,6 @@ export const assetStub = { isArchived: false, isExternal: false, isOffline: false, - libraryId: 'library-id', - library: libraryStub.uploadLibrary1, duration: null, isVisible: true, livePhotoVideo: null, @@ -649,8 +619,6 @@ export const assetStub = { isArchived: false, isExternal: false, isOffline: false, - libraryId: 'library-id', - library: libraryStub.uploadLibrary1, duration: null, isVisible: true, livePhotoVideo: null, @@ -687,8 +655,6 @@ export const assetStub = { isArchived: false, isExternal: false, isOffline: false, - libraryId: 'library-id', - library: libraryStub.uploadLibrary1, duration: null, isVisible: true, livePhotoVideo: null, @@ -807,8 +773,6 @@ export const assetStub = { livePhotoVideo: null, livePhotoVideoId: null, isOffline: false, - libraryId: 'library-id', - library: libraryStub.uploadLibrary1, tags: [], sharedLinks: [], originalFileName: 'asset-id.jpg', @@ -848,8 +812,6 @@ export const assetStub = { livePhotoVideo: null, livePhotoVideoId: null, isOffline: false, - libraryId: 'library-id', - library: libraryStub.uploadLibrary1, tags: [], sharedLinks: [], originalFileName: 'asset-id.jpg', @@ -891,8 +853,6 @@ export const assetStub = { livePhotoVideo: null, livePhotoVideoId: null, isOffline: false, - libraryId: 'library-id', - library: libraryStub.uploadLibrary1, tags: [], sharedLinks: [], originalFileName: 'asset-id.jpg', diff --git a/server/test/fixtures/error.stub.ts b/server/test/fixtures/error.stub.ts index cea514e26e..7e4399d7e5 100644 --- a/server/test/fixtures/error.stub.ts +++ b/server/test/fixtures/error.stub.ts @@ -49,9 +49,4 @@ export const errorStub = { statusCode: 400, message: 'The server already has an admin', }, - noDeleteUploadLibrary: { - error: 'Bad Request', - statusCode: 400, - message: 'Cannot delete the last upload library', - }, }; diff --git a/server/test/fixtures/library.stub.ts b/server/test/fixtures/library.stub.ts index bb95439d1c..1a83ffe5d7 100644 --- a/server/test/fixtures/library.stub.ts +++ b/server/test/fixtures/library.stub.ts @@ -1,30 +1,16 @@ import { join } from 'node:path'; import { APP_MEDIA_LOCATION } from 'src/constants'; import { THUMBNAIL_DIR } from 'src/cores/storage.core'; -import { LibraryEntity, LibraryType } from 'src/entities/library.entity'; +import { LibraryEntity } from 'src/entities/library.entity'; import { userStub } from 'test/fixtures/user.stub'; export const libraryStub = { - uploadLibrary1: Object.freeze({ - id: 'library-id', - name: 'test_library', - assets: [], - owner: userStub.user1, - ownerId: 'user-id', - type: LibraryType.UPLOAD, - importPaths: [], - createdAt: new Date('2022-01-01'), - updatedAt: new Date('2022-01-01'), - refreshedAt: null, - exclusionPatterns: [], - }), externalLibrary1: Object.freeze({ id: 'library-id', name: 'test_library', assets: [], owner: userStub.admin, ownerId: 'admin_id', - type: LibraryType.EXTERNAL, importPaths: [], createdAt: new Date('2023-01-01'), updatedAt: new Date('2023-01-01'), @@ -37,7 +23,6 @@ export const libraryStub = { assets: [], owner: userStub.admin, ownerId: 'admin_id', - type: LibraryType.EXTERNAL, importPaths: [], createdAt: new Date('2021-01-01'), updatedAt: new Date('2022-01-01'), @@ -50,7 +35,6 @@ export const libraryStub = { assets: [], owner: userStub.admin, ownerId: 'admin_id', - type: LibraryType.EXTERNAL, importPaths: ['/foo', '/bar'], createdAt: new Date('2023-01-01'), updatedAt: new Date('2023-01-01'), @@ -63,7 +47,6 @@ export const libraryStub = { assets: [], owner: userStub.admin, ownerId: 'admin_id', - type: LibraryType.EXTERNAL, importPaths: ['/xyz', '/asdf'], createdAt: new Date('2023-01-01'), updatedAt: new Date('2023-01-01'), @@ -76,7 +59,6 @@ export const libraryStub = { assets: [], owner: userStub.admin, ownerId: 'user-id', - type: LibraryType.EXTERNAL, importPaths: [], createdAt: new Date('2023-01-01'), updatedAt: new Date('2023-01-01'), @@ -89,7 +71,6 @@ export const libraryStub = { assets: [], owner: userStub.admin, ownerId: 'user-id', - type: LibraryType.EXTERNAL, importPaths: ['/xyz', '/asdf'], createdAt: new Date('2023-01-01'), updatedAt: new Date('2023-01-01'), @@ -102,7 +83,6 @@ export const libraryStub = { assets: [], owner: userStub.admin, ownerId: 'user-id', - type: LibraryType.EXTERNAL, importPaths: [join(THUMBNAIL_DIR, 'library'), '/xyz', join(APP_MEDIA_LOCATION, 'library')], createdAt: new Date('2023-01-01'), updatedAt: new Date('2023-01-01'), diff --git a/server/test/fixtures/shared-link.stub.ts b/server/test/fixtures/shared-link.stub.ts index d83bd49096..ebadc63fac 100644 --- a/server/test/fixtures/shared-link.stub.ts +++ b/server/test/fixtures/shared-link.stub.ts @@ -9,7 +9,6 @@ import { SharedLinkEntity, SharedLinkType } from 'src/entities/shared-link.entit import { UserEntity } from 'src/entities/user.entity'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; -import { libraryStub } from 'test/fixtures/library.stub'; import { userStub } from 'test/fixtures/user.stub'; const today = new Date(); @@ -210,8 +209,6 @@ export const sharedLinkStub = { isArchived: false, isExternal: false, isOffline: false, - libraryId: 'library-id', - library: libraryStub.uploadLibrary1, smartInfo: { assetId: 'id_1', tags: [], diff --git a/server/test/repositories/access.repository.mock.ts b/server/test/repositories/access.repository.mock.ts index 21d298599f..8d69e35c05 100644 --- a/server/test/repositories/access.repository.mock.ts +++ b/server/test/repositories/access.repository.mock.ts @@ -7,7 +7,6 @@ export interface IAccessRepositoryMock { asset: Mocked; album: Mocked; authDevice: Mocked; - library: Mocked; timeline: Mocked; memory: Mocked; person: Mocked; @@ -43,10 +42,6 @@ export const newAccessRepositoryMock = (reset = true): IAccessRepositoryMock => checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()), }, - library: { - checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()), - }, - timeline: { checkPartnerAccess: vitest.fn().mockResolvedValue(new Set()), }, diff --git a/server/test/repositories/library.repository.mock.ts b/server/test/repositories/library.repository.mock.ts index 4280619862..6f6100d933 100644 --- a/server/test/repositories/library.repository.mock.ts +++ b/server/test/repositories/library.repository.mock.ts @@ -10,8 +10,6 @@ export const newLibraryRepositoryMock = (): Mocked => { softDelete: vitest.fn(), update: vitest.fn(), getStatistics: vitest.fn(), - getDefaultUploadLibrary: vitest.fn(), - getUploadLibraryCount: vitest.fn(), getAssetIds: vitest.fn(), getAllDeleted: vitest.fn(), getAll: vitest.fn(), 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 3896d7df78..244d99bf37 100644 --- a/web/src/lib/components/forms/library-scan-settings-form.svelte +++ b/web/src/lib/components/forms/library-scan-settings-form.svelte @@ -1,5 +1,5 @@ -

{getDateRange(startDate, endDate)}

-

·

-

{album.assetCount} items

+ {getDateRange(startDate, endDate)} + + {album.assetCount} items
diff --git a/web/src/lib/components/asset-viewer/album-list-item-details.svelte b/web/src/lib/components/asset-viewer/album-list-item-details.svelte new file mode 100644 index 0000000000..60e49526ef --- /dev/null +++ b/web/src/lib/components/asset-viewer/album-list-item-details.svelte @@ -0,0 +1,10 @@ + + +{album.assetCount} items +{#if album.shared} + • Shared +{/if} diff --git a/web/src/lib/components/asset-viewer/album-list-item.svelte b/web/src/lib/components/asset-viewer/album-list-item.svelte index 23c06e1bd5..0cde7c8465 100644 --- a/web/src/lib/components/asset-viewer/album-list-item.svelte +++ b/web/src/lib/components/asset-viewer/album-list-item.svelte @@ -3,13 +3,13 @@ import { ThumbnailFormat, type AlbumResponseDto } from '@immich/sdk'; import { createEventDispatcher } from 'svelte'; import { normalizeSearchString } from '$lib/utils/string-utils.js'; + import AlbumListItemDetails from './album-list-item-details.svelte'; const dispatch = createEventDispatcher<{ album: void; }>(); export let album: AlbumResponseDto; - export let variant: 'simple' | 'full' = 'full'; export let searchQuery = ''; let albumNameArray: string[] = ['', '', '']; @@ -31,7 +31,7 @@ on:click={() => dispatch('album')} class="flex w-full gap-4 px-6 py-2 text-left transition-colors hover:bg-gray-200 dark:hover:bg-gray-700 rounded-xl" > -
+ {#if album.albumThumbnailAssetId} {/if} -
-
+ + {albumNameArray[0]}{albumNameArray[1]}{albumNameArray[2]} - {#if variant === 'simple'} - {album.shared ? 'Shared' : ''} - {:else} - {album.assetCount} items{album.shared ? ' - Shared' : ''} - {/if} + -
+ diff --git a/web/src/lib/components/asset-viewer/detail-panel.svelte b/web/src/lib/components/asset-viewer/detail-panel.svelte index 653d5903d2..d2590b3db1 100644 --- a/web/src/lib/components/asset-viewer/detail-panel.svelte +++ b/web/src/lib/components/asset-viewer/detail-panel.svelte @@ -33,8 +33,8 @@ import { DateTime } from 'luxon'; import { createEventDispatcher, onMount } from 'svelte'; import { slide } from 'svelte/transition'; - import { asByteUnitString } from '../../utils/byte-units'; - import { handleError } from '../../utils/handle-error'; + import { asByteUnitString } from '$lib/utils/byte-units'; + import { handleError } from '$lib/utils/handle-error'; import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte'; import CircleIconButton from '../elements/buttons/circle-icon-button.svelte'; import PersonSidePanel from '../faces-page/person-side-panel.svelte'; @@ -43,6 +43,7 @@ import LoadingSpinner from '../shared-components/loading-spinner.svelte'; import { NotificationType, notificationController } from '../shared-components/notification/notification'; import { shortcut } from '$lib/utils/shortcut'; + import AlbumListItemDetails from './album-list-item-details.svelte'; export let asset: AssetResponseDto; export let albums: AlbumResponseDto[] = []; @@ -607,10 +608,7 @@

{album.albumName}

- {album.assetCount} items - {#if album.shared} - • Shared - {/if} +
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 a3ef3a9ff3..385e70d83b 100644 --- a/web/src/lib/components/shared-components/album-selection-modal.svelte +++ b/web/src/lib/components/shared-components/album-selection-modal.svelte @@ -89,7 +89,7 @@ {#if !shared && search.length === 0}

RECENT

{#each recentAlbums as album (album.id)} - handleSelect(album)} /> + handleSelect(album)} /> {/each} {/if} From a3e7e8cc3193df75a37f98b9c82adbabb74450c4 Mon Sep 17 00:00:00 2001 From: Zack Pollard Date: Wed, 22 May 2024 10:25:55 +0100 Subject: [PATCH 121/163] refactor: deprecate /server-info and replace with /server-info/storage (#9645) --- .../server_info/server_disk_info.model.dart | 2 +- .../lib/providers/backup/backup.provider.dart | 12 +- .../backup/manual_upload.provider.dart | 2 +- mobile/lib/services/server_info.service.dart | 6 +- .../common/app_bar_dialog/app_bar_dialog.dart | 2 +- mobile/openapi/README.md | 4 +- mobile/openapi/lib/api.dart | 3 +- mobile/openapi/lib/api/deprecated_api.dart | 62 +++++++++ mobile/openapi/lib/api/server_info_api.dart | 50 ++++++- mobile/openapi/lib/api_client.dart | 4 +- ....dart => server_storage_response_dto.dart} | 36 +++--- open-api/immich-openapi-specs.json | 122 ++++++++++++------ open-api/typescript-sdk/src/fetch-client.ts | 15 ++- .../src/controllers/server-info.controller.ts | 14 +- server/src/dtos/server-info.dto.ts | 2 +- .../src/services/server-info.service.spec.ts | 14 +- server/src/services/server-info.service.ts | 6 +- web/src/lib/stores/server-info.store.ts | 4 +- web/src/lib/utils/auth.ts | 4 +- 19 files changed, 265 insertions(+), 99 deletions(-) create mode 100644 mobile/openapi/lib/api/deprecated_api.dart rename mobile/openapi/lib/model/{server_info_response_dto.dart => server_storage_response_dto.dart} (71%) diff --git a/mobile/lib/models/server_info/server_disk_info.model.dart b/mobile/lib/models/server_info/server_disk_info.model.dart index 95e94e369c..01ce49beec 100644 --- a/mobile/lib/models/server_info/server_disk_info.model.dart +++ b/mobile/lib/models/server_info/server_disk_info.model.dart @@ -32,7 +32,7 @@ class ServerDiskInfo { return 'ServerDiskInfo(diskAvailable: $diskAvailable, diskSize: $diskSize, diskUse: $diskUse, diskUsagePercentage: $diskUsagePercentage)'; } - ServerDiskInfo.fromDto(ServerInfoResponseDto dto) + ServerDiskInfo.fromDto(ServerStorageResponseDto dto) : diskAvailable = dto.diskAvailable, diskSize = dto.diskSize, diskUse = dto.diskUse, diff --git a/mobile/lib/providers/backup/backup.provider.dart b/mobile/lib/providers/backup/backup.provider.dart index ab869c2328..58027e3b94 100644 --- a/mobile/lib/providers/backup/backup.provider.dart +++ b/mobile/lib/providers/backup/backup.provider.dart @@ -374,7 +374,7 @@ class BackupNotifier extends StateNotifier { if (state.backupProgress != BackUpProgressEnum.inBackground) { await _getBackupAlbumsInfo(); - await updateServerInfo(); + await updateDiskInfo(); await _updateBackupAssetCount(); } else { log.warning("cannot get backup info - background backup is in progress!"); @@ -542,7 +542,7 @@ class BackupNotifier extends StateNotifier { _updatePersistentAlbumsSelection(); } - updateServerInfo(); + updateDiskInfo(); } void _onUploadProgress(int sent, int total) { @@ -579,13 +579,13 @@ class BackupNotifier extends StateNotifier { ); } - Future updateServerInfo() async { - final serverInfo = await _serverInfoService.getServerInfo(); + Future updateDiskInfo() async { + final diskInfo = await _serverInfoService.getDiskInfo(); // Update server info - if (serverInfo != null) { + if (diskInfo != null) { state = state.copyWith( - serverInfo: serverInfo, + serverInfo: diskInfo, ); } } diff --git a/mobile/lib/providers/backup/manual_upload.provider.dart b/mobile/lib/providers/backup/manual_upload.provider.dart index 1de7f7a788..b446711226 100644 --- a/mobile/lib/providers/backup/manual_upload.provider.dart +++ b/mobile/lib/providers/backup/manual_upload.provider.dart @@ -121,7 +121,7 @@ class ManualUploadNotifier extends StateNotifier { bool isDuplicated, ) { state = state.copyWith(successfulUploads: state.successfulUploads + 1); - _backupProvider.updateServerInfo(); + _backupProvider.updateDiskInfo(); } void _onAssetUploadError(ErrorUploadAsset errorAssetInfo) { diff --git a/mobile/lib/services/server_info.service.dart b/mobile/lib/services/server_info.service.dart index a2ce77c820..e2b7db2fce 100644 --- a/mobile/lib/services/server_info.service.dart +++ b/mobile/lib/services/server_info.service.dart @@ -18,14 +18,14 @@ class ServerInfoService { ServerInfoService(this._apiService); - Future getServerInfo() async { + Future getDiskInfo() async { try { - final dto = await _apiService.serverInfoApi.getServerInfo(); + final dto = await _apiService.serverInfoApi.getStorage(); if (dto != null) { return ServerDiskInfo.fromDto(dto); } } catch (e) { - debugPrint("Error [getServerInfo] ${e.toString()}"); + debugPrint("Error [getDiskInfo] ${e.toString()}"); } return null; } diff --git a/mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart b/mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart index 75d3966b97..fbcfd64713 100644 --- a/mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart +++ b/mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart @@ -31,7 +31,7 @@ class ImmichAppBarDialog extends HookConsumerWidget { useEffect( () { - ref.read(backupProvider.notifier).updateServerInfo(); + ref.read(backupProvider.notifier).updateDiskInfo(); ref.read(currentUserProvider.notifier).refresh(); return null; }, diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 090182fc39..6055742602 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -116,6 +116,7 @@ Class | Method | HTTP request | Description *AuthenticationApi* | [**logout**](doc//AuthenticationApi.md#logout) | **POST** /auth/logout | *AuthenticationApi* | [**signUpAdmin**](doc//AuthenticationApi.md#signupadmin) | **POST** /auth/admin-sign-up | *AuthenticationApi* | [**validateAccessToken**](doc//AuthenticationApi.md#validateaccesstoken) | **POST** /auth/validateToken | +*DeprecatedApi* | [**getServerInfo**](doc//DeprecatedApi.md#getserverinfo) | **GET** /server-info | *DownloadApi* | [**downloadArchive**](doc//DownloadApi.md#downloadarchive) | **POST** /download/archive | *DownloadApi* | [**downloadFile**](doc//DownloadApi.md#downloadfile) | **POST** /download/asset/{id} | *DownloadApi* | [**getDownloadInfo**](doc//DownloadApi.md#getdownloadinfo) | **POST** /download/info | @@ -174,6 +175,7 @@ Class | Method | HTTP request | Description *ServerInfoApi* | [**getServerInfo**](doc//ServerInfoApi.md#getserverinfo) | **GET** /server-info | *ServerInfoApi* | [**getServerStatistics**](doc//ServerInfoApi.md#getserverstatistics) | **GET** /server-info/statistics | *ServerInfoApi* | [**getServerVersion**](doc//ServerInfoApi.md#getserverversion) | **GET** /server-info/version | +*ServerInfoApi* | [**getStorage**](doc//ServerInfoApi.md#getstorage) | **GET** /server-info/storage | *ServerInfoApi* | [**getSupportedMediaTypes**](doc//ServerInfoApi.md#getsupportedmediatypes) | **GET** /server-info/media-types | *ServerInfoApi* | [**getTheme**](doc//ServerInfoApi.md#gettheme) | **GET** /server-info/theme | *ServerInfoApi* | [**pingServer**](doc//ServerInfoApi.md#pingserver) | **GET** /server-info/ping | @@ -348,10 +350,10 @@ Class | Method | HTTP request | Description - [SearchSuggestionType](doc//SearchSuggestionType.md) - [ServerConfigDto](doc//ServerConfigDto.md) - [ServerFeaturesDto](doc//ServerFeaturesDto.md) - - [ServerInfoResponseDto](doc//ServerInfoResponseDto.md) - [ServerMediaTypesResponseDto](doc//ServerMediaTypesResponseDto.md) - [ServerPingResponse](doc//ServerPingResponse.md) - [ServerStatsResponseDto](doc//ServerStatsResponseDto.md) + - [ServerStorageResponseDto](doc//ServerStorageResponseDto.md) - [ServerThemeDto](doc//ServerThemeDto.md) - [ServerVersionResponseDto](doc//ServerVersionResponseDto.md) - [SessionResponseDto](doc//SessionResponseDto.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 8cd0f4365c..be7c4a936e 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -35,6 +35,7 @@ part 'api/album_api.dart'; part 'api/asset_api.dart'; part 'api/audit_api.dart'; part 'api/authentication_api.dart'; +part 'api/deprecated_api.dart'; part 'api/download_api.dart'; part 'api/duplicate_api.dart'; part 'api/face_api.dart'; @@ -180,10 +181,10 @@ part 'model/search_response_dto.dart'; part 'model/search_suggestion_type.dart'; part 'model/server_config_dto.dart'; part 'model/server_features_dto.dart'; -part 'model/server_info_response_dto.dart'; part 'model/server_media_types_response_dto.dart'; part 'model/server_ping_response.dart'; part 'model/server_stats_response_dto.dart'; +part 'model/server_storage_response_dto.dart'; part 'model/server_theme_dto.dart'; part 'model/server_version_response_dto.dart'; part 'model/session_response_dto.dart'; diff --git a/mobile/openapi/lib/api/deprecated_api.dart b/mobile/openapi/lib/api/deprecated_api.dart new file mode 100644 index 0000000000..117735015d --- /dev/null +++ b/mobile/openapi/lib/api/deprecated_api.dart @@ -0,0 +1,62 @@ +// +// 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 DeprecatedApi { + DeprecatedApi([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient; + + final ApiClient apiClient; + + /// This property was deprecated in v1.106.0 + /// + /// Note: This method returns the HTTP [Response]. + Future getServerInfoWithHttpInfo() async { + // ignore: prefer_const_declarations + final path = r'/server-info'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + path, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// This property was deprecated in v1.106.0 + Future getServerInfo() async { + final response = await getServerInfoWithHttpInfo(); + 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), 'ServerStorageResponseDto',) as ServerStorageResponseDto; + + } + return null; + } +} diff --git a/mobile/openapi/lib/api/server_info_api.dart b/mobile/openapi/lib/api/server_info_api.dart index db72dd370d..6e55c62759 100644 --- a/mobile/openapi/lib/api/server_info_api.dart +++ b/mobile/openapi/lib/api/server_info_api.dart @@ -98,7 +98,9 @@ class ServerInfoApi { return null; } - /// Performs an HTTP 'GET /server-info' operation and returns the [Response]. + /// This property was deprecated in v1.106.0 + /// + /// Note: This method returns the HTTP [Response]. Future getServerInfoWithHttpInfo() async { // ignore: prefer_const_declarations final path = r'/server-info'; @@ -124,7 +126,8 @@ class ServerInfoApi { ); } - Future getServerInfo() async { + /// This property was deprecated in v1.106.0 + Future getServerInfo() async { final response = await getServerInfoWithHttpInfo(); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); @@ -133,7 +136,7 @@ class ServerInfoApi { // 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), 'ServerInfoResponseDto',) as ServerInfoResponseDto; + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'ServerStorageResponseDto',) as ServerStorageResponseDto; } return null; @@ -221,6 +224,47 @@ class ServerInfoApi { return null; } + /// Performs an HTTP 'GET /server-info/storage' operation and returns the [Response]. + Future getStorageWithHttpInfo() async { + // ignore: prefer_const_declarations + final path = r'/server-info/storage'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + path, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + Future getStorage() async { + final response = await getStorageWithHttpInfo(); + 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), 'ServerStorageResponseDto',) as ServerStorageResponseDto; + + } + return null; + } + /// Performs an HTTP 'GET /server-info/media-types' operation and returns the [Response]. Future getSupportedMediaTypesWithHttpInfo() async { // ignore: prefer_const_declarations diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 2bebf46b1c..3e2f2c15c6 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -428,14 +428,14 @@ class ApiClient { return ServerConfigDto.fromJson(value); case 'ServerFeaturesDto': return ServerFeaturesDto.fromJson(value); - case 'ServerInfoResponseDto': - return ServerInfoResponseDto.fromJson(value); case 'ServerMediaTypesResponseDto': return ServerMediaTypesResponseDto.fromJson(value); case 'ServerPingResponse': return ServerPingResponse.fromJson(value); case 'ServerStatsResponseDto': return ServerStatsResponseDto.fromJson(value); + case 'ServerStorageResponseDto': + return ServerStorageResponseDto.fromJson(value); case 'ServerThemeDto': return ServerThemeDto.fromJson(value); case 'ServerVersionResponseDto': diff --git a/mobile/openapi/lib/model/server_info_response_dto.dart b/mobile/openapi/lib/model/server_storage_response_dto.dart similarity index 71% rename from mobile/openapi/lib/model/server_info_response_dto.dart rename to mobile/openapi/lib/model/server_storage_response_dto.dart index 295e0f7e6a..ab0f169e4b 100644 --- a/mobile/openapi/lib/model/server_info_response_dto.dart +++ b/mobile/openapi/lib/model/server_storage_response_dto.dart @@ -10,9 +10,9 @@ part of openapi.api; -class ServerInfoResponseDto { - /// Returns a new [ServerInfoResponseDto] instance. - ServerInfoResponseDto({ +class ServerStorageResponseDto { + /// Returns a new [ServerStorageResponseDto] instance. + ServerStorageResponseDto({ required this.diskAvailable, required this.diskAvailableRaw, required this.diskSize, @@ -37,7 +37,7 @@ class ServerInfoResponseDto { int diskUseRaw; @override - bool operator ==(Object other) => identical(this, other) || other is ServerInfoResponseDto && + bool operator ==(Object other) => identical(this, other) || other is ServerStorageResponseDto && other.diskAvailable == diskAvailable && other.diskAvailableRaw == diskAvailableRaw && other.diskSize == diskSize && @@ -58,7 +58,7 @@ class ServerInfoResponseDto { (diskUseRaw.hashCode); @override - String toString() => 'ServerInfoResponseDto[diskAvailable=$diskAvailable, diskAvailableRaw=$diskAvailableRaw, diskSize=$diskSize, diskSizeRaw=$diskSizeRaw, diskUsagePercentage=$diskUsagePercentage, diskUse=$diskUse, diskUseRaw=$diskUseRaw]'; + String toString() => 'ServerStorageResponseDto[diskAvailable=$diskAvailable, diskAvailableRaw=$diskAvailableRaw, diskSize=$diskSize, diskSizeRaw=$diskSizeRaw, diskUsagePercentage=$diskUsagePercentage, diskUse=$diskUse, diskUseRaw=$diskUseRaw]'; Map toJson() { final json = {}; @@ -72,14 +72,14 @@ class ServerInfoResponseDto { return json; } - /// Returns a new [ServerInfoResponseDto] instance and imports its values from + /// Returns a new [ServerStorageResponseDto] instance and imports its values from /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods - static ServerInfoResponseDto? fromJson(dynamic value) { + static ServerStorageResponseDto? fromJson(dynamic value) { if (value is Map) { final json = value.cast(); - return ServerInfoResponseDto( + return ServerStorageResponseDto( diskAvailable: mapValueOfType(json, r'diskAvailable')!, diskAvailableRaw: mapValueOfType(json, r'diskAvailableRaw')!, diskSize: mapValueOfType(json, r'diskSize')!, @@ -92,11 +92,11 @@ class ServerInfoResponseDto { return null; } - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; if (json is List && json.isNotEmpty) { for (final row in json) { - final value = ServerInfoResponseDto.fromJson(row); + final value = ServerStorageResponseDto.fromJson(row); if (value != null) { result.add(value); } @@ -105,12 +105,12 @@ class ServerInfoResponseDto { return result.toList(growable: growable); } - static Map mapFromJson(dynamic json) { - final map = {}; + 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 = ServerInfoResponseDto.fromJson(entry.value); + final value = ServerStorageResponseDto.fromJson(entry.value); if (value != null) { map[entry.key] = value; } @@ -119,14 +119,14 @@ class ServerInfoResponseDto { return map; } - // maps a json object with a list of ServerInfoResponseDto-objects as value to a dart map - static Map> mapListFromJson(dynamic json, {bool growable = false,}) { - final map = >{}; + // maps a json object with a list of ServerStorageResponseDto-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] = ServerInfoResponseDto.listFromJson(entry.value, growable: growable,); + map[entry.key] = ServerStorageResponseDto.listFromJson(entry.value, growable: growable,); } } return map; diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index f2d30f1537..c6eff20a47 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -4337,6 +4337,8 @@ }, "/server-info": { "get": { + "deprecated": true, + "description": "This property was deprecated in v1.106.0", "operationId": "getServerInfo", "parameters": [], "responses": { @@ -4344,7 +4346,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ServerInfoResponseDto" + "$ref": "#/components/schemas/ServerStorageResponseDto" } } }, @@ -4363,8 +4365,12 @@ } ], "tags": [ - "Server Info" - ] + "Server Info", + "Deprecated" + ], + "x-immich-lifecycle": { + "deprecatedAt": "v1.106.0" + } } }, "/server-info/config": { @@ -4483,6 +4489,38 @@ ] } }, + "/server-info/storage": { + "get": { + "operationId": "getStorage", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ServerStorageResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Server Info" + ] + } + }, "/server-info/theme": { "get": { "operationId": "getTheme", @@ -9487,45 +9525,6 @@ ], "type": "object" }, - "ServerInfoResponseDto": { - "properties": { - "diskAvailable": { - "type": "string" - }, - "diskAvailableRaw": { - "format": "int64", - "type": "integer" - }, - "diskSize": { - "type": "string" - }, - "diskSizeRaw": { - "format": "int64", - "type": "integer" - }, - "diskUsagePercentage": { - "format": "float", - "type": "number" - }, - "diskUse": { - "type": "string" - }, - "diskUseRaw": { - "format": "int64", - "type": "integer" - } - }, - "required": [ - "diskAvailable", - "diskAvailableRaw", - "diskSize", - "diskSizeRaw", - "diskUsagePercentage", - "diskUse", - "diskUseRaw" - ], - "type": "object" - }, "ServerMediaTypesResponseDto": { "properties": { "image": { @@ -9606,6 +9605,45 @@ ], "type": "object" }, + "ServerStorageResponseDto": { + "properties": { + "diskAvailable": { + "type": "string" + }, + "diskAvailableRaw": { + "format": "int64", + "type": "integer" + }, + "diskSize": { + "type": "string" + }, + "diskSizeRaw": { + "format": "int64", + "type": "integer" + }, + "diskUsagePercentage": { + "format": "float", + "type": "number" + }, + "diskUse": { + "type": "string" + }, + "diskUseRaw": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "diskAvailable", + "diskAvailableRaw", + "diskSize", + "diskSizeRaw", + "diskUsagePercentage", + "diskUse", + "diskUseRaw" + ], + "type": "object" + }, "ServerThemeDto": { "properties": { "customCss": { diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index d3ab222d77..050dbfeb9a 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -732,7 +732,7 @@ export type SmartSearchDto = { withDeleted?: boolean; withExif?: boolean; }; -export type ServerInfoResponseDto = { +export type ServerStorageResponseDto = { diskAvailable: string; diskAvailableRaw: number; diskSize: string; @@ -2245,10 +2245,13 @@ export function getSearchSuggestions({ country, make, model, state, $type }: { ...opts })); } +/** + * This property was deprecated in v1.106.0 + */ export function getServerInfo(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; - data: ServerInfoResponseDto; + data: ServerStorageResponseDto; }>("/server-info", { ...opts })); @@ -2293,6 +2296,14 @@ export function getServerStatistics(opts?: Oazapfts.RequestOpts) { ...opts })); } +export function getStorage(opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: ServerStorageResponseDto; + }>("/server-info/storage", { + ...opts + })); +} export function getTheme(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; diff --git a/server/src/controllers/server-info.controller.ts b/server/src/controllers/server-info.controller.ts index 960acfe3fd..308778dbb5 100644 --- a/server/src/controllers/server-info.controller.ts +++ b/server/src/controllers/server-info.controller.ts @@ -1,12 +1,13 @@ import { Controller, Get } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; +import { EndpointLifecycle } from 'src/decorators'; import { ServerConfigDto, ServerFeaturesDto, - ServerInfoResponseDto, ServerMediaTypesResponseDto, ServerPingResponse, ServerStatsResponseDto, + ServerStorageResponseDto, ServerThemeDto, ServerVersionResponseDto, } from 'src/dtos/server-info.dto'; @@ -23,9 +24,16 @@ export class ServerInfoController { ) {} @Get() + @EndpointLifecycle({ deprecatedAt: 'v1.106.0' }) @Authenticated() - getServerInfo(): Promise { - return this.service.getInfo(); + getServerInfo(): Promise { + return this.service.getStorage(); + } + + @Get('storage') + @Authenticated() + getStorage(): Promise { + return this.service.getStorage(); } @Get('ping') diff --git a/server/src/dtos/server-info.dto.ts b/server/src/dtos/server-info.dto.ts index 1e91332c0d..6afe8534c7 100644 --- a/server/src/dtos/server-info.dto.ts +++ b/server/src/dtos/server-info.dto.ts @@ -7,7 +7,7 @@ export class ServerPingResponse { res!: string; } -export class ServerInfoResponseDto { +export class ServerStorageResponseDto { diskSize!: string; diskUse!: string; diskAvailable!: string; diff --git a/server/src/services/server-info.service.spec.ts b/server/src/services/server-info.service.spec.ts index 41f2e95e22..57beb165db 100644 --- a/server/src/services/server-info.service.spec.ts +++ b/server/src/services/server-info.service.spec.ts @@ -29,11 +29,11 @@ describe(ServerInfoService.name, () => { expect(sut).toBeDefined(); }); - describe('getInfo', () => { + describe('getStorage', () => { it('should return the disk space as B', async () => { storageMock.checkDiskUsage.mockResolvedValue({ free: 200, available: 300, total: 500 }); - await expect(sut.getInfo()).resolves.toEqual({ + await expect(sut.getStorage()).resolves.toEqual({ diskAvailable: '300 B', diskAvailableRaw: 300, diskSize: '500 B', @@ -49,7 +49,7 @@ describe(ServerInfoService.name, () => { it('should return the disk space as KiB', async () => { storageMock.checkDiskUsage.mockResolvedValue({ free: 200_000, available: 300_000, total: 500_000 }); - await expect(sut.getInfo()).resolves.toEqual({ + await expect(sut.getStorage()).resolves.toEqual({ diskAvailable: '293.0 KiB', diskAvailableRaw: 300_000, diskSize: '488.3 KiB', @@ -65,7 +65,7 @@ describe(ServerInfoService.name, () => { it('should return the disk space as MiB', async () => { storageMock.checkDiskUsage.mockResolvedValue({ free: 200_000_000, available: 300_000_000, total: 500_000_000 }); - await expect(sut.getInfo()).resolves.toEqual({ + await expect(sut.getStorage()).resolves.toEqual({ diskAvailable: '286.1 MiB', diskAvailableRaw: 300_000_000, diskSize: '476.8 MiB', @@ -85,7 +85,7 @@ describe(ServerInfoService.name, () => { total: 500_000_000_000, }); - await expect(sut.getInfo()).resolves.toEqual({ + await expect(sut.getStorage()).resolves.toEqual({ diskAvailable: '279.4 GiB', diskAvailableRaw: 300_000_000_000, diskSize: '465.7 GiB', @@ -105,7 +105,7 @@ describe(ServerInfoService.name, () => { total: 500_000_000_000_000, }); - await expect(sut.getInfo()).resolves.toEqual({ + await expect(sut.getStorage()).resolves.toEqual({ diskAvailable: '272.8 TiB', diskAvailableRaw: 300_000_000_000_000, diskSize: '454.7 TiB', @@ -125,7 +125,7 @@ describe(ServerInfoService.name, () => { total: 500_000_000_000_000_000, }); - await expect(sut.getInfo()).resolves.toEqual({ + await expect(sut.getStorage()).resolves.toEqual({ diskAvailable: '266.5 PiB', diskAvailableRaw: 300_000_000_000_000_000, diskSize: '444.1 PiB', diff --git a/server/src/services/server-info.service.ts b/server/src/services/server-info.service.ts index 9e27e9d7ac..89e095ba5d 100644 --- a/server/src/services/server-info.service.ts +++ b/server/src/services/server-info.service.ts @@ -4,10 +4,10 @@ import { SystemConfigCore } from 'src/cores/system-config.core'; import { ServerConfigDto, ServerFeaturesDto, - ServerInfoResponseDto, ServerMediaTypesResponseDto, ServerPingResponse, ServerStatsResponseDto, + ServerStorageResponseDto, UsageByUserDto, } from 'src/dtos/server-info.dto'; import { SystemMetadataKey } from 'src/entities/system-metadata.entity'; @@ -42,13 +42,13 @@ export class ServerInfoService { } } - async getInfo(): Promise { + async getStorage(): Promise { const libraryBase = StorageCore.getBaseFolder(StorageFolder.LIBRARY); const diskInfo = await this.storageRepository.checkDiskUsage(libraryBase); const usagePercentage = (((diskInfo.total - diskInfo.free) / diskInfo.total) * 100).toFixed(2); - const serverInfo = new ServerInfoResponseDto(); + const serverInfo = new ServerStorageResponseDto(); serverInfo.diskAvailable = asHumanReadable(diskInfo.available); serverInfo.diskSize = asHumanReadable(diskInfo.total); serverInfo.diskUse = asHumanReadable(diskInfo.total - diskInfo.free); diff --git a/web/src/lib/stores/server-info.store.ts b/web/src/lib/stores/server-info.store.ts index 817bbeca4a..360b2c5630 100644 --- a/web/src/lib/stores/server-info.store.ts +++ b/web/src/lib/stores/server-info.store.ts @@ -1,4 +1,4 @@ -import type { ServerInfoResponseDto } from '@immich/sdk'; +import type { ServerStorageResponseDto } from '@immich/sdk'; import { writable } from 'svelte/store'; -export const serverInfo = writable(); +export const serverInfo = writable(); diff --git a/web/src/lib/utils/auth.ts b/web/src/lib/utils/auth.ts index dc7bb53db1..1fbc158f74 100644 --- a/web/src/lib/utils/auth.ts +++ b/web/src/lib/utils/auth.ts @@ -1,7 +1,7 @@ import { browser } from '$app/environment'; import { serverInfo } from '$lib/stores/server-info.store'; import { user } from '$lib/stores/user.store'; -import { getMyUserInfo, getServerInfo } from '@immich/sdk'; +import { getMyUserInfo, getStorage } from '@immich/sdk'; import { redirect } from '@sveltejs/kit'; import { get } from 'svelte/store'; import { AppRoute } from '../constants'; @@ -58,7 +58,7 @@ export const authenticate = async (options?: AuthOptions) => { export const requestServerInfo = async () => { if (get(user)) { - const data = await getServerInfo(); + const data = await getStorage(); serverInfo.set(data); } }; From f8ee977b9e1bd0f0dbaf4aac1fbf5677d4c744a1 Mon Sep 17 00:00:00 2001 From: Matthew Momjian <50788000+mmomjian@users.noreply.github.com> Date: Wed, 22 May 2024 05:28:12 -0400 Subject: [PATCH 122/163] feat(server): healthchecks for PG and redis (#9590) * HCs -> docker compose --------- Co-authored-by: Zack Pollard --- docker/docker-compose.dev.yml | 7 +++++++ docker/docker-compose.prod.yml | 10 +++++++++- docker/docker-compose.yml | 9 ++++++++- 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index 9f7e9e5b0e..a2b8bf0bb4 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -82,6 +82,8 @@ services: redis: container_name: immich_redis image: redis:6.2-alpine@sha256:c0634a08e74a4bb576d02d1ee993dc05dba10e8b7b9492dfa28a7af100d46c01 + healthcheck: + test: redis-cli ping || exit 1 database: container_name: immich_postgres @@ -97,6 +99,11 @@ services: - ${UPLOAD_LOCATION}/postgres:/var/lib/postgresql/data ports: - 5432:5432 + healthcheck: + test: pg_isready --dbname='${DB_DATABASE_NAME}' || exit 1; Chksum="$$(psql --dbname='${DB_DATABASE_NAME}' --username='${DB_USERNAME}' --tuples-only --no-align --command='SELECT SUM(checksum_failures) FROM pg_stat_database')"; echo "checksum failure count is $$Chksum"; [ "$$Chksum" = '0' ] || exit 1 + interval: 5m + start_interval: 30s + start_period: 5m command: ["postgres", "-c" ,"shared_preload_libraries=vectors.so", "-c", 'search_path="$$user", public, vectors', "-c", "logging_collector=on", "-c", "max_wal_size=2GB", "-c", "shared_buffers=512MB", "-c", "wal_compression=on"] # set IMMICH_METRICS=true in .env to enable metrics diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml index 929499d8e9..e47b7b499c 100644 --- a/docker/docker-compose.prod.yml +++ b/docker/docker-compose.prod.yml @@ -12,12 +12,12 @@ services: - /etc/localtime:/etc/localtime:ro env_file: - .env - restart: always ports: - 2283:3001 depends_on: - redis - database + restart: always immich-machine-learning: container_name: immich_machine_learning @@ -39,6 +39,8 @@ services: redis: container_name: immich_redis image: redis:6.2-alpine@sha256:c0634a08e74a4bb576d02d1ee993dc05dba10e8b7b9492dfa28a7af100d46c01 + healthcheck: + test: redis-cli ping || exit 1 restart: always database: @@ -55,7 +57,13 @@ services: - ${UPLOAD_LOCATION}/postgres:/var/lib/postgresql/data ports: - 5432:5432 + healthcheck: + test: pg_isready --dbname='${DB_DATABASE_NAME}' || exit 1; Chksum="$$(psql --dbname='${DB_DATABASE_NAME}' --username='${DB_USERNAME}' --tuples-only --no-align --command='SELECT SUM(checksum_failures) FROM pg_stat_database')"; echo "checksum failure count is $$Chksum"; [ "$$Chksum" = '0' ] || exit 1 + interval: 5m + start_interval: 30s + start_period: 5m command: ["postgres", "-c" ,"shared_preload_libraries=vectors.so", "-c", 'search_path="$$user", public, vectors', "-c", "logging_collector=on", "-c", "max_wal_size=2GB", "-c", "shared_buffers=512MB", "-c", "wal_compression=on"] + restart: always # set IMMICH_METRICS=true in .env to enable metrics immich-prometheus: diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 6884636756..d5d4316349 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -41,6 +41,8 @@ services: redis: container_name: immich_redis image: docker.io/redis:6.2-alpine@sha256:c0634a08e74a4bb576d02d1ee993dc05dba10e8b7b9492dfa28a7af100d46c01 + healthcheck: + test: redis-cli ping || exit 1 restart: always database: @@ -53,8 +55,13 @@ services: POSTGRES_INITDB_ARGS: '--data-checksums' volumes: - ${DB_DATA_LOCATION}:/var/lib/postgresql/data - restart: always + healthcheck: + test: pg_isready --dbname='${DB_DATABASE_NAME}' || exit 1; Chksum="$$(psql --dbname='${DB_DATABASE_NAME}' --username='${DB_USERNAME}' --tuples-only --no-align --command='SELECT SUM(checksum_failures) FROM pg_stat_database')"; echo "checksum failure count is $$Chksum"; [ "$$Chksum" = '0' ] || exit 1 + interval: 5m + start_interval: 30s + start_period: 5m command: ["postgres", "-c" ,"shared_preload_libraries=vectors.so", "-c", 'search_path="$$user", public, vectors', "-c", "logging_collector=on", "-c", "max_wal_size=2GB", "-c", "shared_buffers=512MB", "-c", "wal_compression=on"] + restart: always volumes: model-cache: From 27a02c75dc9e21ebb83ffac36482384a2d9321ec Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 22 May 2024 09:46:53 +0000 Subject: [PATCH 123/163] chore(deps): update dependency fastlane to v2.220.0 (#9653) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- mobile/android/Gemfile.lock | 2 +- mobile/ios/Gemfile.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/mobile/android/Gemfile.lock b/mobile/android/Gemfile.lock index 8c22196952..b41cba39e6 100644 --- a/mobile/android/Gemfile.lock +++ b/mobile/android/Gemfile.lock @@ -10,7 +10,7 @@ GEM artifactory (3.0.17) atomos (0.1.3) aws-eventstream (1.3.0) - aws-partitions (1.931.0) + aws-partitions (1.932.0) aws-sdk-core (3.196.1) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.651.0) diff --git a/mobile/ios/Gemfile.lock b/mobile/ios/Gemfile.lock index 8c22196952..b41cba39e6 100644 --- a/mobile/ios/Gemfile.lock +++ b/mobile/ios/Gemfile.lock @@ -10,7 +10,7 @@ GEM artifactory (3.0.17) atomos (0.1.3) aws-eventstream (1.3.0) - aws-partitions (1.931.0) + aws-partitions (1.932.0) aws-sdk-core (3.196.1) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.651.0) From a4887bfa7eb71ad55748742bdb03cadc0a0cf820 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 22 May 2024 11:43:46 +0100 Subject: [PATCH 124/163] chore(deps): bump ytanikin/PRConventionalCommits from 1.1.0 to 1.2.0 (#9661) --- updated-dependencies: - dependency-name: ytanikin/PRConventionalCommits dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/pr-require-conventional-commit.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr-require-conventional-commit.yml b/.github/workflows/pr-require-conventional-commit.yml index 9427de94ee..4899031249 100644 --- a/.github/workflows/pr-require-conventional-commit.yml +++ b/.github/workflows/pr-require-conventional-commit.yml @@ -9,7 +9,7 @@ jobs: runs-on: ubuntu-latest steps: - name: PR Conventional Commit Validation - uses: ytanikin/PRConventionalCommits@1.1.0 + uses: ytanikin/PRConventionalCommits@1.2.0 with: task_types: '["feat","fix","docs","test","ci","refactor","perf","chore","revert"]' add_label: 'false' From 06ce8247cc37ed772ff99be0f6055f8409446793 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Wed, 22 May 2024 08:13:36 -0400 Subject: [PATCH 125/163] feat(server): user metadata (#9650) * feat(server): user metadata * add missing method to user mock * update migration to include cascades * update sql files * test: fix e2e * chore: clean up --------- Co-authored-by: Daniel Dietzler --- e2e/src/api/specs/user.e2e-spec.ts | 17 ----- server/src/cores/user.core.ts | 2 +- server/src/dtos/user-profile.dto.ts | 9 --- server/src/dtos/user.dto.ts | 9 +-- server/src/entities/index.ts | 2 + server/src/entities/user-metadata.entity.ts | 63 +++++++++++++++++++ server/src/entities/user.entity.ts | 23 ++----- server/src/interfaces/user.interface.ts | 2 + .../migrations/1716312279245-UserMetadata.ts | 60 ++++++++++++++++++ server/src/queries/activity.repository.sql | 2 - server/src/queries/album.repository.sql | 24 ------- server/src/queries/api.key.repository.sql | 2 - server/src/queries/library.repository.sql | 8 --- server/src/queries/session.repository.sql | 2 - server/src/queries/shared.link.repository.sql | 6 -- server/src/queries/user.repository.sql | 8 --- server/src/repositories/user.repository.ts | 23 ++++++- server/src/services/auth.service.spec.ts | 10 ++- server/src/services/partner.service.spec.ts | 4 +- server/src/services/user.service.spec.ts | 10 ++- server/src/services/user.service.ts | 43 +++++++++++-- server/src/utils/preferences.ts | 39 ++++++++++++ server/test/fixtures/user.stub.ts | 24 ++++--- .../test/repositories/user.repository.mock.ts | 1 + 24 files changed, 267 insertions(+), 126 deletions(-) create mode 100644 server/src/entities/user-metadata.entity.ts create mode 100644 server/src/migrations/1716312279245-UserMetadata.ts create mode 100644 server/src/utils/preferences.ts diff --git a/e2e/src/api/specs/user.e2e-spec.ts b/e2e/src/api/specs/user.e2e-spec.ts index 911f25381a..5de410606a 100644 --- a/e2e/src/api/specs/user.e2e-spec.ts +++ b/e2e/src/api/specs/user.e2e-spec.ts @@ -257,23 +257,6 @@ describe('/user', () => { expect(body).toMatchObject({ id: admin.userId, profileImagePath: '' }); }); - it('should ignore updates to createdAt, updatedAt and deletedAt', async () => { - const before = await getUserById({ id: admin.userId }, { headers: asBearerAuth(admin.accessToken) }); - - const { status, body } = await request(app) - .put(`/user`) - .send({ - id: admin.userId, - createdAt: '2023-01-01T00:00:00.000Z', - updatedAt: '2023-01-01T00:00:00.000Z', - deletedAt: '2023-01-01T00:00:00.000Z', - }) - .set('Authorization', `Bearer ${admin.accessToken}`); - - expect(status).toBe(200); - expect(body).toStrictEqual(before); - }); - it('should update first and last name', async () => { const before = await getUserById({ id: admin.userId }, { headers: asBearerAuth(admin.accessToken) }); diff --git a/server/src/cores/user.core.ts b/server/src/cores/user.core.ts index 4628c83834..fa6ee7cdea 100644 --- a/server/src/cores/user.core.ts +++ b/server/src/cores/user.core.ts @@ -69,7 +69,7 @@ export class UserCore { dto.storageLabel = null; } - return this.userRepository.update(id, dto); + return this.userRepository.update(id, { ...dto, updatedAt: new Date() }); } async createUser(dto: Partial & { email: string }): Promise { diff --git a/server/src/dtos/user-profile.dto.ts b/server/src/dtos/user-profile.dto.ts index 2f3d8cf224..bc879380c4 100644 --- a/server/src/dtos/user-profile.dto.ts +++ b/server/src/dtos/user-profile.dto.ts @@ -1,6 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; import { UploadFieldName } from 'src/dtos/asset.dto'; -import { UserAvatarColor, UserEntity } from 'src/entities/user.entity'; export class CreateProfileImageDto { @ApiProperty({ type: 'string', format: 'binary' }) @@ -18,11 +17,3 @@ export function mapCreateProfileImageResponse(userId: string, profileImagePath: profileImagePath: profileImagePath, }; } - -export const getRandomAvatarColor = (user: UserEntity): UserAvatarColor => { - const values = Object.values(UserAvatarColor); - const randomIndex = Math.floor( - [...user.email].map((letter) => letter.codePointAt(0) ?? 0).reduce((a, b) => a + b, 0) % values.length, - ); - return values[randomIndex] as UserAvatarColor; -}; diff --git a/server/src/dtos/user.dto.ts b/server/src/dtos/user.dto.ts index d0d19e2713..21cc9ae2c3 100644 --- a/server/src/dtos/user.dto.ts +++ b/server/src/dtos/user.dto.ts @@ -1,8 +1,9 @@ import { ApiProperty } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; import { IsBoolean, IsEmail, IsEnum, IsNotEmpty, IsNumber, IsPositive, IsString, IsUUID } from 'class-validator'; -import { getRandomAvatarColor } from 'src/dtos/user-profile.dto'; -import { UserAvatarColor, UserEntity, UserStatus } from 'src/entities/user.entity'; +import { UserAvatarColor } from 'src/entities/user-metadata.entity'; +import { UserEntity, UserStatus } from 'src/entities/user.entity'; +import { getPreferences } from 'src/utils/preferences'; import { Optional, ValidateBoolean, toEmail, toSanitized } from 'src/validation'; export class CreateUserDto { @@ -151,7 +152,7 @@ export const mapSimpleUser = (entity: UserEntity): UserDto => { email: entity.email, name: entity.name, profileImagePath: entity.profileImagePath, - avatarColor: entity.avatarColor ?? getRandomAvatarColor(entity), + avatarColor: getPreferences(entity).avatar.color, }; }; @@ -165,7 +166,7 @@ export function mapUser(entity: UserEntity): UserResponseDto { deletedAt: entity.deletedAt, updatedAt: entity.updatedAt, oauthId: entity.oauthId, - memoriesEnabled: entity.memoriesEnabled, + memoriesEnabled: getPreferences(entity).memories.enabled, quotaSizeInBytes: entity.quotaSizeInBytes, quotaUsageInBytes: entity.quotaUsageInBytes, status: entity.status, diff --git a/server/src/entities/index.ts b/server/src/entities/index.ts index abe67efdd5..313f2dc269 100644 --- a/server/src/entities/index.ts +++ b/server/src/entities/index.ts @@ -20,6 +20,7 @@ import { SmartInfoEntity } from 'src/entities/smart-info.entity'; import { SmartSearchEntity } from 'src/entities/smart-search.entity'; import { SystemMetadataEntity } from 'src/entities/system-metadata.entity'; import { TagEntity } from 'src/entities/tag.entity'; +import { UserMetadataEntity } from 'src/entities/user-metadata.entity'; import { UserEntity } from 'src/entities/user.entity'; export const entities = [ @@ -44,6 +45,7 @@ export const entities = [ SystemMetadataEntity, TagEntity, UserEntity, + UserMetadataEntity, SessionEntity, LibraryEntity, ]; diff --git a/server/src/entities/user-metadata.entity.ts b/server/src/entities/user-metadata.entity.ts new file mode 100644 index 0000000000..26715e05e3 --- /dev/null +++ b/server/src/entities/user-metadata.entity.ts @@ -0,0 +1,63 @@ +import { UserEntity } from 'src/entities/user.entity'; +import { Column, DeepPartial, Entity, ManyToOne, PrimaryColumn } from 'typeorm'; + +@Entity('user_metadata') +export class UserMetadataEntity { + @PrimaryColumn({ type: 'uuid' }) + userId!: string; + + @ManyToOne(() => UserEntity, (user) => user.metadata, { onUpdate: 'CASCADE', onDelete: 'CASCADE' }) + user!: UserEntity; + + @PrimaryColumn({ type: 'varchar' }) + key!: T; + + @Column({ type: 'jsonb' }) + value!: UserMetadata[T]; +} + +export enum UserAvatarColor { + PRIMARY = 'primary', + PINK = 'pink', + RED = 'red', + YELLOW = 'yellow', + BLUE = 'blue', + GREEN = 'green', + PURPLE = 'purple', + ORANGE = 'orange', + GRAY = 'gray', + AMBER = 'amber', +} + +export interface UserPreferences { + memories: { + enabled: boolean; + }; + avatar: { + color: UserAvatarColor; + }; +} + +export const getDefaultPreferences = (user: { email: string }): UserPreferences => { + const values = Object.values(UserAvatarColor); + const randomIndex = Math.floor( + [...user.email].map((letter) => letter.codePointAt(0) ?? 0).reduce((a, b) => a + b, 0) % values.length, + ); + + return { + memories: { + enabled: true, + }, + avatar: { + color: values[randomIndex], + }, + }; +}; + +export enum UserMetadataKey { + PREFERENCES = 'preferences', +} + +export interface UserMetadata extends Record> { + [UserMetadataKey.PREFERENCES]: DeepPartial; +} diff --git a/server/src/entities/user.entity.ts b/server/src/entities/user.entity.ts index 4d6361abad..6878292ab0 100644 --- a/server/src/entities/user.entity.ts +++ b/server/src/entities/user.entity.ts @@ -1,5 +1,6 @@ import { AssetEntity } from 'src/entities/asset.entity'; import { TagEntity } from 'src/entities/tag.entity'; +import { UserMetadataEntity } from 'src/entities/user-metadata.entity'; import { Column, CreateDateColumn, @@ -10,19 +11,6 @@ import { UpdateDateColumn, } from 'typeorm'; -export enum UserAvatarColor { - PRIMARY = 'primary', - PINK = 'pink', - RED = 'red', - YELLOW = 'yellow', - BLUE = 'blue', - GREEN = 'green', - PURPLE = 'purple', - ORANGE = 'orange', - GRAY = 'gray', - AMBER = 'amber', -} - export enum UserStatus { ACTIVE = 'active', REMOVING = 'removing', @@ -37,9 +25,6 @@ export class UserEntity { @Column({ default: '' }) name!: string; - @Column({ type: 'varchar', nullable: true }) - avatarColor!: UserAvatarColor | null; - @Column({ default: false }) isAdmin!: boolean; @@ -73,9 +58,6 @@ export class UserEntity { @UpdateDateColumn({ type: 'timestamptz' }) updatedAt!: Date; - @Column({ default: true }) - memoriesEnabled!: boolean; - @OneToMany(() => TagEntity, (tag) => tag.user) tags!: TagEntity[]; @@ -87,4 +69,7 @@ export class UserEntity { @Column({ type: 'bigint', default: 0 }) quotaUsageInBytes!: number; + + @OneToMany(() => UserMetadataEntity, (metadata) => metadata.user) + metadata!: UserMetadataEntity[]; } diff --git a/server/src/interfaces/user.interface.ts b/server/src/interfaces/user.interface.ts index ebc70688ea..87ed2ccb08 100644 --- a/server/src/interfaces/user.interface.ts +++ b/server/src/interfaces/user.interface.ts @@ -1,3 +1,4 @@ +import { UserMetadata } from 'src/entities/user-metadata.entity'; import { UserEntity } from 'src/entities/user.entity'; export interface UserListFilter { @@ -31,6 +32,7 @@ export interface IUserRepository { getUserStats(): Promise; create(user: Partial): Promise; update(id: string, user: Partial): Promise; + upsertMetadata(id: string, item: { key: T; value: UserMetadata[T] }): Promise; delete(user: UserEntity, hard?: boolean): Promise; updateUsage(id: string, delta: number): Promise; syncUsage(id?: string): Promise; diff --git a/server/src/migrations/1716312279245-UserMetadata.ts b/server/src/migrations/1716312279245-UserMetadata.ts new file mode 100644 index 0000000000..b118a8d42a --- /dev/null +++ b/server/src/migrations/1716312279245-UserMetadata.ts @@ -0,0 +1,60 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class UserMetadata1716312279245 implements MigrationInterface { + name = 'UserMetadata1716312279245'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "user_metadata" ("userId" uuid NOT NULL, "key" character varying NOT NULL, "value" jsonb NOT NULL, CONSTRAINT "PK_5931462150b3438cbc83277fe5a" PRIMARY KEY ("userId", "key"))`, + ); + const users = await queryRunner.query('SELECT "id", "memoriesEnabled", "avatarColor" FROM "users"'); + for (const { id, memoriesEnabled, avatarColor } of users) { + const preferences: any = {}; + if (!memoriesEnabled) { + preferences.memories = { enabled: false }; + } + + if (avatarColor) { + preferences.avatar = { color: avatarColor }; + } + + if (Object.keys(preferences).length === 0) { + continue; + } + + await queryRunner.query('INSERT INTO "user_metadata" ("userId", "key", "value") VALUES ($1, $2, $3)', [ + id, + 'preferences', + preferences, + ]); + } + await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "memoriesEnabled"`); + await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "avatarColor"`); + await queryRunner.query( + `ALTER TABLE "user_metadata" ADD CONSTRAINT "FK_6afb43681a21cf7815932bc38ac" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "user_metadata" DROP CONSTRAINT "FK_6afb43681a21cf7815932bc38ac"`); + await queryRunner.query(`ALTER TABLE "users" ADD "avatarColor" character varying`); + await queryRunner.query(`ALTER TABLE "users" ADD "memoriesEnabled" boolean NOT NULL DEFAULT true`); + const items = await queryRunner.query( + `SELECT "userId" as "id", "value" FROM "user_metadata" WHERE "key"='preferences'`, + ); + for (const { id, value } of items) { + if (!value) { + continue; + } + + if (value.avatar?.color) { + await queryRunner.query(`UPDATE "users" SET "avatarColor" = $1 WHERE "id" = $2`, [value.avatar.color, id]); + } + + if (value.memories?.enabled === false) { + await queryRunner.query(`UPDATE "users" SET "memoriesEnabled" = false WHERE "id" = $1`, [id]); + } + } + await queryRunner.query(`DROP TABLE "user_metadata"`); + } +} diff --git a/server/src/queries/activity.repository.sql b/server/src/queries/activity.repository.sql index c271e4bef2..3f3e04140c 100644 --- a/server/src/queries/activity.repository.sql +++ b/server/src/queries/activity.repository.sql @@ -12,7 +12,6 @@ SELECT "ActivityEntity"."isLiked" AS "ActivityEntity_isLiked", "ActivityEntity__ActivityEntity_user"."id" AS "ActivityEntity__ActivityEntity_user_id", "ActivityEntity__ActivityEntity_user"."name" AS "ActivityEntity__ActivityEntity_user_name", - "ActivityEntity__ActivityEntity_user"."avatarColor" AS "ActivityEntity__ActivityEntity_user_avatarColor", "ActivityEntity__ActivityEntity_user"."isAdmin" AS "ActivityEntity__ActivityEntity_user_isAdmin", "ActivityEntity__ActivityEntity_user"."email" AS "ActivityEntity__ActivityEntity_user_email", "ActivityEntity__ActivityEntity_user"."storageLabel" AS "ActivityEntity__ActivityEntity_user_storageLabel", @@ -23,7 +22,6 @@ SELECT "ActivityEntity__ActivityEntity_user"."deletedAt" AS "ActivityEntity__ActivityEntity_user_deletedAt", "ActivityEntity__ActivityEntity_user"."status" AS "ActivityEntity__ActivityEntity_user_status", "ActivityEntity__ActivityEntity_user"."updatedAt" AS "ActivityEntity__ActivityEntity_user_updatedAt", - "ActivityEntity__ActivityEntity_user"."memoriesEnabled" AS "ActivityEntity__ActivityEntity_user_memoriesEnabled", "ActivityEntity__ActivityEntity_user"."quotaSizeInBytes" AS "ActivityEntity__ActivityEntity_user_quotaSizeInBytes", "ActivityEntity__ActivityEntity_user"."quotaUsageInBytes" AS "ActivityEntity__ActivityEntity_user_quotaUsageInBytes" FROM diff --git a/server/src/queries/album.repository.sql b/server/src/queries/album.repository.sql index 2037e320a1..8ac7ed6731 100644 --- a/server/src/queries/album.repository.sql +++ b/server/src/queries/album.repository.sql @@ -18,7 +18,6 @@ FROM "AlbumEntity"."order" AS "AlbumEntity_order", "AlbumEntity__AlbumEntity_owner"."id" AS "AlbumEntity__AlbumEntity_owner_id", "AlbumEntity__AlbumEntity_owner"."name" AS "AlbumEntity__AlbumEntity_owner_name", - "AlbumEntity__AlbumEntity_owner"."avatarColor" AS "AlbumEntity__AlbumEntity_owner_avatarColor", "AlbumEntity__AlbumEntity_owner"."isAdmin" AS "AlbumEntity__AlbumEntity_owner_isAdmin", "AlbumEntity__AlbumEntity_owner"."email" AS "AlbumEntity__AlbumEntity_owner_email", "AlbumEntity__AlbumEntity_owner"."storageLabel" AS "AlbumEntity__AlbumEntity_owner_storageLabel", @@ -29,7 +28,6 @@ FROM "AlbumEntity__AlbumEntity_owner"."deletedAt" AS "AlbumEntity__AlbumEntity_owner_deletedAt", "AlbumEntity__AlbumEntity_owner"."status" AS "AlbumEntity__AlbumEntity_owner_status", "AlbumEntity__AlbumEntity_owner"."updatedAt" AS "AlbumEntity__AlbumEntity_owner_updatedAt", - "AlbumEntity__AlbumEntity_owner"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_owner_memoriesEnabled", "AlbumEntity__AlbumEntity_owner"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaSizeInBytes", "AlbumEntity__AlbumEntity_owner"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaUsageInBytes", "AlbumEntity__AlbumEntity_albumUsers"."albumsId" AS "AlbumEntity__AlbumEntity_albumUsers_albumsId", @@ -37,7 +35,6 @@ FROM "AlbumEntity__AlbumEntity_albumUsers"."role" AS "AlbumEntity__AlbumEntity_albumUsers_role", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."id" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_id", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."name" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_name", - "a641d58cf46d4a391ba060ac4dc337665c69ffea"."avatarColor" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_avatarColor", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."isAdmin" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_isAdmin", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."email" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_email", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."storageLabel" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_storageLabel", @@ -48,7 +45,6 @@ FROM "a641d58cf46d4a391ba060ac4dc337665c69ffea"."deletedAt" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_deletedAt", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."status" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_status", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."updatedAt" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_updatedAt", - "a641d58cf46d4a391ba060ac4dc337665c69ffea"."memoriesEnabled" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_memoriesEnabled", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."quotaSizeInBytes" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_quotaSizeInBytes", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."quotaUsageInBytes" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_quotaUsageInBytes", "AlbumEntity__AlbumEntity_sharedLinks"."id" AS "AlbumEntity__AlbumEntity_sharedLinks_id", @@ -98,7 +94,6 @@ SELECT "AlbumEntity"."order" AS "AlbumEntity_order", "AlbumEntity__AlbumEntity_owner"."id" AS "AlbumEntity__AlbumEntity_owner_id", "AlbumEntity__AlbumEntity_owner"."name" AS "AlbumEntity__AlbumEntity_owner_name", - "AlbumEntity__AlbumEntity_owner"."avatarColor" AS "AlbumEntity__AlbumEntity_owner_avatarColor", "AlbumEntity__AlbumEntity_owner"."isAdmin" AS "AlbumEntity__AlbumEntity_owner_isAdmin", "AlbumEntity__AlbumEntity_owner"."email" AS "AlbumEntity__AlbumEntity_owner_email", "AlbumEntity__AlbumEntity_owner"."storageLabel" AS "AlbumEntity__AlbumEntity_owner_storageLabel", @@ -109,7 +104,6 @@ SELECT "AlbumEntity__AlbumEntity_owner"."deletedAt" AS "AlbumEntity__AlbumEntity_owner_deletedAt", "AlbumEntity__AlbumEntity_owner"."status" AS "AlbumEntity__AlbumEntity_owner_status", "AlbumEntity__AlbumEntity_owner"."updatedAt" AS "AlbumEntity__AlbumEntity_owner_updatedAt", - "AlbumEntity__AlbumEntity_owner"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_owner_memoriesEnabled", "AlbumEntity__AlbumEntity_owner"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaSizeInBytes", "AlbumEntity__AlbumEntity_owner"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaUsageInBytes", "AlbumEntity__AlbumEntity_albumUsers"."albumsId" AS "AlbumEntity__AlbumEntity_albumUsers_albumsId", @@ -117,7 +111,6 @@ SELECT "AlbumEntity__AlbumEntity_albumUsers"."role" AS "AlbumEntity__AlbumEntity_albumUsers_role", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."id" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_id", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."name" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_name", - "a641d58cf46d4a391ba060ac4dc337665c69ffea"."avatarColor" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_avatarColor", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."isAdmin" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_isAdmin", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."email" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_email", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."storageLabel" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_storageLabel", @@ -128,7 +121,6 @@ SELECT "a641d58cf46d4a391ba060ac4dc337665c69ffea"."deletedAt" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_deletedAt", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."status" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_status", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."updatedAt" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_updatedAt", - "a641d58cf46d4a391ba060ac4dc337665c69ffea"."memoriesEnabled" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_memoriesEnabled", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."quotaSizeInBytes" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_quotaSizeInBytes", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."quotaUsageInBytes" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_quotaUsageInBytes" FROM @@ -160,7 +152,6 @@ SELECT "AlbumEntity"."order" AS "AlbumEntity_order", "AlbumEntity__AlbumEntity_owner"."id" AS "AlbumEntity__AlbumEntity_owner_id", "AlbumEntity__AlbumEntity_owner"."name" AS "AlbumEntity__AlbumEntity_owner_name", - "AlbumEntity__AlbumEntity_owner"."avatarColor" AS "AlbumEntity__AlbumEntity_owner_avatarColor", "AlbumEntity__AlbumEntity_owner"."isAdmin" AS "AlbumEntity__AlbumEntity_owner_isAdmin", "AlbumEntity__AlbumEntity_owner"."email" AS "AlbumEntity__AlbumEntity_owner_email", "AlbumEntity__AlbumEntity_owner"."storageLabel" AS "AlbumEntity__AlbumEntity_owner_storageLabel", @@ -171,7 +162,6 @@ SELECT "AlbumEntity__AlbumEntity_owner"."deletedAt" AS "AlbumEntity__AlbumEntity_owner_deletedAt", "AlbumEntity__AlbumEntity_owner"."status" AS "AlbumEntity__AlbumEntity_owner_status", "AlbumEntity__AlbumEntity_owner"."updatedAt" AS "AlbumEntity__AlbumEntity_owner_updatedAt", - "AlbumEntity__AlbumEntity_owner"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_owner_memoriesEnabled", "AlbumEntity__AlbumEntity_owner"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaSizeInBytes", "AlbumEntity__AlbumEntity_owner"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaUsageInBytes", "AlbumEntity__AlbumEntity_albumUsers"."albumsId" AS "AlbumEntity__AlbumEntity_albumUsers_albumsId", @@ -179,7 +169,6 @@ SELECT "AlbumEntity__AlbumEntity_albumUsers"."role" AS "AlbumEntity__AlbumEntity_albumUsers_role", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."id" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_id", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."name" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_name", - "a641d58cf46d4a391ba060ac4dc337665c69ffea"."avatarColor" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_avatarColor", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."isAdmin" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_isAdmin", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."email" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_email", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."storageLabel" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_storageLabel", @@ -190,7 +179,6 @@ SELECT "a641d58cf46d4a391ba060ac4dc337665c69ffea"."deletedAt" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_deletedAt", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."status" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_status", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."updatedAt" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_updatedAt", - "a641d58cf46d4a391ba060ac4dc337665c69ffea"."memoriesEnabled" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_memoriesEnabled", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."quotaSizeInBytes" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_quotaSizeInBytes", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."quotaUsageInBytes" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_quotaUsageInBytes" FROM @@ -299,7 +287,6 @@ SELECT "AlbumEntity__AlbumEntity_albumUsers"."role" AS "AlbumEntity__AlbumEntity_albumUsers_role", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."id" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_id", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."name" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_name", - "a641d58cf46d4a391ba060ac4dc337665c69ffea"."avatarColor" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_avatarColor", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."isAdmin" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_isAdmin", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."email" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_email", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."storageLabel" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_storageLabel", @@ -310,7 +297,6 @@ SELECT "a641d58cf46d4a391ba060ac4dc337665c69ffea"."deletedAt" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_deletedAt", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."status" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_status", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."updatedAt" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_updatedAt", - "a641d58cf46d4a391ba060ac4dc337665c69ffea"."memoriesEnabled" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_memoriesEnabled", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."quotaSizeInBytes" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_quotaSizeInBytes", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."quotaUsageInBytes" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_quotaUsageInBytes", "AlbumEntity__AlbumEntity_sharedLinks"."id" AS "AlbumEntity__AlbumEntity_sharedLinks_id", @@ -327,7 +313,6 @@ SELECT "AlbumEntity__AlbumEntity_sharedLinks"."albumId" AS "AlbumEntity__AlbumEntity_sharedLinks_albumId", "AlbumEntity__AlbumEntity_owner"."id" AS "AlbumEntity__AlbumEntity_owner_id", "AlbumEntity__AlbumEntity_owner"."name" AS "AlbumEntity__AlbumEntity_owner_name", - "AlbumEntity__AlbumEntity_owner"."avatarColor" AS "AlbumEntity__AlbumEntity_owner_avatarColor", "AlbumEntity__AlbumEntity_owner"."isAdmin" AS "AlbumEntity__AlbumEntity_owner_isAdmin", "AlbumEntity__AlbumEntity_owner"."email" AS "AlbumEntity__AlbumEntity_owner_email", "AlbumEntity__AlbumEntity_owner"."storageLabel" AS "AlbumEntity__AlbumEntity_owner_storageLabel", @@ -338,7 +323,6 @@ SELECT "AlbumEntity__AlbumEntity_owner"."deletedAt" AS "AlbumEntity__AlbumEntity_owner_deletedAt", "AlbumEntity__AlbumEntity_owner"."status" AS "AlbumEntity__AlbumEntity_owner_status", "AlbumEntity__AlbumEntity_owner"."updatedAt" AS "AlbumEntity__AlbumEntity_owner_updatedAt", - "AlbumEntity__AlbumEntity_owner"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_owner_memoriesEnabled", "AlbumEntity__AlbumEntity_owner"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaSizeInBytes", "AlbumEntity__AlbumEntity_owner"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaUsageInBytes" FROM @@ -376,7 +360,6 @@ SELECT "AlbumEntity__AlbumEntity_albumUsers"."role" AS "AlbumEntity__AlbumEntity_albumUsers_role", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."id" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_id", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."name" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_name", - "a641d58cf46d4a391ba060ac4dc337665c69ffea"."avatarColor" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_avatarColor", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."isAdmin" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_isAdmin", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."email" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_email", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."storageLabel" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_storageLabel", @@ -387,7 +370,6 @@ SELECT "a641d58cf46d4a391ba060ac4dc337665c69ffea"."deletedAt" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_deletedAt", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."status" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_status", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."updatedAt" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_updatedAt", - "a641d58cf46d4a391ba060ac4dc337665c69ffea"."memoriesEnabled" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_memoriesEnabled", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."quotaSizeInBytes" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_quotaSizeInBytes", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."quotaUsageInBytes" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_quotaUsageInBytes", "AlbumEntity__AlbumEntity_sharedLinks"."id" AS "AlbumEntity__AlbumEntity_sharedLinks_id", @@ -404,7 +386,6 @@ SELECT "AlbumEntity__AlbumEntity_sharedLinks"."albumId" AS "AlbumEntity__AlbumEntity_sharedLinks_albumId", "AlbumEntity__AlbumEntity_owner"."id" AS "AlbumEntity__AlbumEntity_owner_id", "AlbumEntity__AlbumEntity_owner"."name" AS "AlbumEntity__AlbumEntity_owner_name", - "AlbumEntity__AlbumEntity_owner"."avatarColor" AS "AlbumEntity__AlbumEntity_owner_avatarColor", "AlbumEntity__AlbumEntity_owner"."isAdmin" AS "AlbumEntity__AlbumEntity_owner_isAdmin", "AlbumEntity__AlbumEntity_owner"."email" AS "AlbumEntity__AlbumEntity_owner_email", "AlbumEntity__AlbumEntity_owner"."storageLabel" AS "AlbumEntity__AlbumEntity_owner_storageLabel", @@ -415,7 +396,6 @@ SELECT "AlbumEntity__AlbumEntity_owner"."deletedAt" AS "AlbumEntity__AlbumEntity_owner_deletedAt", "AlbumEntity__AlbumEntity_owner"."status" AS "AlbumEntity__AlbumEntity_owner_status", "AlbumEntity__AlbumEntity_owner"."updatedAt" AS "AlbumEntity__AlbumEntity_owner_updatedAt", - "AlbumEntity__AlbumEntity_owner"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_owner_memoriesEnabled", "AlbumEntity__AlbumEntity_owner"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaSizeInBytes", "AlbumEntity__AlbumEntity_owner"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaUsageInBytes" FROM @@ -504,7 +484,6 @@ SELECT "AlbumEntity__AlbumEntity_sharedLinks"."albumId" AS "AlbumEntity__AlbumEntity_sharedLinks_albumId", "AlbumEntity__AlbumEntity_owner"."id" AS "AlbumEntity__AlbumEntity_owner_id", "AlbumEntity__AlbumEntity_owner"."name" AS "AlbumEntity__AlbumEntity_owner_name", - "AlbumEntity__AlbumEntity_owner"."avatarColor" AS "AlbumEntity__AlbumEntity_owner_avatarColor", "AlbumEntity__AlbumEntity_owner"."isAdmin" AS "AlbumEntity__AlbumEntity_owner_isAdmin", "AlbumEntity__AlbumEntity_owner"."email" AS "AlbumEntity__AlbumEntity_owner_email", "AlbumEntity__AlbumEntity_owner"."storageLabel" AS "AlbumEntity__AlbumEntity_owner_storageLabel", @@ -515,7 +494,6 @@ SELECT "AlbumEntity__AlbumEntity_owner"."deletedAt" AS "AlbumEntity__AlbumEntity_owner_deletedAt", "AlbumEntity__AlbumEntity_owner"."status" AS "AlbumEntity__AlbumEntity_owner_status", "AlbumEntity__AlbumEntity_owner"."updatedAt" AS "AlbumEntity__AlbumEntity_owner_updatedAt", - "AlbumEntity__AlbumEntity_owner"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_owner_memoriesEnabled", "AlbumEntity__AlbumEntity_owner"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaSizeInBytes", "AlbumEntity__AlbumEntity_owner"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaUsageInBytes" FROM @@ -564,7 +542,6 @@ SELECT "AlbumEntity"."order" AS "AlbumEntity_order", "AlbumEntity__AlbumEntity_owner"."id" AS "AlbumEntity__AlbumEntity_owner_id", "AlbumEntity__AlbumEntity_owner"."name" AS "AlbumEntity__AlbumEntity_owner_name", - "AlbumEntity__AlbumEntity_owner"."avatarColor" AS "AlbumEntity__AlbumEntity_owner_avatarColor", "AlbumEntity__AlbumEntity_owner"."isAdmin" AS "AlbumEntity__AlbumEntity_owner_isAdmin", "AlbumEntity__AlbumEntity_owner"."email" AS "AlbumEntity__AlbumEntity_owner_email", "AlbumEntity__AlbumEntity_owner"."storageLabel" AS "AlbumEntity__AlbumEntity_owner_storageLabel", @@ -575,7 +552,6 @@ SELECT "AlbumEntity__AlbumEntity_owner"."deletedAt" AS "AlbumEntity__AlbumEntity_owner_deletedAt", "AlbumEntity__AlbumEntity_owner"."status" AS "AlbumEntity__AlbumEntity_owner_status", "AlbumEntity__AlbumEntity_owner"."updatedAt" AS "AlbumEntity__AlbumEntity_owner_updatedAt", - "AlbumEntity__AlbumEntity_owner"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_owner_memoriesEnabled", "AlbumEntity__AlbumEntity_owner"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaSizeInBytes", "AlbumEntity__AlbumEntity_owner"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaUsageInBytes" FROM diff --git a/server/src/queries/api.key.repository.sql b/server/src/queries/api.key.repository.sql index 22b8fd6722..fa0431fb0f 100644 --- a/server/src/queries/api.key.repository.sql +++ b/server/src/queries/api.key.repository.sql @@ -11,7 +11,6 @@ FROM "APIKeyEntity"."userId" AS "APIKeyEntity_userId", "APIKeyEntity__APIKeyEntity_user"."id" AS "APIKeyEntity__APIKeyEntity_user_id", "APIKeyEntity__APIKeyEntity_user"."name" AS "APIKeyEntity__APIKeyEntity_user_name", - "APIKeyEntity__APIKeyEntity_user"."avatarColor" AS "APIKeyEntity__APIKeyEntity_user_avatarColor", "APIKeyEntity__APIKeyEntity_user"."isAdmin" AS "APIKeyEntity__APIKeyEntity_user_isAdmin", "APIKeyEntity__APIKeyEntity_user"."email" AS "APIKeyEntity__APIKeyEntity_user_email", "APIKeyEntity__APIKeyEntity_user"."storageLabel" AS "APIKeyEntity__APIKeyEntity_user_storageLabel", @@ -22,7 +21,6 @@ FROM "APIKeyEntity__APIKeyEntity_user"."deletedAt" AS "APIKeyEntity__APIKeyEntity_user_deletedAt", "APIKeyEntity__APIKeyEntity_user"."status" AS "APIKeyEntity__APIKeyEntity_user_status", "APIKeyEntity__APIKeyEntity_user"."updatedAt" AS "APIKeyEntity__APIKeyEntity_user_updatedAt", - "APIKeyEntity__APIKeyEntity_user"."memoriesEnabled" AS "APIKeyEntity__APIKeyEntity_user_memoriesEnabled", "APIKeyEntity__APIKeyEntity_user"."quotaSizeInBytes" AS "APIKeyEntity__APIKeyEntity_user_quotaSizeInBytes", "APIKeyEntity__APIKeyEntity_user"."quotaUsageInBytes" AS "APIKeyEntity__APIKeyEntity_user_quotaUsageInBytes" FROM diff --git a/server/src/queries/library.repository.sql b/server/src/queries/library.repository.sql index 700a71048a..7f1e0eff93 100644 --- a/server/src/queries/library.repository.sql +++ b/server/src/queries/library.repository.sql @@ -17,7 +17,6 @@ FROM "LibraryEntity"."refreshedAt" AS "LibraryEntity_refreshedAt", "LibraryEntity__LibraryEntity_owner"."id" AS "LibraryEntity__LibraryEntity_owner_id", "LibraryEntity__LibraryEntity_owner"."name" AS "LibraryEntity__LibraryEntity_owner_name", - "LibraryEntity__LibraryEntity_owner"."avatarColor" AS "LibraryEntity__LibraryEntity_owner_avatarColor", "LibraryEntity__LibraryEntity_owner"."isAdmin" AS "LibraryEntity__LibraryEntity_owner_isAdmin", "LibraryEntity__LibraryEntity_owner"."email" AS "LibraryEntity__LibraryEntity_owner_email", "LibraryEntity__LibraryEntity_owner"."storageLabel" AS "LibraryEntity__LibraryEntity_owner_storageLabel", @@ -28,7 +27,6 @@ FROM "LibraryEntity__LibraryEntity_owner"."deletedAt" AS "LibraryEntity__LibraryEntity_owner_deletedAt", "LibraryEntity__LibraryEntity_owner"."status" AS "LibraryEntity__LibraryEntity_owner_status", "LibraryEntity__LibraryEntity_owner"."updatedAt" AS "LibraryEntity__LibraryEntity_owner_updatedAt", - "LibraryEntity__LibraryEntity_owner"."memoriesEnabled" AS "LibraryEntity__LibraryEntity_owner_memoriesEnabled", "LibraryEntity__LibraryEntity_owner"."quotaSizeInBytes" AS "LibraryEntity__LibraryEntity_owner_quotaSizeInBytes", "LibraryEntity__LibraryEntity_owner"."quotaUsageInBytes" AS "LibraryEntity__LibraryEntity_owner_quotaUsageInBytes" FROM @@ -89,7 +87,6 @@ SELECT "LibraryEntity"."refreshedAt" AS "LibraryEntity_refreshedAt", "LibraryEntity__LibraryEntity_owner"."id" AS "LibraryEntity__LibraryEntity_owner_id", "LibraryEntity__LibraryEntity_owner"."name" AS "LibraryEntity__LibraryEntity_owner_name", - "LibraryEntity__LibraryEntity_owner"."avatarColor" AS "LibraryEntity__LibraryEntity_owner_avatarColor", "LibraryEntity__LibraryEntity_owner"."isAdmin" AS "LibraryEntity__LibraryEntity_owner_isAdmin", "LibraryEntity__LibraryEntity_owner"."email" AS "LibraryEntity__LibraryEntity_owner_email", "LibraryEntity__LibraryEntity_owner"."storageLabel" AS "LibraryEntity__LibraryEntity_owner_storageLabel", @@ -100,7 +97,6 @@ SELECT "LibraryEntity__LibraryEntity_owner"."deletedAt" AS "LibraryEntity__LibraryEntity_owner_deletedAt", "LibraryEntity__LibraryEntity_owner"."status" AS "LibraryEntity__LibraryEntity_owner_status", "LibraryEntity__LibraryEntity_owner"."updatedAt" AS "LibraryEntity__LibraryEntity_owner_updatedAt", - "LibraryEntity__LibraryEntity_owner"."memoriesEnabled" AS "LibraryEntity__LibraryEntity_owner_memoriesEnabled", "LibraryEntity__LibraryEntity_owner"."quotaSizeInBytes" AS "LibraryEntity__LibraryEntity_owner_quotaSizeInBytes", "LibraryEntity__LibraryEntity_owner"."quotaUsageInBytes" AS "LibraryEntity__LibraryEntity_owner_quotaUsageInBytes" FROM @@ -128,7 +124,6 @@ SELECT "LibraryEntity"."refreshedAt" AS "LibraryEntity_refreshedAt", "LibraryEntity__LibraryEntity_owner"."id" AS "LibraryEntity__LibraryEntity_owner_id", "LibraryEntity__LibraryEntity_owner"."name" AS "LibraryEntity__LibraryEntity_owner_name", - "LibraryEntity__LibraryEntity_owner"."avatarColor" AS "LibraryEntity__LibraryEntity_owner_avatarColor", "LibraryEntity__LibraryEntity_owner"."isAdmin" AS "LibraryEntity__LibraryEntity_owner_isAdmin", "LibraryEntity__LibraryEntity_owner"."email" AS "LibraryEntity__LibraryEntity_owner_email", "LibraryEntity__LibraryEntity_owner"."storageLabel" AS "LibraryEntity__LibraryEntity_owner_storageLabel", @@ -139,7 +134,6 @@ SELECT "LibraryEntity__LibraryEntity_owner"."deletedAt" AS "LibraryEntity__LibraryEntity_owner_deletedAt", "LibraryEntity__LibraryEntity_owner"."status" AS "LibraryEntity__LibraryEntity_owner_status", "LibraryEntity__LibraryEntity_owner"."updatedAt" AS "LibraryEntity__LibraryEntity_owner_updatedAt", - "LibraryEntity__LibraryEntity_owner"."memoriesEnabled" AS "LibraryEntity__LibraryEntity_owner_memoriesEnabled", "LibraryEntity__LibraryEntity_owner"."quotaSizeInBytes" AS "LibraryEntity__LibraryEntity_owner_quotaSizeInBytes", "LibraryEntity__LibraryEntity_owner"."quotaUsageInBytes" AS "LibraryEntity__LibraryEntity_owner_quotaUsageInBytes" FROM @@ -166,7 +160,6 @@ SELECT "LibraryEntity"."refreshedAt" AS "LibraryEntity_refreshedAt", "LibraryEntity__LibraryEntity_owner"."id" AS "LibraryEntity__LibraryEntity_owner_id", "LibraryEntity__LibraryEntity_owner"."name" AS "LibraryEntity__LibraryEntity_owner_name", - "LibraryEntity__LibraryEntity_owner"."avatarColor" AS "LibraryEntity__LibraryEntity_owner_avatarColor", "LibraryEntity__LibraryEntity_owner"."isAdmin" AS "LibraryEntity__LibraryEntity_owner_isAdmin", "LibraryEntity__LibraryEntity_owner"."email" AS "LibraryEntity__LibraryEntity_owner_email", "LibraryEntity__LibraryEntity_owner"."storageLabel" AS "LibraryEntity__LibraryEntity_owner_storageLabel", @@ -177,7 +170,6 @@ SELECT "LibraryEntity__LibraryEntity_owner"."deletedAt" AS "LibraryEntity__LibraryEntity_owner_deletedAt", "LibraryEntity__LibraryEntity_owner"."status" AS "LibraryEntity__LibraryEntity_owner_status", "LibraryEntity__LibraryEntity_owner"."updatedAt" AS "LibraryEntity__LibraryEntity_owner_updatedAt", - "LibraryEntity__LibraryEntity_owner"."memoriesEnabled" AS "LibraryEntity__LibraryEntity_owner_memoriesEnabled", "LibraryEntity__LibraryEntity_owner"."quotaSizeInBytes" AS "LibraryEntity__LibraryEntity_owner_quotaSizeInBytes", "LibraryEntity__LibraryEntity_owner"."quotaUsageInBytes" AS "LibraryEntity__LibraryEntity_owner_quotaUsageInBytes" FROM diff --git a/server/src/queries/session.repository.sql b/server/src/queries/session.repository.sql index 87c1b55c9b..b26b291e8b 100644 --- a/server/src/queries/session.repository.sql +++ b/server/src/queries/session.repository.sql @@ -27,7 +27,6 @@ FROM "SessionEntity"."deviceOS" AS "SessionEntity_deviceOS", "SessionEntity__SessionEntity_user"."id" AS "SessionEntity__SessionEntity_user_id", "SessionEntity__SessionEntity_user"."name" AS "SessionEntity__SessionEntity_user_name", - "SessionEntity__SessionEntity_user"."avatarColor" AS "SessionEntity__SessionEntity_user_avatarColor", "SessionEntity__SessionEntity_user"."isAdmin" AS "SessionEntity__SessionEntity_user_isAdmin", "SessionEntity__SessionEntity_user"."email" AS "SessionEntity__SessionEntity_user_email", "SessionEntity__SessionEntity_user"."storageLabel" AS "SessionEntity__SessionEntity_user_storageLabel", @@ -38,7 +37,6 @@ FROM "SessionEntity__SessionEntity_user"."deletedAt" AS "SessionEntity__SessionEntity_user_deletedAt", "SessionEntity__SessionEntity_user"."status" AS "SessionEntity__SessionEntity_user_status", "SessionEntity__SessionEntity_user"."updatedAt" AS "SessionEntity__SessionEntity_user_updatedAt", - "SessionEntity__SessionEntity_user"."memoriesEnabled" AS "SessionEntity__SessionEntity_user_memoriesEnabled", "SessionEntity__SessionEntity_user"."quotaSizeInBytes" AS "SessionEntity__SessionEntity_user_quotaSizeInBytes", "SessionEntity__SessionEntity_user"."quotaUsageInBytes" AS "SessionEntity__SessionEntity_user_quotaUsageInBytes" FROM diff --git a/server/src/queries/shared.link.repository.sql b/server/src/queries/shared.link.repository.sql index 6ae80b3e6a..09f0cf7cb5 100644 --- a/server/src/queries/shared.link.repository.sql +++ b/server/src/queries/shared.link.repository.sql @@ -147,7 +147,6 @@ FROM "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f"."fps" AS "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f_fps", "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."id" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_id", "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."name" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_name", - "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."avatarColor" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_avatarColor", "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."isAdmin" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_isAdmin", "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."email" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_email", "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."storageLabel" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_storageLabel", @@ -158,7 +157,6 @@ FROM "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."deletedAt" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_deletedAt", "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."status" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_status", "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."updatedAt" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_updatedAt", - "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."memoriesEnabled" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_memoriesEnabled", "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."quotaSizeInBytes" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_quotaSizeInBytes", "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."quotaUsageInBytes" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_quotaUsageInBytes" FROM @@ -252,7 +250,6 @@ SELECT "SharedLinkEntity__SharedLinkEntity_album"."order" AS "SharedLinkEntity__SharedLinkEntity_album_order", "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."id" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_id", "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."name" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_name", - "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."avatarColor" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_avatarColor", "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."isAdmin" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_isAdmin", "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."email" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_email", "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."storageLabel" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_storageLabel", @@ -263,7 +260,6 @@ SELECT "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."deletedAt" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_deletedAt", "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."status" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_status", "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."updatedAt" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_updatedAt", - "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."memoriesEnabled" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_memoriesEnabled", "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."quotaSizeInBytes" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_quotaSizeInBytes", "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."quotaUsageInBytes" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_quotaUsageInBytes" FROM @@ -306,7 +302,6 @@ FROM "SharedLinkEntity"."albumId" AS "SharedLinkEntity_albumId", "SharedLinkEntity__SharedLinkEntity_user"."id" AS "SharedLinkEntity__SharedLinkEntity_user_id", "SharedLinkEntity__SharedLinkEntity_user"."name" AS "SharedLinkEntity__SharedLinkEntity_user_name", - "SharedLinkEntity__SharedLinkEntity_user"."avatarColor" AS "SharedLinkEntity__SharedLinkEntity_user_avatarColor", "SharedLinkEntity__SharedLinkEntity_user"."isAdmin" AS "SharedLinkEntity__SharedLinkEntity_user_isAdmin", "SharedLinkEntity__SharedLinkEntity_user"."email" AS "SharedLinkEntity__SharedLinkEntity_user_email", "SharedLinkEntity__SharedLinkEntity_user"."storageLabel" AS "SharedLinkEntity__SharedLinkEntity_user_storageLabel", @@ -317,7 +312,6 @@ FROM "SharedLinkEntity__SharedLinkEntity_user"."deletedAt" AS "SharedLinkEntity__SharedLinkEntity_user_deletedAt", "SharedLinkEntity__SharedLinkEntity_user"."status" AS "SharedLinkEntity__SharedLinkEntity_user_status", "SharedLinkEntity__SharedLinkEntity_user"."updatedAt" AS "SharedLinkEntity__SharedLinkEntity_user_updatedAt", - "SharedLinkEntity__SharedLinkEntity_user"."memoriesEnabled" AS "SharedLinkEntity__SharedLinkEntity_user_memoriesEnabled", "SharedLinkEntity__SharedLinkEntity_user"."quotaSizeInBytes" AS "SharedLinkEntity__SharedLinkEntity_user_quotaSizeInBytes", "SharedLinkEntity__SharedLinkEntity_user"."quotaUsageInBytes" AS "SharedLinkEntity__SharedLinkEntity_user_quotaUsageInBytes" FROM diff --git a/server/src/queries/user.repository.sql b/server/src/queries/user.repository.sql index d27b83fdd7..64d4e12643 100644 --- a/server/src/queries/user.repository.sql +++ b/server/src/queries/user.repository.sql @@ -4,7 +4,6 @@ SELECT "UserEntity"."id" AS "UserEntity_id", "UserEntity"."name" AS "UserEntity_name", - "UserEntity"."avatarColor" AS "UserEntity_avatarColor", "UserEntity"."isAdmin" AS "UserEntity_isAdmin", "UserEntity"."email" AS "UserEntity_email", "UserEntity"."storageLabel" AS "UserEntity_storageLabel", @@ -15,7 +14,6 @@ SELECT "UserEntity"."deletedAt" AS "UserEntity_deletedAt", "UserEntity"."status" AS "UserEntity_status", "UserEntity"."updatedAt" AS "UserEntity_updatedAt", - "UserEntity"."memoriesEnabled" AS "UserEntity_memoriesEnabled", "UserEntity"."quotaSizeInBytes" AS "UserEntity_quotaSizeInBytes", "UserEntity"."quotaUsageInBytes" AS "UserEntity_quotaUsageInBytes" FROM @@ -51,7 +49,6 @@ LIMIT SELECT "user"."id" AS "user_id", "user"."name" AS "user_name", - "user"."avatarColor" AS "user_avatarColor", "user"."isAdmin" AS "user_isAdmin", "user"."email" AS "user_email", "user"."storageLabel" AS "user_storageLabel", @@ -62,7 +59,6 @@ SELECT "user"."deletedAt" AS "user_deletedAt", "user"."status" AS "user_status", "user"."updatedAt" AS "user_updatedAt", - "user"."memoriesEnabled" AS "user_memoriesEnabled", "user"."quotaSizeInBytes" AS "user_quotaSizeInBytes", "user"."quotaUsageInBytes" AS "user_quotaUsageInBytes" FROM @@ -75,7 +71,6 @@ WHERE SELECT "UserEntity"."id" AS "UserEntity_id", "UserEntity"."name" AS "UserEntity_name", - "UserEntity"."avatarColor" AS "UserEntity_avatarColor", "UserEntity"."isAdmin" AS "UserEntity_isAdmin", "UserEntity"."email" AS "UserEntity_email", "UserEntity"."storageLabel" AS "UserEntity_storageLabel", @@ -86,7 +81,6 @@ SELECT "UserEntity"."deletedAt" AS "UserEntity_deletedAt", "UserEntity"."status" AS "UserEntity_status", "UserEntity"."updatedAt" AS "UserEntity_updatedAt", - "UserEntity"."memoriesEnabled" AS "UserEntity_memoriesEnabled", "UserEntity"."quotaSizeInBytes" AS "UserEntity_quotaSizeInBytes", "UserEntity"."quotaUsageInBytes" AS "UserEntity_quotaUsageInBytes" FROM @@ -101,7 +95,6 @@ LIMIT SELECT "UserEntity"."id" AS "UserEntity_id", "UserEntity"."name" AS "UserEntity_name", - "UserEntity"."avatarColor" AS "UserEntity_avatarColor", "UserEntity"."isAdmin" AS "UserEntity_isAdmin", "UserEntity"."email" AS "UserEntity_email", "UserEntity"."storageLabel" AS "UserEntity_storageLabel", @@ -112,7 +105,6 @@ SELECT "UserEntity"."deletedAt" AS "UserEntity_deletedAt", "UserEntity"."status" AS "UserEntity_status", "UserEntity"."updatedAt" AS "UserEntity_updatedAt", - "UserEntity"."memoriesEnabled" AS "UserEntity_memoriesEnabled", "UserEntity"."quotaSizeInBytes" AS "UserEntity_quotaSizeInBytes", "UserEntity"."quotaUsageInBytes" AS "UserEntity_quotaUsageInBytes" FROM diff --git a/server/src/repositories/user.repository.ts b/server/src/repositories/user.repository.ts index 6829bb024a..7b7861a8de 100644 --- a/server/src/repositories/user.repository.ts +++ b/server/src/repositories/user.repository.ts @@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { DummyValue, GenerateSql } from 'src/decorators'; import { AssetEntity } from 'src/entities/asset.entity'; +import { UserMetadata, UserMetadataEntity, UserMetadataKey } from 'src/entities/user-metadata.entity'; import { UserEntity } from 'src/entities/user.entity'; import { IUserRepository, @@ -18,6 +19,7 @@ export class UserRepository implements IUserRepository { constructor( @InjectRepository(AssetEntity) private assetRepository: Repository, @InjectRepository(UserEntity) private userRepository: Repository, + @InjectRepository(UserMetadataEntity) private metadataRepository: Repository, ) {} async get(userId: string, options: UserFindOptions): Promise { @@ -25,6 +27,9 @@ export class UserRepository implements IUserRepository { return this.userRepository.findOne({ where: { id: userId }, withDeleted: options.withDeleted, + relations: { + metadata: true, + }, }); } @@ -69,6 +74,9 @@ export class UserRepository implements IUserRepository { order: { createdAt: 'DESC', }, + relations: { + metadata: true, + }, }); } @@ -81,6 +89,13 @@ export class UserRepository implements IUserRepository { return this.save({ ...user, id }); } + async upsertMetadata( + id: string, + { key, value }: { key: T; value: UserMetadata[T] }, + ) { + await this.metadataRepository.upsert({ userId: id, key, value }, { conflictPaths: { userId: true, key: true } }); + } + async delete(user: UserEntity, hard?: boolean): Promise { return hard ? this.userRepository.remove(user) : this.userRepository.softRemove(user); } @@ -142,6 +157,12 @@ export class UserRepository implements IUserRepository { private async save(user: Partial) { const { id } = await this.userRepository.save(user); - return this.userRepository.findOneOrFail({ where: { id }, withDeleted: true }); + return this.userRepository.findOneOrFail({ + where: { id }, + withDeleted: true, + relations: { + metadata: true, + }, + }); } } diff --git a/server/src/services/auth.service.spec.ts b/server/src/services/auth.service.spec.ts index b93599b38a..34b8d9a550 100644 --- a/server/src/services/auth.service.spec.ts +++ b/server/src/services/auth.service.spec.ts @@ -4,6 +4,7 @@ import { Issuer, generators } from 'openid-client'; import { Socket } from 'socket.io'; import { AuthType } from 'src/constants'; import { AuthDto, SignUpDto } from 'src/dtos/auth.dto'; +import { UserMetadataEntity } from 'src/entities/user-metadata.entity'; import { UserEntity } from 'src/entities/user.entity'; import { IKeyRepository } from 'src/interfaces/api-key.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; @@ -248,8 +249,13 @@ describe('AuthService', () => { it('should sign up the admin', async () => { userMock.getAdmin.mockResolvedValue(null); - userMock.create.mockResolvedValue({ ...dto, id: 'admin', createdAt: new Date('2021-01-01') } as UserEntity); - await expect(sut.adminSignUp(dto)).resolves.toEqual({ + userMock.create.mockResolvedValue({ + ...dto, + id: 'admin', + createdAt: new Date('2021-01-01'), + metadata: [] as UserMetadataEntity[], + } as UserEntity); + await expect(sut.adminSignUp(dto)).resolves.toMatchObject({ avatarColor: expect.any(String), id: 'admin', createdAt: new Date('2021-01-01'), diff --git a/server/src/services/partner.service.spec.ts b/server/src/services/partner.service.spec.ts index 70c73b0d94..8fe93e7961 100644 --- a/server/src/services/partner.service.spec.ts +++ b/server/src/services/partner.service.spec.ts @@ -1,6 +1,6 @@ import { BadRequestException } from '@nestjs/common'; import { PartnerResponseDto } from 'src/dtos/partner.dto'; -import { UserAvatarColor } from 'src/entities/user.entity'; +import { UserAvatarColor } from 'src/entities/user-metadata.entity'; import { IAccessRepository } from 'src/interfaces/access.interface'; import { IPartnerRepository, PartnerDirection } from 'src/interfaces/partner.interface'; import { PartnerService } from 'src/services/partner.service'; @@ -23,7 +23,7 @@ const responseDto = { deletedAt: null, updatedAt: new Date('2021-01-01'), memoriesEnabled: true, - avatarColor: UserAvatarColor.PRIMARY, + avatarColor: UserAvatarColor.GRAY, quotaSizeInBytes: null, inTimeline: true, quotaUsageInBytes: 0, diff --git a/server/src/services/user.service.spec.ts b/server/src/services/user.service.spec.ts index da9c375131..e96cb0e663 100644 --- a/server/src/services/user.service.spec.ts +++ b/server/src/services/user.service.spec.ts @@ -138,13 +138,17 @@ describe(UserService.name, () => { expect(userMock.update).toHaveBeenCalledWith(userStub.user1.id, { id: userStub.user1.id, storageLabel: null, + updatedAt: expect.any(Date), }); }); it('should omit a storage label set by non-admin users', async () => { userMock.update.mockResolvedValue(userStub.user1); await sut.update({ user: userStub.user1 }, { id: userStub.user1.id, storageLabel: 'admin' }); - expect(userMock.update).toHaveBeenCalledWith(userStub.user1.id, { id: userStub.user1.id }); + expect(userMock.update).toHaveBeenCalledWith(userStub.user1.id, { + id: userStub.user1.id, + updatedAt: expect.any(Date), + }); }); it('user can only update its information', async () => { @@ -174,6 +178,7 @@ describe(UserService.name, () => { expect(userMock.update).toHaveBeenCalledWith(userStub.user1.id, { id: 'user-id', email: 'updated@test.com', + updatedAt: expect.any(Date), }); }); @@ -210,6 +215,7 @@ describe(UserService.name, () => { expect(userMock.update).toHaveBeenCalledWith(userStub.user1.id, { id: 'user-id', shouldChangePassword: true, + updatedAt: expect.any(Date), }); }); @@ -231,7 +237,7 @@ describe(UserService.name, () => { await sut.update(authStub.admin, dto); - expect(userMock.update).toHaveBeenCalledWith(userStub.admin.id, dto); + expect(userMock.update).toHaveBeenCalledWith(userStub.admin.id, { ...dto, updatedAt: expect.any(Date) }); }); it('should not let the another user become an admin', async () => { diff --git a/server/src/services/user.service.ts b/server/src/services/user.service.ts index 0df2ecae94..d546705d3c 100644 --- a/server/src/services/user.service.ts +++ b/server/src/services/user.service.ts @@ -6,6 +6,7 @@ import { UserCore } from 'src/cores/user.core'; import { AuthDto } from 'src/dtos/auth.dto'; import { CreateProfileImageResponseDto, mapCreateProfileImageResponse } from 'src/dtos/user-profile.dto'; import { CreateUserDto, DeleteUserDto, UpdateUserDto, UserResponseDto, mapUser } from 'src/dtos/user.dto'; +import { UserMetadataKey } from 'src/entities/user-metadata.entity'; import { UserEntity, UserStatus } from 'src/entities/user.entity'; import { IAlbumRepository } from 'src/interfaces/album.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; @@ -16,6 +17,7 @@ import { IStorageRepository } from 'src/interfaces/storage.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { IUserRepository, UserFindOptions } from 'src/interfaces/user.interface'; import { CacheControl, ImmichFileResponse } from 'src/utils/file'; +import { getPreferences, getPreferencesPartial } from 'src/utils/preferences'; @Injectable() export class UserService { @@ -61,9 +63,21 @@ export class UserService { } async create(dto: CreateUserDto): Promise { - const user = await this.userCore.createUser(dto); - const tempPassword = user.shouldChangePassword ? dto.password : undefined; - if (dto.notify) { + const { memoriesEnabled, notify, ...rest } = dto; + let user = await this.userCore.createUser(rest); + + // TODO remove and replace with entire dto.preferences config + if (memoriesEnabled === false) { + await this.userRepository.upsertMetadata(user.id, { + key: UserMetadataKey.PREFERENCES, + value: { memories: { enabled: false } }, + }); + + user = await this.findOrFail(user.id, {}); + } + + const tempPassword = user.shouldChangePassword ? rest.password : undefined; + if (notify) { await this.jobRepository.queue({ name: JobName.NOTIFY_SIGNUP, data: { id: user.id, tempPassword } }); } return mapUser(user); @@ -76,7 +90,28 @@ export class UserService { await this.userRepository.syncUsage(dto.id); } - return this.userCore.updateUser(auth.user, dto.id, dto).then(mapUser); + // TODO replace with entire preferences object + if (dto.memoriesEnabled !== undefined || dto.avatarColor) { + const newPreferences = getPreferences(user); + if (dto.memoriesEnabled !== undefined) { + newPreferences.memories.enabled = dto.memoriesEnabled; + delete dto.memoriesEnabled; + } + + if (dto.avatarColor) { + newPreferences.avatar.color = dto.avatarColor; + delete dto.avatarColor; + } + + await this.userRepository.upsertMetadata(dto.id, { + key: UserMetadataKey.PREFERENCES, + value: getPreferencesPartial(user, newPreferences), + }); + } + + const updatedUser = await this.userCore.updateUser(auth.user, dto.id, dto); + + return mapUser(updatedUser); } async delete(auth: AuthDto, id: string, dto: DeleteUserDto): Promise { diff --git a/server/src/utils/preferences.ts b/server/src/utils/preferences.ts new file mode 100644 index 0000000000..ae10c24fc9 --- /dev/null +++ b/server/src/utils/preferences.ts @@ -0,0 +1,39 @@ +import _ from 'lodash'; +import { UserMetadataKey, UserPreferences, getDefaultPreferences } from 'src/entities/user-metadata.entity'; +import { UserEntity } from 'src/entities/user.entity'; +import { getKeysDeep } from 'src/utils/misc'; +import { DeepPartial } from 'typeorm'; + +export const getPreferences = (user: UserEntity) => { + const preferences = getDefaultPreferences(user); + if (!user.metadata) { + return preferences; + } + + const item = user.metadata.find(({ key }) => key === UserMetadataKey.PREFERENCES); + const partial = item?.value || {}; + for (const property of getKeysDeep(partial)) { + _.set(preferences, property, _.get(partial, property)); + } + + return preferences; +}; + +export const getPreferencesPartial = (user: { email: string }, newPreferences: UserPreferences) => { + const defaultPreferences = getDefaultPreferences(user); + const partial: DeepPartial = {}; + for (const property of getKeysDeep(defaultPreferences)) { + const newValue = _.get(newPreferences, property); + const isEmpty = newValue === undefined || newValue === null || newValue === ''; + const defaultValue = _.get(defaultPreferences, property); + const isEqual = newValue === defaultValue || _.isEqual(newValue, defaultValue); + + if (isEmpty || isEqual) { + continue; + } + + _.set(partial, property, newValue); + } + + return partial; +}; diff --git a/server/test/fixtures/user.stub.ts b/server/test/fixtures/user.stub.ts index 5cf5acfc3b..cb82dfe26c 100644 --- a/server/test/fixtures/user.stub.ts +++ b/server/test/fixtures/user.stub.ts @@ -1,4 +1,5 @@ -import { UserAvatarColor, UserEntity } from 'src/entities/user.entity'; +import { UserAvatarColor, UserMetadataKey } from 'src/entities/user-metadata.entity'; +import { UserEntity } from 'src/entities/user.entity'; import { authStub } from 'test/fixtures/auth.stub'; export const userDto = { @@ -39,8 +40,7 @@ export const userStub = { updatedAt: new Date('2021-01-01'), tags: [], assets: [], - memoriesEnabled: true, - avatarColor: UserAvatarColor.PRIMARY, + metadata: [], quotaSizeInBytes: null, quotaUsageInBytes: 0, }), @@ -57,8 +57,14 @@ export const userStub = { updatedAt: new Date('2021-01-01'), tags: [], assets: [], - memoriesEnabled: true, - avatarColor: UserAvatarColor.PRIMARY, + metadata: [ + { + user: authStub.user1.user, + userId: authStub.user1.user.id, + key: UserMetadataKey.PREFERENCES, + value: { avatar: { color: UserAvatarColor.PRIMARY } }, + }, + ], quotaSizeInBytes: null, quotaUsageInBytes: 0, }), @@ -75,8 +81,6 @@ export const userStub = { updatedAt: new Date('2021-01-01'), tags: [], assets: [], - memoriesEnabled: true, - avatarColor: UserAvatarColor.PRIMARY, quotaSizeInBytes: null, quotaUsageInBytes: 0, }), @@ -93,8 +97,6 @@ export const userStub = { updatedAt: new Date('2021-01-01'), tags: [], assets: [], - memoriesEnabled: true, - avatarColor: UserAvatarColor.PRIMARY, quotaSizeInBytes: null, quotaUsageInBytes: 0, }), @@ -111,8 +113,6 @@ export const userStub = { updatedAt: new Date('2021-01-01'), tags: [], assets: [], - memoriesEnabled: true, - avatarColor: UserAvatarColor.PRIMARY, quotaSizeInBytes: null, quotaUsageInBytes: 0, }), @@ -129,8 +129,6 @@ export const userStub = { updatedAt: new Date('2021-01-01'), tags: [], assets: [], - memoriesEnabled: true, - avatarColor: UserAvatarColor.PRIMARY, quotaSizeInBytes: null, quotaUsageInBytes: 0, }), diff --git a/server/test/repositories/user.repository.mock.ts b/server/test/repositories/user.repository.mock.ts index 5f2e2f083e..c3b9dc0f3d 100644 --- a/server/test/repositories/user.repository.mock.ts +++ b/server/test/repositories/user.repository.mock.ts @@ -22,5 +22,6 @@ export const newUserRepositoryMock = (reset = true): Mocked => hasAdmin: vitest.fn(), updateUsage: vitest.fn(), syncUsage: vitest.fn(), + upsertMetadata: vitest.fn(), }; }; From ae217814426119f0089185497f9a3a5bafccafb7 Mon Sep 17 00:00:00 2001 From: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Date: Wed, 22 May 2024 14:14:53 +0200 Subject: [PATCH 126/163] fix(web): albums dark mode contrast + a11y issue (#9662) --- .../album-page/album-card-group.svelte | 12 +++--- .../components/album-page/album-card.svelte | 2 +- .../components/album-page/albums-table.svelte | 38 +++++++++++-------- 3 files changed, 30 insertions(+), 22 deletions(-) diff --git a/web/src/lib/components/album-page/album-card-group.svelte b/web/src/lib/components/album-page/album-card-group.svelte index 4c303caa68..99347b3a02 100644 --- a/web/src/lib/components/album-page/album-card-group.svelte +++ b/web/src/lib/components/album-page/album-card-group.svelte @@ -29,17 +29,19 @@ {#if group}
- - -

toggleAlbumGroupCollapsing(group.id)} class="w-fit mt-2 pt-2 pr-2 mb-2 hover:cursor-pointer"> +


{/if} diff --git a/web/src/lib/components/album-page/album-card.svelte b/web/src/lib/components/album-page/album-card.svelte index fd0f231316..6bd5f85485 100644 --- a/web/src/lib/components/album-page/album-card.svelte +++ b/web/src/lib/components/album-page/album-card.svelte @@ -61,7 +61,7 @@

{/if} - + {#if showItemCount}

{album.assetCount.toLocaleString($locale)} diff --git a/web/src/lib/components/album-page/albums-table.svelte b/web/src/lib/components/album-page/albums-table.svelte index 09d924ca2c..1af4d7629d 100644 --- a/web/src/lib/components/album-page/albums-table.svelte +++ b/web/src/lib/components/album-page/albums-table.svelte @@ -40,24 +40,30 @@ {#each groupedAlbums as albumGroup (albumGroup.id)} {@const isCollapsed = isAlbumGroupCollapsed($albumViewSettings, albumGroup.id)} {@const iconRotation = isCollapsed ? 'rotate-0' : 'rotate-90'} - - - toggleAlbumGroupCollapsing(albumGroup.id)} + class="flex w-full mt-4 rounded-md" + aria-expanded={!isCollapsed} > - - - - {albumGroup.name} - ({albumGroup.albums.length} {albumGroup.albums.length > 1 ? 'albums' : 'album'}) - - - + + + + + {albumGroup.name} + + ({albumGroup.albums.length} + {albumGroup.albums.length > 1 ? 'albums' : 'album'}) + + + + + {#if !isCollapsed} Date: Wed, 22 May 2024 09:33:37 -0400 Subject: [PATCH 127/163] feat(web): s (#9663) --- web/src/lib/components/album-page/album-card.svelte | 3 ++- .../components/faces-page/unmerge-face-selector.svelte | 5 +++-- .../lib/components/forms/library-import-paths-form.svelte | 8 ++------ .../photos-page/actions/remove-from-album.svelte | 3 ++- .../lib/components/photos-page/delete-asset-dialog.svelte | 3 ++- .../lib/components/shared-components/upload-panel.svelte | 5 +++-- web/src/lib/utils.ts | 4 +++- web/src/lib/utils/asset-utils.ts | 8 ++++---- .../[[photos=photos]]/[[assetId=id]]/+page.svelte | 4 ++-- .../[[photos=photos]]/[[assetId=id]]/+page.svelte | 4 ++-- 10 files changed, 25 insertions(+), 22 deletions(-) diff --git a/web/src/lib/components/album-page/album-card.svelte b/web/src/lib/components/album-page/album-card.svelte index 6bd5f85485..6d64783e38 100644 --- a/web/src/lib/components/album-page/album-card.svelte +++ b/web/src/lib/components/album-page/album-card.svelte @@ -7,6 +7,7 @@ import { getShortDateRange } from '$lib/utils/date-time'; import AlbumCover from '$lib/components/album-page/album-cover.svelte'; import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte'; + import { s } from '$lib/utils'; export let album: AlbumResponseDto; export let showOwner = false; @@ -65,7 +66,7 @@ {#if showItemCount}

{album.assetCount.toLocaleString($locale)} - {album.assetCount === 1 ? `item` : `items`} + item{s(album.assetCount)}

{/if} 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 2343df68c9..1e480f85db 100644 --- a/web/src/lib/components/faces-page/unmerge-face-selector.svelte +++ b/web/src/lib/components/faces-page/unmerge-face-selector.svelte @@ -19,6 +19,7 @@ import { NotificationType, notificationController } from '../shared-components/notification/notification'; import FaceThumbnail from './face-thumbnail.svelte'; import PeopleList from './people-list.svelte'; + import { s } from '$lib/utils'; export let assetIds: string[]; export let personAssets: PersonResponseDto; @@ -76,7 +77,7 @@ await reassignFaces({ id: data.id, assetFaceUpdateDto: { data: selectedPeople } }); notificationController.show({ - message: `Re-assigned ${assetIds.length} asset${assetIds.length > 1 ? 's' : ''} to a new person`, + message: `Re-assigned ${assetIds.length} asset${s(assetIds.length)} to a new person`, type: NotificationType.Info, }); } catch (error) { @@ -96,7 +97,7 @@ if (selectedPerson) { await reassignFaces({ id: selectedPerson.id, assetFaceUpdateDto: { data: selectedPeople } }); notificationController.show({ - message: `Re-assigned ${assetIds.length} asset${assetIds.length > 1 ? 's' : ''} to ${ + message: `Re-assigned ${assetIds.length} asset${s(assetIds.length)} to ${ selectedPerson.name || 'an existing person' }`, type: NotificationType.Info, 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 dca565ce31..be832a63a3 100644 --- a/web/src/lib/components/forms/library-import-paths-form.svelte +++ b/web/src/lib/components/forms/library-import-paths-form.svelte @@ -9,6 +9,7 @@ import type { ValidateLibraryImportPathResponseDto } from '@immich/sdk'; import { NotificationType, notificationController } from '../shared-components/notification/notification'; import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte'; + import { s } from '$lib/utils'; export let library: LibraryResponseDto; @@ -56,14 +57,9 @@ type: NotificationType.Info, }); } - } else if (failedPaths === 1) { - notificationController.show({ - message: `${failedPaths} path failed validation`, - type: NotificationType.Warning, - }); } else { notificationController.show({ - message: `${failedPaths} paths failed validation`, + message: `${failedPaths} path${s(failedPaths)} failed validation`, type: NotificationType.Warning, }); } diff --git a/web/src/lib/components/photos-page/actions/remove-from-album.svelte b/web/src/lib/components/photos-page/actions/remove-from-album.svelte index c6db3e077a..89385327a5 100644 --- a/web/src/lib/components/photos-page/actions/remove-from-album.svelte +++ b/web/src/lib/components/photos-page/actions/remove-from-album.svelte @@ -9,6 +9,7 @@ import { mdiDeleteOutline, mdiImageRemoveOutline } from '@mdi/js'; import MenuOption from '../../shared-components/context-menu/menu-option.svelte'; import { getAssetControlContext } from '../asset-select-control-bar.svelte'; + import { s } from '$lib/utils'; export let album: AlbumResponseDto; export let onRemove: ((assetIds: string[]) => void) | undefined; @@ -33,7 +34,7 @@ const count = results.filter(({ success }) => success).length; notificationController.show({ type: NotificationType.Info, - message: `Removed ${count} asset${count === 1 ? '' : 's'}`, + message: `Removed ${count} asset${s(count)}`, }); clearSelect(); 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 6338ff875f..22a1855ab4 100644 --- a/web/src/lib/components/photos-page/delete-asset-dialog.svelte +++ b/web/src/lib/components/photos-page/delete-asset-dialog.svelte @@ -3,6 +3,7 @@ import ConfirmDialogue from '../shared-components/confirm-dialogue.svelte'; import { showDeleteModal } from '$lib/stores/preferences.store'; import Checkbox from '$lib/components/elements/checkbox.svelte'; + import { s } from '$lib/utils'; export let size: number; @@ -23,7 +24,7 @@ dispatch('cancel')} diff --git a/web/src/lib/components/shared-components/upload-panel.svelte b/web/src/lib/components/shared-components/upload-panel.svelte index bbcba662b3..7793fd5a1c 100644 --- a/web/src/lib/components/shared-components/upload-panel.svelte +++ b/web/src/lib/components/shared-components/upload-panel.svelte @@ -8,6 +8,7 @@ import { uploadExecutionQueue } from '$lib/utils/file-uploader'; import CircleIconButton from '../elements/buttons/circle-icon-button.svelte'; import { mdiCog, mdiWindowMinimize, mdiCancel, mdiCloudUploadOutline } from '@mdi/js'; + import { s } from '$lib/utils'; let showDetail = false; let showOptions = false; @@ -36,7 +37,7 @@ on:outroend={() => { if ($errorCounter > 0) { notificationController.show({ - message: `Upload completed with ${$errorCounter} error${$errorCounter > 1 ? 's' : ''}, refresh the page to see new upload assets.`, + message: `Upload completed with ${$errorCounter} error${s($errorCounter)}, refresh the page to see new upload assets.`, type: NotificationType.Warning, }); } else if ($successCounter > 0) { @@ -47,7 +48,7 @@ } if ($duplicateCounter > 0) { notificationController.show({ - message: `Skipped ${$duplicateCounter} duplicate asset${$duplicateCounter > 1 ? 's' : ''}`, + message: `Skipped ${$duplicateCounter} duplicate asset${s($duplicateCounter)}`, type: NotificationType.Warning, }); } diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts index 87cef15737..2baabd0a44 100644 --- a/web/src/lib/utils.ts +++ b/web/src/lib/utils.ts @@ -279,4 +279,6 @@ export const handlePromiseError = (promise: Promise): void => { promise.catch((error) => console.error(`[utils.ts]:handlePromiseError ${error}`, error)); }; -export const memoryLaneTitle = (yearsAgo: number) => `${yearsAgo} ${yearsAgo ? 'years' : 'year'} ago`; +export const s = (count: number) => (count === 1 ? '' : 's'); + +export const memoryLaneTitle = (yearsAgo: number) => `year${s(yearsAgo)} ago`; diff --git a/web/src/lib/utils/asset-utils.ts b/web/src/lib/utils/asset-utils.ts index 7a8bff68b2..dc29375ddd 100644 --- a/web/src/lib/utils/asset-utils.ts +++ b/web/src/lib/utils/asset-utils.ts @@ -5,7 +5,7 @@ import type { AssetInteractionStore } from '$lib/stores/asset-interaction.store' import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { BucketPosition, isSelectingAllAssets, type AssetStore } from '$lib/stores/assets.store'; import { downloadManager } from '$lib/stores/download'; -import { downloadRequest, getKey } from '$lib/utils'; +import { downloadRequest, getKey, s } from '$lib/utils'; import { createAlbum } from '$lib/utils/album-utils'; import { encodeHTMLSpecialChars } from '$lib/utils/string-utils'; import { @@ -38,7 +38,7 @@ export const addAssetsToAlbum = async (albumId: string, assetIds: string[]) => { timeout: 5000, message: count > 0 - ? `Added ${count} asset${count === 1 ? '' : 's'} to the album` + ? `Added ${count} asset${s(count)} to the album` : `Asset${assetIds.length === 1 ? ' was' : 's were'} already part of the album`, button: { text: 'View Album', @@ -58,7 +58,7 @@ export const addAssetsToNewAlbum = async (albumName: string, assetIds: string[]) notificationController.show({ type: NotificationType.Info, timeout: 5000, - message: `Added ${assetIds.length} asset${assetIds.length === 1 ? '' : 's'} to ${displayName}`, + message: `Added ${assetIds.length} asset${s(assetIds.length)} to ${displayName}`, html: true, button: { text: 'View Album', @@ -267,7 +267,7 @@ export const getSelectedAssets = (assets: Set, user: UserRespo const numberOfIssues = [...assets].filter((a) => user && a.ownerId !== user.id).length; if (numberOfIssues > 0) { notificationController.show({ - message: `Can't change metadata of ${numberOfIssues} asset${numberOfIssues > 1 ? 's' : ''}`, + message: `Can't change metadata of ${numberOfIssues} asset${s(numberOfIssues)}`, type: NotificationType.Warning, }); } 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 4c57a9b967..c4e859fc3d 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 @@ -42,7 +42,7 @@ import { locale } from '$lib/stores/preferences.store'; import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store'; import { user } from '$lib/stores/user.store'; - import { handlePromiseError } from '$lib/utils'; + import { handlePromiseError, s } from '$lib/utils'; import { downloadAlbum } from '$lib/utils/asset-utils'; import { clickOutside } from '$lib/utils/click-outside'; import { getContextMenuPosition } from '$lib/utils/context-menu'; @@ -291,7 +291,7 @@ const count = results.filter(({ success }) => success).length; notificationController.show({ type: NotificationType.Info, - message: `Added ${count} asset${count === 1 ? '' : 's'}`, + message: `Added ${count} asset${s(count)}`, }); await refreshAlbum(); 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 fcf31584f4..b87af4d37f 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 @@ -31,7 +31,7 @@ import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { AssetStore } from '$lib/stores/assets.store'; import { websocketEvents } from '$lib/stores/websocket'; - import { getPeopleThumbnailUrl, handlePromiseError } from '$lib/utils'; + import { getPeopleThumbnailUrl, handlePromiseError, s } from '$lib/utils'; import { clickOutside } from '$lib/utils/click-outside'; import { handleError } from '$lib/utils/handle-error'; import { isExternalUrl } from '$lib/utils/navigation'; @@ -482,7 +482,7 @@ {#if data.person.name}

{data.person.name}

- {`${numberOfAssets} asset${numberOfAssets > 1 ? 's' : ''}`} + {`${numberOfAssets} asset${s(numberOfAssets)}`}

{:else}

Add a name

From 6a4c2e97c0afafe802759d0f4367d5a42feeb296 Mon Sep 17 00:00:00 2001 From: CodaBool <61724833+CodaBool@users.noreply.github.com> Date: Wed, 22 May 2024 11:54:29 -0500 Subject: [PATCH 128/163] feat: add docker healthchecks to server and ml (#9583) * add healthcheck * format, import, IMMICH_PORT, and eslint change * chore: clean up nodejs healthcheck * fix ruff formating * add healthcheck * format, import, IMMICH_PORT, and eslint change * chore: clean up nodejs healthcheck * fix ruff formating * add healthcheck to dockerfile * poetry run ruff check --fix * removed 2 of 3 console calls --------- Co-authored-by: Jason Rasmussen --- machine-learning/Dockerfile | 2 ++ machine-learning/app/healthcheck.py | 14 ++++++++++++++ server/Dockerfile | 2 ++ server/package.json | 1 + server/src/utils/healthcheck.ts | 29 +++++++++++++++++++++++++++++ 5 files changed, 48 insertions(+) create mode 100644 machine-learning/app/healthcheck.py create mode 100644 server/src/utils/healthcheck.ts diff --git a/machine-learning/Dockerfile b/machine-learning/Dockerfile index afd793b033..21bb93af65 100644 --- a/machine-learning/Dockerfile +++ b/machine-learning/Dockerfile @@ -95,3 +95,5 @@ COPY start.sh log_conf.json ./ COPY app . ENTRYPOINT ["tini", "--"] CMD ["./start.sh"] + +HEALTHCHECK CMD python3 healthcheck.py diff --git a/machine-learning/app/healthcheck.py b/machine-learning/app/healthcheck.py new file mode 100644 index 0000000000..8a4ec897f0 --- /dev/null +++ b/machine-learning/app/healthcheck.py @@ -0,0 +1,14 @@ +import os +import sys + +import requests + +port = os.getenv("IMMICH_PORT", 3003) + +try: + response = requests.get(f"http://localhost:{port}/ping", timeout=2) + if response.status_code == 200: + sys.exit(0) + sys.exit(1) +except requests.RequestException: + sys.exit(1) diff --git a/server/Dockerfile b/server/Dockerfile index d9ad1665aa..ab5a730d37 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -62,3 +62,5 @@ VOLUME /usr/src/app/upload EXPOSE 3001 ENTRYPOINT ["tini", "--", "/bin/bash"] CMD ["start.sh"] + +HEALTHCHECK CMD npm run healthcheck diff --git a/server/package.json b/server/package.json index 9bf972a272..6265c6d928 100644 --- a/server/package.json +++ b/server/package.json @@ -18,6 +18,7 @@ "check": "tsc --noEmit", "check:code": "npm run format && npm run lint && npm run check", "check:all": "npm run check:code && npm run test:cov", + "healthcheck": "node ./dist/utils/healthcheck.js", "test": "vitest", "test:watch": "vitest --watch", "test:cov": "vitest --coverage", diff --git a/server/src/utils/healthcheck.ts b/server/src/utils/healthcheck.ts new file mode 100644 index 0000000000..df50636f45 --- /dev/null +++ b/server/src/utils/healthcheck.ts @@ -0,0 +1,29 @@ +#!/usr/bin/env node +const port = Number(process.env.IMMICH_PORT) || 3001; +const controller = new AbortController(); + +const main = async () => { + const timeout = setTimeout(() => controller.abort(), 2000); + try { + const response = await fetch(`http://localhost:${port}/api/server-info/ping`, { + signal: controller.signal, + }); + + if (response.ok) { + const body = await response.json(); + if (body.res === 'pong') { + process.exit(); + } + } + } catch (error) { + if (error instanceof DOMException === false) { + console.error(error); + } + } finally { + clearTimeout(timeout); + } + + process.exit(1); +}; + +void main(); From 202745f14b83a14c868a98ba14d30d63ff60aaf6 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Wed, 22 May 2024 13:24:57 -0400 Subject: [PATCH 129/163] refactor(server): plural endpoints (#9667) --- e2e/src/api/specs/activity.e2e-spec.ts | 66 ++++---- e2e/src/api/specs/album.e2e-spec.ts | 108 ++++++------- e2e/src/api/specs/asset.e2e-spec.ts | 3 +- e2e/src/api/specs/audit.e2e-spec.ts | 2 +- e2e/src/api/specs/library.e2e-spec.ts | 76 ++++----- e2e/src/api/specs/partner.e2e-spec.ts | 32 ++-- e2e/src/api/specs/person.e2e-spec.ts | 46 +++--- e2e/src/api/specs/shared-link.e2e-spec.ts | 76 ++++----- e2e/src/api/specs/user.e2e-spec.ts | 56 +++---- mobile/lib/utils/image_url_builder.dart | 2 +- mobile/openapi/README.md | 148 +++++++++--------- mobile/openapi/lib/api/activity_api.dart | 16 +- mobile/openapi/lib/api/album_api.dart | 44 +++--- mobile/openapi/lib/api/api_key_api.dart | 20 +-- mobile/openapi/lib/api/face_api.dart | 8 +- mobile/openapi/lib/api/file_report_api.dart | 12 +- mobile/openapi/lib/api/library_api.dart | 36 ++--- mobile/openapi/lib/api/partner_api.dart | 16 +- mobile/openapi/lib/api/person_api.dart | 40 ++--- mobile/openapi/lib/api/shared_link_api.dart | 32 ++-- mobile/openapi/lib/api/tag_api.dart | 32 ++-- mobile/openapi/lib/api/user_api.dart | 40 ++--- open-api/immich-openapi-specs.json | 90 +++++------ open-api/typescript-sdk/src/fetch-client.ts | 148 +++++++++--------- server/src/controllers/activity.controller.ts | 2 +- server/src/controllers/album.controller.ts | 2 +- server/src/controllers/api-key.controller.ts | 2 +- server/src/controllers/face.controller.ts | 2 +- .../src/controllers/file-report.controller.ts | 2 +- server/src/controllers/library.controller.ts | 2 +- server/src/controllers/partner.controller.ts | 2 +- server/src/controllers/person.controller.ts | 2 +- .../src/controllers/shared-link.controller.ts | 2 +- server/src/controllers/tag.controller.ts | 2 +- .../src/middleware/file-upload.interceptor.ts | 2 +- web/src/lib/utils.ts | 2 +- 36 files changed, 589 insertions(+), 584 deletions(-) diff --git a/e2e/src/api/specs/activity.e2e-spec.ts b/e2e/src/api/specs/activity.e2e-spec.ts index a1b717883d..3258f74d6e 100644 --- a/e2e/src/api/specs/activity.e2e-spec.ts +++ b/e2e/src/api/specs/activity.e2e-spec.ts @@ -14,7 +14,7 @@ import { app, asBearerAuth, utils } from 'src/utils'; import request from 'supertest'; import { beforeAll, beforeEach, describe, expect, it } from 'vitest'; -describe('/activity', () => { +describe('/activities', () => { let admin: LoginResponseDto; let nonOwner: LoginResponseDto; let asset: AssetFileUploadResponseDto; @@ -45,22 +45,24 @@ describe('/activity', () => { await utils.resetDatabase(['activity']); }); - describe('GET /activity', () => { + describe('GET /activities', () => { it('should require authentication', async () => { - const { status, body } = await request(app).get('/activity'); + const { status, body } = await request(app).get('/activities'); expect(status).toBe(401); expect(body).toEqual(errorDto.unauthorized); }); it('should require an albumId', async () => { - const { status, body } = await request(app).get('/activity').set('Authorization', `Bearer ${admin.accessToken}`); + const { status, body } = await request(app) + .get('/activities') + .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toEqual(400); expect(body).toEqual(errorDto.badRequest(expect.arrayContaining(['albumId must be a UUID']))); }); it('should reject an invalid albumId', async () => { const { status, body } = await request(app) - .get('/activity') + .get('/activities') .query({ albumId: uuidDto.invalid }) .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toEqual(400); @@ -69,7 +71,7 @@ describe('/activity', () => { it('should reject an invalid assetId', async () => { const { status, body } = await request(app) - .get('/activity') + .get('/activities') .query({ albumId: uuidDto.notFound, assetId: uuidDto.invalid }) .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toEqual(400); @@ -78,7 +80,7 @@ describe('/activity', () => { it('should start off empty', async () => { const { status, body } = await request(app) - .get('/activity') + .get('/activities') .query({ albumId: album.id }) .set('Authorization', `Bearer ${admin.accessToken}`); expect(body).toEqual([]); @@ -102,7 +104,7 @@ describe('/activity', () => { ]); const { status, body } = await request(app) - .get('/activity') + .get('/activities') .query({ albumId: album.id }) .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toEqual(200); @@ -121,7 +123,7 @@ describe('/activity', () => { ]); const { status, body } = await request(app) - .get('/activity') + .get('/activities') .query({ albumId: album.id, type: 'comment' }) .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toEqual(200); @@ -140,7 +142,7 @@ describe('/activity', () => { ]); const { status, body } = await request(app) - .get('/activity') + .get('/activities') .query({ albumId: album.id, type: 'like' }) .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toEqual(200); @@ -152,7 +154,7 @@ describe('/activity', () => { const reaction = await createActivity({ albumId: album.id, type: ReactionType.Like }); const response1 = await request(app) - .get('/activity') + .get('/activities') .query({ albumId: album.id, userId: uuidDto.notFound }) .set('Authorization', `Bearer ${admin.accessToken}`); @@ -160,7 +162,7 @@ describe('/activity', () => { expect(response1.body.length).toBe(0); const response2 = await request(app) - .get('/activity') + .get('/activities') .query({ albumId: album.id, userId: admin.userId }) .set('Authorization', `Bearer ${admin.accessToken}`); @@ -180,7 +182,7 @@ describe('/activity', () => { ]); const { status, body } = await request(app) - .get('/activity') + .get('/activities') .query({ albumId: album.id, assetId: asset.id }) .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toEqual(200); @@ -189,16 +191,16 @@ describe('/activity', () => { }); }); - describe('POST /activity', () => { + describe('POST /activities', () => { it('should require authentication', async () => { - const { status, body } = await request(app).post('/activity'); + const { status, body } = await request(app).post('/activities'); expect(status).toBe(401); expect(body).toEqual(errorDto.unauthorized); }); it('should require an albumId', async () => { const { status, body } = await request(app) - .post('/activity') + .post('/activities') .set('Authorization', `Bearer ${admin.accessToken}`) .send({ albumId: uuidDto.invalid }); expect(status).toEqual(400); @@ -207,7 +209,7 @@ describe('/activity', () => { it('should require a comment when type is comment', async () => { const { status, body } = await request(app) - .post('/activity') + .post('/activities') .set('Authorization', `Bearer ${admin.accessToken}`) .send({ albumId: uuidDto.notFound, type: 'comment', comment: null }); expect(status).toEqual(400); @@ -216,7 +218,7 @@ describe('/activity', () => { it('should add a comment to an album', async () => { const { status, body } = await request(app) - .post('/activity') + .post('/activities') .set('Authorization', `Bearer ${admin.accessToken}`) .send({ albumId: album.id, @@ -236,7 +238,7 @@ describe('/activity', () => { it('should add a like to an album', async () => { const { status, body } = await request(app) - .post('/activity') + .post('/activities') .set('Authorization', `Bearer ${admin.accessToken}`) .send({ albumId: album.id, type: 'like' }); expect(status).toEqual(201); @@ -253,7 +255,7 @@ describe('/activity', () => { it('should return a 200 for a duplicate like on the album', async () => { const reaction = await createActivity({ albumId: album.id, type: ReactionType.Like }); const { status, body } = await request(app) - .post('/activity') + .post('/activities') .set('Authorization', `Bearer ${admin.accessToken}`) .send({ albumId: album.id, type: 'like' }); expect(status).toEqual(200); @@ -267,7 +269,7 @@ describe('/activity', () => { type: ReactionType.Like, }); const { status, body } = await request(app) - .post('/activity') + .post('/activities') .set('Authorization', `Bearer ${admin.accessToken}`) .send({ albumId: album.id, type: 'like' }); expect(status).toEqual(201); @@ -276,7 +278,7 @@ describe('/activity', () => { it('should add a comment to an asset', async () => { const { status, body } = await request(app) - .post('/activity') + .post('/activities') .set('Authorization', `Bearer ${admin.accessToken}`) .send({ albumId: album.id, @@ -297,7 +299,7 @@ describe('/activity', () => { it('should add a like to an asset', async () => { const { status, body } = await request(app) - .post('/activity') + .post('/activities') .set('Authorization', `Bearer ${admin.accessToken}`) .send({ albumId: album.id, assetId: asset.id, type: 'like' }); expect(status).toEqual(201); @@ -319,7 +321,7 @@ describe('/activity', () => { }); const { status, body } = await request(app) - .post('/activity') + .post('/activities') .set('Authorization', `Bearer ${admin.accessToken}`) .send({ albumId: album.id, assetId: asset.id, type: 'like' }); expect(status).toEqual(200); @@ -327,16 +329,16 @@ describe('/activity', () => { }); }); - describe('DELETE /activity/:id', () => { + describe('DELETE /activities/:id', () => { it('should require authentication', async () => { - const { status, body } = await request(app).delete(`/activity/${uuidDto.notFound}`); + const { status, body } = await request(app).delete(`/activities/${uuidDto.notFound}`); expect(status).toBe(401); expect(body).toEqual(errorDto.unauthorized); }); it('should require a valid uuid', async () => { const { status, body } = await request(app) - .delete(`/activity/${uuidDto.invalid}`) + .delete(`/activities/${uuidDto.invalid}`) .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(400); expect(body).toEqual(errorDto.badRequest(['id must be a UUID'])); @@ -349,7 +351,7 @@ describe('/activity', () => { comment: 'This is a test comment', }); const { status } = await request(app) - .delete(`/activity/${reaction.id}`) + .delete(`/activities/${reaction.id}`) .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toEqual(204); }); @@ -360,7 +362,7 @@ describe('/activity', () => { type: ReactionType.Like, }); const { status } = await request(app) - .delete(`/activity/${reaction.id}`) + .delete(`/activities/${reaction.id}`) .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toEqual(204); }); @@ -373,7 +375,7 @@ describe('/activity', () => { }); const { status } = await request(app) - .delete(`/activity/${reaction.id}`) + .delete(`/activities/${reaction.id}`) .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toEqual(204); @@ -387,7 +389,7 @@ describe('/activity', () => { }); const { status, body } = await request(app) - .delete(`/activity/${reaction.id}`) + .delete(`/activities/${reaction.id}`) .set('Authorization', `Bearer ${nonOwner.accessToken}`); expect(status).toBe(400); @@ -405,7 +407,7 @@ describe('/activity', () => { ); const { status } = await request(app) - .delete(`/activity/${reaction.id}`) + .delete(`/activities/${reaction.id}`) .set('Authorization', `Bearer ${nonOwner.accessToken}`); expect(status).toBe(204); diff --git a/e2e/src/api/specs/album.e2e-spec.ts b/e2e/src/api/specs/album.e2e-spec.ts index ec5238f376..5cebe8f42c 100644 --- a/e2e/src/api/specs/album.e2e-spec.ts +++ b/e2e/src/api/specs/album.e2e-spec.ts @@ -23,7 +23,7 @@ const user2SharedUser = 'user2SharedUser'; const user2SharedLink = 'user2SharedLink'; const user2NotShared = 'user2NotShared'; -describe('/album', () => { +describe('/albums', () => { let admin: LoginResponseDto; let user1: LoginResponseDto; let user1Asset1: AssetFileUploadResponseDto; @@ -110,16 +110,16 @@ describe('/album', () => { await deleteUser({ id: user3.userId, deleteUserDto: {} }, { headers: asBearerAuth(admin.accessToken) }); }); - describe('GET /album', () => { + describe('GET /albums', () => { it('should require authentication', async () => { - const { status, body } = await request(app).get('/album'); + const { status, body } = await request(app).get('/albums'); expect(status).toBe(401); expect(body).toEqual(errorDto.unauthorized); }); it('should reject an invalid shared param', async () => { const { status, body } = await request(app) - .get('/album?shared=invalid') + .get('/albums?shared=invalid') .set('Authorization', `Bearer ${user1.accessToken}`); expect(status).toEqual(400); expect(body).toEqual(errorDto.badRequest(['shared must be a boolean value'])); @@ -127,7 +127,7 @@ describe('/album', () => { it('should reject an invalid assetId param', async () => { const { status, body } = await request(app) - .get('/album?assetId=invalid') + .get('/albums?assetId=invalid') .set('Authorization', `Bearer ${user1.accessToken}`); expect(status).toEqual(400); expect(body).toEqual(errorDto.badRequest(['assetId must be a UUID'])); @@ -135,7 +135,7 @@ describe('/album', () => { it("should not show other users' favorites", async () => { const { status, body } = await request(app) - .get(`/album/${user1Albums[0].id}?withoutAssets=false`) + .get(`/albums/${user1Albums[0].id}?withoutAssets=false`) .set('Authorization', `Bearer ${user2.accessToken}`); expect(status).toEqual(200); expect(body).toEqual({ @@ -146,7 +146,7 @@ describe('/album', () => { it('should not return shared albums with a deleted owner', async () => { const { status, body } = await request(app) - .get('/album?shared=true') + .get('/albums?shared=true') .set('Authorization', `Bearer ${user1.accessToken}`); expect(status).toBe(200); @@ -178,7 +178,7 @@ describe('/album', () => { }); it('should return the album collection including owned and shared', async () => { - const { status, body } = await request(app).get('/album').set('Authorization', `Bearer ${user1.accessToken}`); + const { status, body } = await request(app).get('/albums').set('Authorization', `Bearer ${user1.accessToken}`); expect(status).toBe(200); expect(body).toHaveLength(4); expect(body).toEqual( @@ -209,7 +209,7 @@ describe('/album', () => { it('should return the album collection filtered by shared', async () => { const { status, body } = await request(app) - .get('/album?shared=true') + .get('/albums?shared=true') .set('Authorization', `Bearer ${user1.accessToken}`); expect(status).toBe(200); expect(body).toHaveLength(4); @@ -241,7 +241,7 @@ describe('/album', () => { it('should return the album collection filtered by NOT shared', async () => { const { status, body } = await request(app) - .get('/album?shared=false') + .get('/albums?shared=false') .set('Authorization', `Bearer ${user1.accessToken}`); expect(status).toBe(200); expect(body).toHaveLength(1); @@ -258,7 +258,7 @@ describe('/album', () => { it('should return the album collection filtered by assetId', async () => { const { status, body } = await request(app) - .get(`/album?assetId=${user1Asset2.id}`) + .get(`/albums?assetId=${user1Asset2.id}`) .set('Authorization', `Bearer ${user1.accessToken}`); expect(status).toBe(200); expect(body).toHaveLength(1); @@ -266,7 +266,7 @@ describe('/album', () => { it('should return the album collection filtered by assetId and ignores shared=true', async () => { const { status, body } = await request(app) - .get(`/album?shared=true&assetId=${user1Asset1.id}`) + .get(`/albums?shared=true&assetId=${user1Asset1.id}`) .set('Authorization', `Bearer ${user1.accessToken}`); expect(status).toBe(200); expect(body).toHaveLength(5); @@ -274,23 +274,23 @@ describe('/album', () => { it('should return the album collection filtered by assetId and ignores shared=false', async () => { const { status, body } = await request(app) - .get(`/album?shared=false&assetId=${user1Asset1.id}`) + .get(`/albums?shared=false&assetId=${user1Asset1.id}`) .set('Authorization', `Bearer ${user1.accessToken}`); expect(status).toBe(200); expect(body).toHaveLength(5); }); }); - describe('GET /album/:id', () => { + describe('GET /albums/:id', () => { it('should require authentication', async () => { - const { status, body } = await request(app).get(`/album/${user1Albums[0].id}`); + const { status, body } = await request(app).get(`/albums/${user1Albums[0].id}`); expect(status).toBe(401); expect(body).toEqual(errorDto.unauthorized); }); it('should return album info for own album', async () => { const { status, body } = await request(app) - .get(`/album/${user1Albums[0].id}?withoutAssets=false`) + .get(`/albums/${user1Albums[0].id}?withoutAssets=false`) .set('Authorization', `Bearer ${user1.accessToken}`); expect(status).toBe(200); @@ -302,7 +302,7 @@ describe('/album', () => { it('should return album info for shared album (editor)', async () => { const { status, body } = await request(app) - .get(`/album/${user2Albums[0].id}?withoutAssets=false`) + .get(`/albums/${user2Albums[0].id}?withoutAssets=false`) .set('Authorization', `Bearer ${user1.accessToken}`); expect(status).toBe(200); @@ -311,7 +311,7 @@ describe('/album', () => { it('should return album info for shared album (viewer)', async () => { const { status, body } = await request(app) - .get(`/album/${user1Albums[3].id}?withoutAssets=false`) + .get(`/albums/${user1Albums[3].id}?withoutAssets=false`) .set('Authorization', `Bearer ${user2.accessToken}`); expect(status).toBe(200); @@ -320,7 +320,7 @@ describe('/album', () => { it('should return album info with assets when withoutAssets is undefined', async () => { const { status, body } = await request(app) - .get(`/album/${user1Albums[0].id}`) + .get(`/albums/${user1Albums[0].id}`) .set('Authorization', `Bearer ${user1.accessToken}`); expect(status).toBe(200); @@ -332,7 +332,7 @@ describe('/album', () => { it('should return album info without assets when withoutAssets is true', async () => { const { status, body } = await request(app) - .get(`/album/${user1Albums[0].id}?withoutAssets=true`) + .get(`/albums/${user1Albums[0].id}?withoutAssets=true`) .set('Authorization', `Bearer ${user1.accessToken}`); expect(status).toBe(200); @@ -344,16 +344,16 @@ describe('/album', () => { }); }); - describe('GET /album/count', () => { + describe('GET /albums/count', () => { it('should require authentication', async () => { - const { status, body } = await request(app).get('/album/count'); + const { status, body } = await request(app).get('/albums/count'); expect(status).toBe(401); expect(body).toEqual(errorDto.unauthorized); }); it('should return total count of albums the user has access to', async () => { const { status, body } = await request(app) - .get('/album/count') + .get('/albums/count') .set('Authorization', `Bearer ${user1.accessToken}`); expect(status).toBe(200); @@ -361,16 +361,16 @@ describe('/album', () => { }); }); - describe('POST /album', () => { + describe('POST /albums', () => { it('should require authentication', async () => { - const { status, body } = await request(app).post('/album').send({ albumName: 'New album' }); + const { status, body } = await request(app).post('/albums').send({ albumName: 'New album' }); expect(status).toBe(401); expect(body).toEqual(errorDto.unauthorized); }); it('should create an album', async () => { const { status, body } = await request(app) - .post('/album') + .post('/albums') .send({ albumName: 'New album' }) .set('Authorization', `Bearer ${user1.accessToken}`); expect(status).toBe(201); @@ -395,9 +395,9 @@ describe('/album', () => { }); }); - describe('PUT /album/:id/assets', () => { + describe('PUT /albums/:id/assets', () => { it('should require authentication', async () => { - const { status, body } = await request(app).put(`/album/${user1Albums[0].id}/assets`); + const { status, body } = await request(app).put(`/albums/${user1Albums[0].id}/assets`); expect(status).toBe(401); expect(body).toEqual(errorDto.unauthorized); }); @@ -405,7 +405,7 @@ describe('/album', () => { it('should be able to add own asset to own album', async () => { const asset = await utils.createAsset(user1.accessToken); const { status, body } = await request(app) - .put(`/album/${user1Albums[0].id}/assets`) + .put(`/albums/${user1Albums[0].id}/assets`) .set('Authorization', `Bearer ${user1.accessToken}`) .send({ ids: [asset.id] }); @@ -416,7 +416,7 @@ describe('/album', () => { it('should be able to add own asset to shared album', async () => { const asset = await utils.createAsset(user1.accessToken); const { status, body } = await request(app) - .put(`/album/${user2Albums[0].id}/assets`) + .put(`/albums/${user2Albums[0].id}/assets`) .set('Authorization', `Bearer ${user1.accessToken}`) .send({ ids: [asset.id] }); @@ -427,7 +427,7 @@ describe('/album', () => { it('should not be able to add assets to album as a viewer', async () => { const asset = await utils.createAsset(user2.accessToken); const { status, body } = await request(app) - .put(`/album/${user1Albums[3].id}/assets`) + .put(`/albums/${user1Albums[3].id}/assets`) .set('Authorization', `Bearer ${user2.accessToken}`) .send({ ids: [asset.id] }); @@ -438,7 +438,7 @@ describe('/album', () => { it('should add duplicate assets only once', async () => { const asset = await utils.createAsset(user1.accessToken); const { status, body } = await request(app) - .put(`/album/${user1Albums[0].id}/assets`) + .put(`/albums/${user1Albums[0].id}/assets`) .set('Authorization', `Bearer ${user1.accessToken}`) .send({ ids: [asset.id, asset.id] }); @@ -450,10 +450,10 @@ describe('/album', () => { }); }); - describe('PATCH /album/:id', () => { + describe('PATCH /albums/:id', () => { it('should require authentication', async () => { const { status, body } = await request(app) - .patch(`/album/${uuidDto.notFound}`) + .patch(`/albums/${uuidDto.notFound}`) .send({ albumName: 'New album name' }); expect(status).toBe(401); expect(body).toEqual(errorDto.unauthorized); @@ -464,7 +464,7 @@ describe('/album', () => { albumName: 'New album', }); const { status, body } = await request(app) - .patch(`/album/${album.id}`) + .patch(`/albums/${album.id}`) .set('Authorization', `Bearer ${user1.accessToken}`) .send({ albumName: 'New album name', @@ -481,7 +481,7 @@ describe('/album', () => { it('should not be able to update as a viewer', async () => { const { status, body } = await request(app) - .patch(`/album/${user1Albums[3].id}`) + .patch(`/albums/${user1Albums[3].id}`) .set('Authorization', `Bearer ${user2.accessToken}`) .send({ albumName: 'New album name' }); @@ -491,7 +491,7 @@ describe('/album', () => { it('should not be able to update as an editor', async () => { const { status, body } = await request(app) - .patch(`/album/${user1Albums[0].id}`) + .patch(`/albums/${user1Albums[0].id}`) .set('Authorization', `Bearer ${user2.accessToken}`) .send({ albumName: 'New album name' }); @@ -500,10 +500,10 @@ describe('/album', () => { }); }); - describe('DELETE /album/:id/assets', () => { + describe('DELETE /albums/:id/assets', () => { it('should require authentication', async () => { const { status, body } = await request(app) - .delete(`/album/${user1Albums[0].id}/assets`) + .delete(`/albums/${user1Albums[0].id}/assets`) .send({ ids: [user1Asset1.id] }); expect(status).toBe(401); @@ -512,7 +512,7 @@ describe('/album', () => { it('should not be able to remove foreign asset from own album', async () => { const { status, body } = await request(app) - .delete(`/album/${user2Albums[0].id}/assets`) + .delete(`/albums/${user2Albums[0].id}/assets`) .set('Authorization', `Bearer ${user2.accessToken}`) .send({ ids: [user1Asset1.id] }); @@ -528,7 +528,7 @@ describe('/album', () => { it('should not be able to remove foreign asset from foreign album', async () => { const { status, body } = await request(app) - .delete(`/album/${user1Albums[0].id}/assets`) + .delete(`/albums/${user1Albums[0].id}/assets`) .set('Authorization', `Bearer ${user2.accessToken}`) .send({ ids: [user1Asset1.id] }); @@ -544,7 +544,7 @@ describe('/album', () => { it('should be able to remove own asset from own album', async () => { const { status, body } = await request(app) - .delete(`/album/${user1Albums[0].id}/assets`) + .delete(`/albums/${user1Albums[0].id}/assets`) .set('Authorization', `Bearer ${user1.accessToken}`) .send({ ids: [user1Asset1.id] }); @@ -554,7 +554,7 @@ describe('/album', () => { it('should be able to remove own asset from shared album', async () => { const { status, body } = await request(app) - .delete(`/album/${user2Albums[0].id}/assets`) + .delete(`/albums/${user2Albums[0].id}/assets`) .set('Authorization', `Bearer ${user1.accessToken}`) .send({ ids: [user1Asset1.id] }); @@ -564,7 +564,7 @@ describe('/album', () => { it('should not be able to remove assets from album as a viewer', async () => { const { status, body } = await request(app) - .delete(`/album/${user1Albums[3].id}/assets`) + .delete(`/albums/${user1Albums[3].id}/assets`) .set('Authorization', `Bearer ${user2.accessToken}`) .send({ ids: [user1Asset1.id] }); @@ -574,7 +574,7 @@ describe('/album', () => { it('should remove duplicate assets only once', async () => { const { status, body } = await request(app) - .delete(`/album/${user1Albums[1].id}/assets`) + .delete(`/albums/${user1Albums[1].id}/assets`) .set('Authorization', `Bearer ${user1.accessToken}`) .send({ ids: [user1Asset1.id, user1Asset1.id] }); @@ -596,7 +596,7 @@ describe('/album', () => { }); it('should require authentication', async () => { - const { status, body } = await request(app).put(`/album/${user1Albums[0].id}/users`).send({ sharedUserIds: [] }); + const { status, body } = await request(app).put(`/albums/${user1Albums[0].id}/users`).send({ sharedUserIds: [] }); expect(status).toBe(401); expect(body).toEqual(errorDto.unauthorized); @@ -604,7 +604,7 @@ describe('/album', () => { it('should be able to add user to own album', async () => { const { status, body } = await request(app) - .put(`/album/${album.id}/users`) + .put(`/albums/${album.id}/users`) .set('Authorization', `Bearer ${user1.accessToken}`) .send({ albumUsers: [{ userId: user2.userId, role: AlbumUserRole.Editor }] }); @@ -618,7 +618,7 @@ describe('/album', () => { it('should not be able to share album with owner', async () => { const { status, body } = await request(app) - .put(`/album/${album.id}/users`) + .put(`/albums/${album.id}/users`) .set('Authorization', `Bearer ${user1.accessToken}`) .send({ albumUsers: [{ userId: user1.userId, role: AlbumUserRole.Editor }] }); @@ -628,12 +628,12 @@ describe('/album', () => { it('should not be able to add existing user to shared album', async () => { await request(app) - .put(`/album/${album.id}/users`) + .put(`/albums/${album.id}/users`) .set('Authorization', `Bearer ${user1.accessToken}`) .send({ albumUsers: [{ userId: user2.userId, role: AlbumUserRole.Editor }] }); const { status, body } = await request(app) - .put(`/album/${album.id}/users`) + .put(`/albums/${album.id}/users`) .set('Authorization', `Bearer ${user1.accessToken}`) .send({ albumUsers: [{ userId: user2.userId, role: AlbumUserRole.Editor }] }); @@ -652,14 +652,16 @@ describe('/album', () => { expect(album.albumUsers[0].role).toEqual(AlbumUserRole.Viewer); const { status } = await request(app) - .put(`/album/${album.id}/user/${user2.userId}`) + .put(`/albums/${album.id}/user/${user2.userId}`) .set('Authorization', `Bearer ${user1.accessToken}`) .send({ role: AlbumUserRole.Editor }); expect(status).toBe(200); // Get album to verify the role change - const { body } = await request(app).get(`/album/${album.id}`).set('Authorization', `Bearer ${user1.accessToken}`); + const { body } = await request(app) + .get(`/albums/${album.id}`) + .set('Authorization', `Bearer ${user1.accessToken}`); expect(body).toEqual( expect.objectContaining({ albumUsers: [expect.objectContaining({ role: AlbumUserRole.Editor })], @@ -676,7 +678,7 @@ describe('/album', () => { expect(album.albumUsers[0].role).toEqual(AlbumUserRole.Viewer); const { status, body } = await request(app) - .put(`/album/${album.id}/user/${user2.userId}`) + .put(`/albums/${album.id}/user/${user2.userId}`) .set('Authorization', `Bearer ${user2.accessToken}`) .send({ role: AlbumUserRole.Editor }); diff --git a/e2e/src/api/specs/asset.e2e-spec.ts b/e2e/src/api/specs/asset.e2e-spec.ts index 50b84fd9b0..5dd3ec698b 100644 --- a/e2e/src/api/specs/asset.e2e-spec.ts +++ b/e2e/src/api/specs/asset.e2e-spec.ts @@ -5,6 +5,7 @@ import { LoginResponseDto, SharedLinkType, getAssetInfo, + getMyUserInfo, updateAssets, } from '@immich/sdk'; import { exiftool } from 'exiftool-vendored'; @@ -830,7 +831,7 @@ describe('/asset', () => { expect(body).toEqual({ id: expect.any(String), duplicate: false }); expect(status).toBe(201); - const { body: user } = await request(app).get('/user/me').set('Authorization', `Bearer ${quotaUser.accessToken}`); + const user = await getMyUserInfo({ headers: asBearerAuth(quotaUser.accessToken) }); expect(user).toEqual(expect.objectContaining({ quotaUsageInBytes: 70 })); }); diff --git a/e2e/src/api/specs/audit.e2e-spec.ts b/e2e/src/api/specs/audit.e2e-spec.ts index ec8c3799c8..c6a2adbb0a 100644 --- a/e2e/src/api/specs/audit.e2e-spec.ts +++ b/e2e/src/api/specs/audit.e2e-spec.ts @@ -2,7 +2,7 @@ import { deleteAssets, getAuditFiles, updateAsset, type LoginResponseDto } from import { asBearerAuth, utils } from 'src/utils'; import { beforeAll, describe, expect, it } from 'vitest'; -describe('/audit', () => { +describe('/audits', () => { let admin: LoginResponseDto; beforeAll(async () => { diff --git a/e2e/src/api/specs/library.e2e-spec.ts b/e2e/src/api/specs/library.e2e-spec.ts index f31a20e27c..762606de5e 100644 --- a/e2e/src/api/specs/library.e2e-spec.ts +++ b/e2e/src/api/specs/library.e2e-spec.ts @@ -11,7 +11,7 @@ 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) }); -describe('/library', () => { +describe('/libraries', () => { let admin: LoginResponseDto; let user: LoginResponseDto; let library: LibraryResponseDto; @@ -37,24 +37,24 @@ describe('/library', () => { utils.resetEvents(); }); - describe('GET /library', () => { + describe('GET /libraries', () => { it('should require authentication', async () => { - const { status, body } = await request(app).get('/library'); + const { status, body } = await request(app).get('/libraries'); expect(status).toBe(401); expect(body).toEqual(errorDto.unauthorized); }); }); - describe('POST /library', () => { + describe('POST /libraries', () => { it('should require authentication', async () => { - const { status, body } = await request(app).post('/library').send({}); + const { status, body } = await request(app).post('/libraries').send({}); expect(status).toBe(401); expect(body).toEqual(errorDto.unauthorized); }); it('should require admin authentication', async () => { const { status, body } = await request(app) - .post('/library') + .post('/libraries') .set('Authorization', `Bearer ${user.accessToken}`) .send({ ownerId: admin.userId }); @@ -64,7 +64,7 @@ describe('/library', () => { it('should create an external library with defaults', async () => { const { status, body } = await request(app) - .post('/library') + .post('/libraries') .set('Authorization', `Bearer ${admin.accessToken}`) .send({ ownerId: admin.userId }); @@ -83,7 +83,7 @@ describe('/library', () => { it('should create an external library with options', async () => { const { status, body } = await request(app) - .post('/library') + .post('/libraries') .set('Authorization', `Bearer ${admin.accessToken}`) .send({ ownerId: admin.userId, @@ -103,7 +103,7 @@ describe('/library', () => { it('should not create an external library with duplicate import paths', async () => { const { status, body } = await request(app) - .post('/library') + .post('/libraries') .set('Authorization', `Bearer ${admin.accessToken}`) .send({ ownerId: admin.userId, @@ -118,7 +118,7 @@ describe('/library', () => { it('should not create an external library with duplicate exclusion patterns', async () => { const { status, body } = await request(app) - .post('/library') + .post('/libraries') .set('Authorization', `Bearer ${admin.accessToken}`) .send({ ownerId: admin.userId, @@ -132,16 +132,16 @@ describe('/library', () => { }); }); - describe('PUT /library/:id', () => { + describe('PUT /libraries/:id', () => { it('should require authentication', async () => { - const { status, body } = await request(app).put(`/library/${uuidDto.notFound}`).send({}); + const { status, body } = await request(app).put(`/libraries/${uuidDto.notFound}`).send({}); expect(status).toBe(401); expect(body).toEqual(errorDto.unauthorized); }); it('should change the library name', async () => { const { status, body } = await request(app) - .put(`/library/${library.id}`) + .put(`/libraries/${library.id}`) .set('Authorization', `Bearer ${admin.accessToken}`) .send({ name: 'New Library Name' }); @@ -155,7 +155,7 @@ describe('/library', () => { it('should not set an empty name', async () => { const { status, body } = await request(app) - .put(`/library/${library.id}`) + .put(`/libraries/${library.id}`) .set('Authorization', `Bearer ${admin.accessToken}`) .send({ name: '' }); @@ -165,7 +165,7 @@ describe('/library', () => { it('should change the import paths', async () => { const { status, body } = await request(app) - .put(`/library/${library.id}`) + .put(`/libraries/${library.id}`) .set('Authorization', `Bearer ${admin.accessToken}`) .send({ importPaths: [testAssetDirInternal] }); @@ -179,7 +179,7 @@ describe('/library', () => { it('should reject an empty import path', async () => { const { status, body } = await request(app) - .put(`/library/${library.id}`) + .put(`/libraries/${library.id}`) .set('Authorization', `Bearer ${admin.accessToken}`) .send({ importPaths: [''] }); @@ -189,7 +189,7 @@ describe('/library', () => { it('should reject duplicate import paths', async () => { const { status, body } = await request(app) - .put(`/library/${library.id}`) + .put(`/libraries/${library.id}`) .set('Authorization', `Bearer ${admin.accessToken}`) .send({ importPaths: ['/path', '/path'] }); @@ -199,7 +199,7 @@ describe('/library', () => { it('should change the exclusion pattern', async () => { const { status, body } = await request(app) - .put(`/library/${library.id}`) + .put(`/libraries/${library.id}`) .set('Authorization', `Bearer ${admin.accessToken}`) .send({ exclusionPatterns: ['**/Raw/**'] }); @@ -213,7 +213,7 @@ describe('/library', () => { it('should reject duplicate exclusion patterns', async () => { const { status, body } = await request(app) - .put(`/library/${library.id}`) + .put(`/libraries/${library.id}`) .set('Authorization', `Bearer ${admin.accessToken}`) .send({ exclusionPatterns: ['**/*.jpg', '**/*.jpg'] }); @@ -223,7 +223,7 @@ describe('/library', () => { it('should reject an empty exclusion pattern', async () => { const { status, body } = await request(app) - .put(`/library/${library.id}`) + .put(`/libraries/${library.id}`) .set('Authorization', `Bearer ${admin.accessToken}`) .send({ exclusionPatterns: [''] }); @@ -232,9 +232,9 @@ describe('/library', () => { }); }); - describe('GET /library/:id', () => { + describe('GET /libraries/:id', () => { it('should require authentication', async () => { - const { status, body } = await request(app).get(`/library/${uuidDto.notFound}`); + const { status, body } = await request(app).get(`/libraries/${uuidDto.notFound}`); expect(status).toBe(401); expect(body).toEqual(errorDto.unauthorized); @@ -242,7 +242,7 @@ describe('/library', () => { it('should require admin access', async () => { const { status, body } = await request(app) - .get(`/library/${uuidDto.notFound}`) + .get(`/libraries/${uuidDto.notFound}`) .set('Authorization', `Bearer ${user.accessToken}`); expect(status).toBe(403); expect(body).toEqual(errorDto.forbidden); @@ -252,7 +252,7 @@ describe('/library', () => { const library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId }); const { status, body } = await request(app) - .get(`/library/${library.id}`) + .get(`/libraries/${library.id}`) .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(200); @@ -269,18 +269,18 @@ describe('/library', () => { }); }); - describe('GET /library/:id/statistics', () => { + describe('GET /libraries/:id/statistics', () => { it('should require authentication', async () => { - const { status, body } = await request(app).get(`/library/${uuidDto.notFound}/statistics`); + const { status, body } = await request(app).get(`/libraries/${uuidDto.notFound}/statistics`); expect(status).toBe(401); expect(body).toEqual(errorDto.unauthorized); }); }); - describe('POST /library/:id/scan', () => { + describe('POST /libraries/:id/scan', () => { it('should require authentication', async () => { - const { status, body } = await request(app).post(`/library/${uuidDto.notFound}/scan`).send({}); + const { status, body } = await request(app).post(`/libraries/${uuidDto.notFound}/scan`).send({}); expect(status).toBe(401); expect(body).toEqual(errorDto.unauthorized); @@ -496,9 +496,9 @@ describe('/library', () => { }); }); - describe('POST /library/:id/removeOffline', () => { + describe('POST /libraries/:id/removeOffline', () => { it('should require authentication', async () => { - const { status, body } = await request(app).post(`/library/${uuidDto.notFound}/removeOffline`).send({}); + const { status, body } = await request(app).post(`/libraries/${uuidDto.notFound}/removeOffline`).send({}); expect(status).toBe(401); expect(body).toEqual(errorDto.unauthorized); @@ -532,7 +532,7 @@ describe('/library', () => { expect(offlineAssets.count).toBe(1); const { status } = await request(app) - .post(`/library/${library.id}/removeOffline`) + .post(`/libraries/${library.id}/removeOffline`) .set('Authorization', `Bearer ${admin.accessToken}`) .send(); expect(status).toBe(204); @@ -557,7 +557,7 @@ describe('/library', () => { expect(assetsBefore.count).toBeGreaterThan(1); const { status } = await request(app) - .post(`/library/${library.id}/removeOffline`) + .post(`/libraries/${library.id}/removeOffline`) .set('Authorization', `Bearer ${admin.accessToken}`) .send(); expect(status).toBe(204); @@ -569,9 +569,9 @@ describe('/library', () => { }); }); - describe('POST /library/:id/validate', () => { + describe('POST /libraries/:id/validate', () => { it('should require authentication', async () => { - const { status, body } = await request(app).post(`/library/${uuidDto.notFound}/validate`).send({}); + const { status, body } = await request(app).post(`/libraries/${uuidDto.notFound}/validate`).send({}); expect(status).toBe(401); expect(body).toEqual(errorDto.unauthorized); @@ -617,9 +617,9 @@ describe('/library', () => { }); }); - describe('DELETE /library/:id', () => { + describe('DELETE /libraries/:id', () => { it('should require authentication', async () => { - const { status, body } = await request(app).delete(`/library/${uuidDto.notFound}`); + const { status, body } = await request(app).delete(`/libraries/${uuidDto.notFound}`); expect(status).toBe(401); expect(body).toEqual(errorDto.unauthorized); @@ -629,7 +629,7 @@ describe('/library', () => { const library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId }); const { status, body } = await request(app) - .delete(`/library/${library.id}`) + .delete(`/libraries/${library.id}`) .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(204); @@ -655,7 +655,7 @@ describe('/library', () => { await utils.waitForWebsocketEvent({ event: 'assetUpload', total: 2 }); const { status, body } = await request(app) - .delete(`/library/${library.id}`) + .delete(`/libraries/${library.id}`) .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(204); diff --git a/e2e/src/api/specs/partner.e2e-spec.ts b/e2e/src/api/specs/partner.e2e-spec.ts index b2fb7f4101..1654f04e18 100644 --- a/e2e/src/api/specs/partner.e2e-spec.ts +++ b/e2e/src/api/specs/partner.e2e-spec.ts @@ -5,7 +5,7 @@ import { app, asBearerAuth, utils } from 'src/utils'; import request from 'supertest'; import { beforeAll, describe, expect, it } from 'vitest'; -describe('/partner', () => { +describe('/partners', () => { let admin: LoginResponseDto; let user1: LoginResponseDto; let user2: LoginResponseDto; @@ -28,9 +28,9 @@ describe('/partner', () => { ]); }); - describe('GET /partner', () => { + describe('GET /partners', () => { it('should require authentication', async () => { - const { status, body } = await request(app).get('/partner'); + const { status, body } = await request(app).get('/partners'); expect(status).toBe(401); expect(body).toEqual(errorDto.unauthorized); @@ -38,7 +38,7 @@ describe('/partner', () => { it('should get all partners shared by user', async () => { const { status, body } = await request(app) - .get('/partner') + .get('/partners') .set('Authorization', `Bearer ${user1.accessToken}`) .query({ direction: 'shared-by' }); @@ -48,7 +48,7 @@ describe('/partner', () => { it('should get all partners that share with user', async () => { const { status, body } = await request(app) - .get('/partner') + .get('/partners') .set('Authorization', `Bearer ${user1.accessToken}`) .query({ direction: 'shared-with' }); @@ -57,9 +57,9 @@ describe('/partner', () => { }); }); - describe('POST /partner/:id', () => { + describe('POST /partners/:id', () => { it('should require authentication', async () => { - const { status, body } = await request(app).post(`/partner/${user3.userId}`); + const { status, body } = await request(app).post(`/partners/${user3.userId}`); expect(status).toBe(401); expect(body).toEqual(errorDto.unauthorized); @@ -67,7 +67,7 @@ describe('/partner', () => { it('should share with new partner', async () => { const { status, body } = await request(app) - .post(`/partner/${user3.userId}`) + .post(`/partners/${user3.userId}`) .set('Authorization', `Bearer ${user1.accessToken}`); expect(status).toBe(201); @@ -76,7 +76,7 @@ describe('/partner', () => { it('should not share with new partner if already sharing with this partner', async () => { const { status, body } = await request(app) - .post(`/partner/${user2.userId}`) + .post(`/partners/${user2.userId}`) .set('Authorization', `Bearer ${user1.accessToken}`); expect(status).toBe(400); @@ -84,9 +84,9 @@ describe('/partner', () => { }); }); - describe('PUT /partner/:id', () => { + describe('PUT /partners/:id', () => { it('should require authentication', async () => { - const { status, body } = await request(app).put(`/partner/${user2.userId}`); + const { status, body } = await request(app).put(`/partners/${user2.userId}`); expect(status).toBe(401); expect(body).toEqual(errorDto.unauthorized); @@ -94,7 +94,7 @@ describe('/partner', () => { it('should update partner', async () => { const { status, body } = await request(app) - .put(`/partner/${user2.userId}`) + .put(`/partners/${user2.userId}`) .set('Authorization', `Bearer ${user1.accessToken}`) .send({ inTimeline: false }); @@ -103,9 +103,9 @@ describe('/partner', () => { }); }); - describe('DELETE /partner/:id', () => { + describe('DELETE /partners/:id', () => { it('should require authentication', async () => { - const { status, body } = await request(app).delete(`/partner/${user3.userId}`); + const { status, body } = await request(app).delete(`/partners/${user3.userId}`); expect(status).toBe(401); expect(body).toEqual(errorDto.unauthorized); @@ -113,7 +113,7 @@ describe('/partner', () => { it('should delete partner', async () => { const { status } = await request(app) - .delete(`/partner/${user3.userId}`) + .delete(`/partners/${user3.userId}`) .set('Authorization', `Bearer ${user1.accessToken}`); expect(status).toBe(200); @@ -121,7 +121,7 @@ describe('/partner', () => { it('should throw a bad request if partner not found', async () => { const { status, body } = await request(app) - .delete(`/partner/${user3.userId}`) + .delete(`/partners/${user3.userId}`) .set('Authorization', `Bearer ${user1.accessToken}`); expect(status).toBe(400); diff --git a/e2e/src/api/specs/person.e2e-spec.ts b/e2e/src/api/specs/person.e2e-spec.ts index 54fbfa9be5..963b4cf7bc 100644 --- a/e2e/src/api/specs/person.e2e-spec.ts +++ b/e2e/src/api/specs/person.e2e-spec.ts @@ -12,7 +12,7 @@ const invalidBirthday = [ { birthDate: new Date(9999, 0, 0).toISOString(), response: ['Birth date cannot be in the future'] }, ]; -describe('/person', () => { +describe('/people', () => { let admin: LoginResponseDto; let visiblePerson: PersonResponseDto; let hiddenPerson: PersonResponseDto; @@ -47,11 +47,11 @@ describe('/person', () => { ]); }); - describe('GET /person', () => { + describe('GET /people', () => { beforeEach(async () => {}); it('should require authentication', async () => { - const { status, body } = await request(app).get('/person'); + const { status, body } = await request(app).get('/people'); expect(status).toBe(401); expect(body).toEqual(errorDto.unauthorized); @@ -59,7 +59,7 @@ describe('/person', () => { it('should return all people (including hidden)', async () => { const { status, body } = await request(app) - .get('/person') + .get('/people') .set('Authorization', `Bearer ${admin.accessToken}`) .query({ withHidden: true }); @@ -76,7 +76,7 @@ describe('/person', () => { }); it('should return only visible people', async () => { - const { status, body } = await request(app).get('/person').set('Authorization', `Bearer ${admin.accessToken}`); + const { status, body } = await request(app).get('/people').set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(200); expect(body).toEqual({ @@ -90,9 +90,9 @@ describe('/person', () => { }); }); - describe('GET /person/:id', () => { + describe('GET /people/:id', () => { it('should require authentication', async () => { - const { status, body } = await request(app).get(`/person/${uuidDto.notFound}`); + const { status, body } = await request(app).get(`/people/${uuidDto.notFound}`); expect(status).toBe(401); expect(body).toEqual(errorDto.unauthorized); @@ -100,7 +100,7 @@ describe('/person', () => { it('should throw error if person with id does not exist', async () => { const { status, body } = await request(app) - .get(`/person/${uuidDto.notFound}`) + .get(`/people/${uuidDto.notFound}`) .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(400); @@ -109,7 +109,7 @@ describe('/person', () => { it('should return person information', async () => { const { status, body } = await request(app) - .get(`/person/${visiblePerson.id}`) + .get(`/people/${visiblePerson.id}`) .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(200); @@ -117,9 +117,9 @@ describe('/person', () => { }); }); - describe('GET /person/:id/statistics', () => { + describe('GET /people/:id/statistics', () => { it('should require authentication', async () => { - const { status, body } = await request(app).get(`/person/${multipleAssetsPerson.id}/statistics`); + const { status, body } = await request(app).get(`/people/${multipleAssetsPerson.id}/statistics`); expect(status).toBe(401); expect(body).toEqual(errorDto.unauthorized); @@ -127,7 +127,7 @@ describe('/person', () => { it('should throw error if person with id does not exist', async () => { const { status, body } = await request(app) - .get(`/person/${uuidDto.notFound}/statistics`) + .get(`/people/${uuidDto.notFound}/statistics`) .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(400); @@ -136,7 +136,7 @@ describe('/person', () => { it('should return the correct number of assets', async () => { const { status, body } = await request(app) - .get(`/person/${multipleAssetsPerson.id}/statistics`) + .get(`/people/${multipleAssetsPerson.id}/statistics`) .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(200); @@ -144,9 +144,9 @@ describe('/person', () => { }); }); - describe('POST /person', () => { + describe('POST /people', () => { it('should require authentication', async () => { - const { status, body } = await request(app).post(`/person`); + const { status, body } = await request(app).post(`/people`); expect(status).toBe(401); expect(body).toEqual(errorDto.unauthorized); }); @@ -154,7 +154,7 @@ describe('/person', () => { for (const { birthDate, response } of invalidBirthday) { it(`should not accept an invalid birth date [${birthDate}]`, async () => { const { status, body } = await request(app) - .post(`/person`) + .post(`/people`) .set('Authorization', `Bearer ${admin.accessToken}`) .send({ birthDate }); expect(status).toBe(400); @@ -164,7 +164,7 @@ describe('/person', () => { it('should create a person', async () => { const { status, body } = await request(app) - .post(`/person`) + .post(`/people`) .set('Authorization', `Bearer ${admin.accessToken}`) .send({ name: 'New Person', @@ -179,9 +179,9 @@ describe('/person', () => { }); }); - describe('PUT /person/:id', () => { + describe('PUT /people/:id', () => { it('should require authentication', async () => { - const { status, body } = await request(app).put(`/person/${uuidDto.notFound}`); + const { status, body } = await request(app).put(`/people/${uuidDto.notFound}`); expect(status).toBe(401); expect(body).toEqual(errorDto.unauthorized); }); @@ -193,7 +193,7 @@ describe('/person', () => { ]) { it(`should not allow null ${key}`, async () => { const { status, body } = await request(app) - .put(`/person/${visiblePerson.id}`) + .put(`/people/${visiblePerson.id}`) .set('Authorization', `Bearer ${admin.accessToken}`) .send({ [key]: null }); expect(status).toBe(400); @@ -204,7 +204,7 @@ describe('/person', () => { for (const { birthDate, response } of invalidBirthday) { it(`should not accept an invalid birth date [${birthDate}]`, async () => { const { status, body } = await request(app) - .put(`/person/${visiblePerson.id}`) + .put(`/people/${visiblePerson.id}`) .set('Authorization', `Bearer ${admin.accessToken}`) .send({ birthDate }); expect(status).toBe(400); @@ -214,7 +214,7 @@ describe('/person', () => { it('should update a date of birth', async () => { const { status, body } = await request(app) - .put(`/person/${visiblePerson.id}`) + .put(`/people/${visiblePerson.id}`) .set('Authorization', `Bearer ${admin.accessToken}`) .send({ birthDate: '1990-01-01T05:00:00.000Z' }); expect(status).toBe(200); @@ -223,7 +223,7 @@ describe('/person', () => { it('should clear a date of birth', async () => { const { status, body } = await request(app) - .put(`/person/${visiblePerson.id}`) + .put(`/people/${visiblePerson.id}`) .set('Authorization', `Bearer ${admin.accessToken}`) .send({ birthDate: null }); expect(status).toBe(200); diff --git a/e2e/src/api/specs/shared-link.e2e-spec.ts b/e2e/src/api/specs/shared-link.e2e-spec.ts index c446fe9cdb..aa4ec7e349 100644 --- a/e2e/src/api/specs/shared-link.e2e-spec.ts +++ b/e2e/src/api/specs/shared-link.e2e-spec.ts @@ -13,7 +13,7 @@ import { app, asBearerAuth, shareUrl, utils } from 'src/utils'; import request from 'supertest'; import { beforeAll, describe, expect, it } from 'vitest'; -describe('/shared-link', () => { +describe('/shared-links', () => { let admin: LoginResponseDto; let asset1: AssetFileUploadResponseDto; let asset2: AssetFileUploadResponseDto; @@ -114,9 +114,9 @@ describe('/shared-link', () => { }); }); - describe('GET /shared-link', () => { + describe('GET /shared-links', () => { it('should require authentication', async () => { - const { status, body } = await request(app).get('/shared-link'); + const { status, body } = await request(app).get('/shared-links'); expect(status).toBe(401); expect(body).toEqual(errorDto.unauthorized); @@ -124,7 +124,7 @@ describe('/shared-link', () => { it('should get all shared links created by user', async () => { const { status, body } = await request(app) - .get('/shared-link') + .get('/shared-links') .set('Authorization', `Bearer ${user1.accessToken}`); expect(status).toBe(200); @@ -142,7 +142,7 @@ describe('/shared-link', () => { it('should not get shared links created by other users', async () => { const { status, body } = await request(app) - .get('/shared-link') + .get('/shared-links') .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(200); @@ -150,15 +150,15 @@ describe('/shared-link', () => { }); }); - describe('GET /shared-link/me', () => { + describe('GET /shared-links/me', () => { it('should not require admin authentication', async () => { - const { status } = await request(app).get('/shared-link/me').set('Authorization', `Bearer ${admin.accessToken}`); + const { status } = await request(app).get('/shared-links/me').set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(403); }); it('should get data for correct shared link', async () => { - const { status, body } = await request(app).get('/shared-link/me').query({ key: linkWithAlbum.key }); + const { status, body } = await request(app).get('/shared-links/me').query({ key: linkWithAlbum.key }); expect(status).toBe(200); expect(body).toEqual( @@ -172,7 +172,7 @@ describe('/shared-link', () => { it('should return unauthorized for incorrect shared link', async () => { const { status, body } = await request(app) - .get('/shared-link/me') + .get('/shared-links/me') .query({ key: linkWithAlbum.key + 'foo' }); expect(status).toBe(401); @@ -180,14 +180,14 @@ describe('/shared-link', () => { }); it('should return unauthorized if target has been soft deleted', async () => { - const { status, body } = await request(app).get('/shared-link/me').query({ key: linkWithDeletedAlbum.key }); + const { status, body } = await request(app).get('/shared-links/me').query({ key: linkWithDeletedAlbum.key }); expect(status).toBe(401); expect(body).toEqual(errorDto.invalidShareKey); }); it('should return unauthorized for password protected link', async () => { - const { status, body } = await request(app).get('/shared-link/me').query({ key: linkWithPassword.key }); + const { status, body } = await request(app).get('/shared-links/me').query({ key: linkWithPassword.key }); expect(status).toBe(401); expect(body).toEqual(errorDto.invalidSharePassword); @@ -195,7 +195,7 @@ describe('/shared-link', () => { it('should get data for correct password protected link', async () => { const { status, body } = await request(app) - .get('/shared-link/me') + .get('/shared-links/me') .query({ key: linkWithPassword.key, password: 'foo' }); expect(status).toBe(200); @@ -209,7 +209,7 @@ describe('/shared-link', () => { }); it('should return metadata for album shared link', async () => { - const { status, body } = await request(app).get('/shared-link/me').query({ key: linkWithMetadata.key }); + const { status, body } = await request(app).get('/shared-links/me').query({ key: linkWithMetadata.key }); expect(status).toBe(200); expect(body.assets).toHaveLength(1); @@ -225,7 +225,7 @@ describe('/shared-link', () => { }); it('should not return metadata for album shared link without metadata', async () => { - const { status, body } = await request(app).get('/shared-link/me').query({ key: linkWithoutMetadata.key }); + const { status, body } = await request(app).get('/shared-links/me').query({ key: linkWithoutMetadata.key }); expect(status).toBe(200); expect(body.assets).toHaveLength(1); @@ -239,9 +239,9 @@ describe('/shared-link', () => { }); }); - describe('GET /shared-link/:id', () => { + describe('GET /shared-links/:id', () => { it('should require authentication', async () => { - const { status, body } = await request(app).get(`/shared-link/${linkWithAlbum.id}`); + const { status, body } = await request(app).get(`/shared-links/${linkWithAlbum.id}`); expect(status).toBe(401); expect(body).toEqual(errorDto.unauthorized); @@ -249,7 +249,7 @@ describe('/shared-link', () => { it('should get shared link by id', async () => { const { status, body } = await request(app) - .get(`/shared-link/${linkWithAlbum.id}`) + .get(`/shared-links/${linkWithAlbum.id}`) .set('Authorization', `Bearer ${user1.accessToken}`); expect(status).toBe(200); @@ -264,7 +264,7 @@ describe('/shared-link', () => { it('should not get shared link by id if user has not created the link or it does not exist', async () => { const { status, body } = await request(app) - .get(`/shared-link/${linkWithAlbum.id}`) + .get(`/shared-links/${linkWithAlbum.id}`) .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(400); @@ -272,10 +272,10 @@ describe('/shared-link', () => { }); }); - describe('POST /shared-link', () => { + describe('POST /shared-links', () => { it('should require authentication', async () => { const { status, body } = await request(app) - .post('/shared-link') + .post('/shared-links') .send({ type: SharedLinkType.Album, albumId: uuidDto.notFound }); expect(status).toBe(401); @@ -284,7 +284,7 @@ describe('/shared-link', () => { it('should require a type and the correspondent asset/album id', async () => { const { status, body } = await request(app) - .post('/shared-link') + .post('/shared-links') .set('Authorization', `Bearer ${user1.accessToken}`); expect(status).toBe(400); @@ -293,7 +293,7 @@ describe('/shared-link', () => { it('should require an asset/album id', async () => { const { status, body } = await request(app) - .post('/shared-link') + .post('/shared-links') .set('Authorization', `Bearer ${user1.accessToken}`) .send({ type: SharedLinkType.Album }); @@ -303,7 +303,7 @@ describe('/shared-link', () => { it('should require a valid asset id', async () => { const { status, body } = await request(app) - .post('/shared-link') + .post('/shared-links') .set('Authorization', `Bearer ${user1.accessToken}`) .send({ type: SharedLinkType.Individual, assetId: uuidDto.notFound }); @@ -313,7 +313,7 @@ describe('/shared-link', () => { it('should create a shared link', async () => { const { status, body } = await request(app) - .post('/shared-link') + .post('/shared-links') .set('Authorization', `Bearer ${user1.accessToken}`) .send({ type: SharedLinkType.Album, albumId: album.id }); @@ -327,10 +327,10 @@ describe('/shared-link', () => { }); }); - describe('PATCH /shared-link/:id', () => { + describe('PATCH /shared-links/:id', () => { it('should require authentication', async () => { const { status, body } = await request(app) - .patch(`/shared-link/${linkWithAlbum.id}`) + .patch(`/shared-links/${linkWithAlbum.id}`) .send({ description: 'foo' }); expect(status).toBe(401); @@ -339,7 +339,7 @@ describe('/shared-link', () => { it('should fail if invalid link', async () => { const { status, body } = await request(app) - .patch(`/shared-link/${uuidDto.notFound}`) + .patch(`/shared-links/${uuidDto.notFound}`) .set('Authorization', `Bearer ${user1.accessToken}`) .send({ description: 'foo' }); @@ -349,7 +349,7 @@ describe('/shared-link', () => { it('should update shared link', async () => { const { status, body } = await request(app) - .patch(`/shared-link/${linkWithAlbum.id}`) + .patch(`/shared-links/${linkWithAlbum.id}`) .set('Authorization', `Bearer ${user1.accessToken}`) .send({ description: 'foo' }); @@ -364,10 +364,10 @@ describe('/shared-link', () => { }); }); - describe('PUT /shared-link/:id/assets', () => { + describe('PUT /shared-links/:id/assets', () => { it('should not add assets to shared link (album)', async () => { const { status, body } = await request(app) - .put(`/shared-link/${linkWithAlbum.id}/assets`) + .put(`/shared-links/${linkWithAlbum.id}/assets`) .set('Authorization', `Bearer ${user1.accessToken}`) .send({ assetIds: [asset2.id] }); @@ -377,7 +377,7 @@ describe('/shared-link', () => { it('should add an assets to a shared link (individual)', async () => { const { status, body } = await request(app) - .put(`/shared-link/${linkWithAssets.id}/assets`) + .put(`/shared-links/${linkWithAssets.id}/assets`) .set('Authorization', `Bearer ${user1.accessToken}`) .send({ assetIds: [asset2.id] }); @@ -386,10 +386,10 @@ describe('/shared-link', () => { }); }); - describe('DELETE /shared-link/:id/assets', () => { + describe('DELETE /shared-links/:id/assets', () => { it('should not remove assets from a shared link (album)', async () => { const { status, body } = await request(app) - .delete(`/shared-link/${linkWithAlbum.id}/assets`) + .delete(`/shared-links/${linkWithAlbum.id}/assets`) .set('Authorization', `Bearer ${user1.accessToken}`) .send({ assetIds: [asset2.id] }); @@ -399,7 +399,7 @@ describe('/shared-link', () => { it('should remove assets from a shared link (individual)', async () => { const { status, body } = await request(app) - .delete(`/shared-link/${linkWithAssets.id}/assets`) + .delete(`/shared-links/${linkWithAssets.id}/assets`) .set('Authorization', `Bearer ${user1.accessToken}`) .send({ assetIds: [asset2.id] }); @@ -408,9 +408,9 @@ describe('/shared-link', () => { }); }); - describe('DELETE /shared-link/:id', () => { + describe('DELETE /shared-links/:id', () => { it('should require authentication', async () => { - const { status, body } = await request(app).delete(`/shared-link/${linkWithAlbum.id}`); + const { status, body } = await request(app).delete(`/shared-links/${linkWithAlbum.id}`); expect(status).toBe(401); expect(body).toEqual(errorDto.unauthorized); @@ -418,7 +418,7 @@ describe('/shared-link', () => { it('should fail if invalid link', async () => { const { status, body } = await request(app) - .delete(`/shared-link/${uuidDto.notFound}`) + .delete(`/shared-links/${uuidDto.notFound}`) .set('Authorization', `Bearer ${user1.accessToken}`); expect(status).toBe(400); @@ -427,7 +427,7 @@ describe('/shared-link', () => { it('should delete a shared link', async () => { const { status } = await request(app) - .delete(`/shared-link/${linkWithAlbum.id}`) + .delete(`/shared-links/${linkWithAlbum.id}`) .set('Authorization', `Bearer ${user1.accessToken}`); expect(status).toBe(200); diff --git a/e2e/src/api/specs/user.e2e-spec.ts b/e2e/src/api/specs/user.e2e-spec.ts index 5de410606a..7518e732ec 100644 --- a/e2e/src/api/specs/user.e2e-spec.ts +++ b/e2e/src/api/specs/user.e2e-spec.ts @@ -6,7 +6,7 @@ import { app, asBearerAuth, utils } from 'src/utils'; import request from 'supertest'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -describe('/user', () => { +describe('/users', () => { let websocket: Socket; let admin: LoginResponseDto; @@ -34,15 +34,15 @@ describe('/user', () => { utils.disconnectWebsocket(websocket); }); - describe('GET /user', () => { + describe('GET /users', () => { it('should require authentication', async () => { - const { status, body } = await request(app).get('/user'); + const { status, body } = await request(app).get('/users'); expect(status).toBe(401); expect(body).toEqual(errorDto.unauthorized); }); it('should get users', async () => { - const { status, body } = await request(app).get('/user').set('Authorization', `Bearer ${admin.accessToken}`); + const { status, body } = await request(app).get('/users').set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toEqual(200); expect(body).toHaveLength(5); expect(body).toEqual( @@ -58,7 +58,7 @@ describe('/user', () => { it('should hide deleted users', async () => { const { status, body } = await request(app) - .get(`/user`) + .get(`/users`) .query({ isAll: true }) .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(200); @@ -75,7 +75,7 @@ describe('/user', () => { it('should include deleted users', async () => { const { status, body } = await request(app) - .get(`/user`) + .get(`/users`) .query({ isAll: false }) .set('Authorization', `Bearer ${admin.accessToken}`); @@ -93,15 +93,15 @@ describe('/user', () => { }); }); - describe('GET /user/info/:id', () => { + describe('GET /users/info/:id', () => { it('should require authentication', async () => { - const { status } = await request(app).get(`/user/info/${admin.userId}`); + const { status } = await request(app).get(`/users/info/${admin.userId}`); expect(status).toEqual(401); }); it('should get the user info', async () => { const { status, body } = await request(app) - .get(`/user/info/${admin.userId}`) + .get(`/users/info/${admin.userId}`) .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(200); expect(body).toMatchObject({ @@ -111,15 +111,15 @@ describe('/user', () => { }); }); - describe('GET /user/me', () => { + describe('GET /users/me', () => { it('should require authentication', async () => { - const { status, body } = await request(app).get(`/user/me`); + const { status, body } = await request(app).get(`/users/me`); expect(status).toBe(401); expect(body).toEqual(errorDto.unauthorized); }); it('should get my info', async () => { - const { status, body } = await request(app).get(`/user/me`).set('Authorization', `Bearer ${admin.accessToken}`); + const { status, body } = await request(app).get(`/users/me`).set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(200); expect(body).toMatchObject({ id: admin.userId, @@ -128,9 +128,9 @@ describe('/user', () => { }); }); - describe('POST /user', () => { + describe('POST /users', () => { it('should require authentication', async () => { - const { status, body } = await request(app).post(`/user`).send(createUserDto.user1); + const { status, body } = await request(app).post(`/users`).send(createUserDto.user1); expect(status).toBe(401); expect(body).toEqual(errorDto.unauthorized); }); @@ -138,7 +138,7 @@ describe('/user', () => { for (const key of Object.keys(createUserDto.user1)) { it(`should not allow null ${key}`, async () => { const { status, body } = await request(app) - .post(`/user`) + .post(`/users`) .set('Authorization', `Bearer ${admin.accessToken}`) .send({ ...createUserDto.user1, [key]: null }); expect(status).toBe(400); @@ -148,7 +148,7 @@ describe('/user', () => { it('should ignore `isAdmin`', async () => { const { status, body } = await request(app) - .post(`/user`) + .post(`/users`) .send({ isAdmin: true, email: 'user5@immich.cloud', @@ -166,7 +166,7 @@ describe('/user', () => { it('should create a user without memories enabled', async () => { const { status, body } = await request(app) - .post(`/user`) + .post(`/users`) .send({ email: 'no-memories@immich.cloud', password: 'Password123', @@ -182,16 +182,16 @@ describe('/user', () => { }); }); - describe('DELETE /user/:id', () => { + describe('DELETE /users/:id', () => { it('should require authentication', async () => { - const { status, body } = await request(app).delete(`/user/${userToDelete.userId}`); + const { status, body } = await request(app).delete(`/users/${userToDelete.userId}`); expect(status).toBe(401); expect(body).toEqual(errorDto.unauthorized); }); it('should delete user', async () => { const { status, body } = await request(app) - .delete(`/user/${userToDelete.userId}`) + .delete(`/users/${userToDelete.userId}`) .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(200); @@ -204,7 +204,7 @@ describe('/user', () => { it('should hard delete user', async () => { const { status, body } = await request(app) - .delete(`/user/${userToHardDelete.userId}`) + .delete(`/users/${userToHardDelete.userId}`) .send({ force: true }) .set('Authorization', `Bearer ${admin.accessToken}`); @@ -219,9 +219,9 @@ describe('/user', () => { }); }); - describe('PUT /user', () => { + describe('PUT /users', () => { it('should require authentication', async () => { - const { status, body } = await request(app).put(`/user`); + const { status, body } = await request(app).put(`/users`); expect(status).toBe(401); expect(body).toEqual(errorDto.unauthorized); }); @@ -229,7 +229,7 @@ describe('/user', () => { for (const key of Object.keys(userDto.admin)) { it(`should not allow null ${key}`, async () => { const { status, body } = await request(app) - .put(`/user`) + .put(`/users`) .set('Authorization', `Bearer ${admin.accessToken}`) .send({ ...userDto.admin, [key]: null }); expect(status).toBe(400); @@ -239,7 +239,7 @@ describe('/user', () => { it('should not allow a non-admin to become an admin', async () => { const { status, body } = await request(app) - .put(`/user`) + .put(`/users`) .send({ isAdmin: true, id: nonAdmin.userId }) .set('Authorization', `Bearer ${admin.accessToken}`); @@ -249,7 +249,7 @@ describe('/user', () => { it('ignores updates to profileImagePath', async () => { const { status, body } = await request(app) - .put(`/user`) + .put(`/users`) .send({ id: admin.userId, profileImagePath: 'invalid.jpg' }) .set('Authorization', `Bearer ${admin.accessToken}`); @@ -261,7 +261,7 @@ describe('/user', () => { const before = await getUserById({ id: admin.userId }, { headers: asBearerAuth(admin.accessToken) }); const { status, body } = await request(app) - .put(`/user`) + .put(`/users`) .send({ id: admin.userId, name: 'Name', @@ -280,7 +280,7 @@ describe('/user', () => { it('should update memories enabled', async () => { const before = await getUserById({ id: admin.userId }, { headers: asBearerAuth(admin.accessToken) }); const { status, body } = await request(app) - .put(`/user`) + .put(`/users`) .send({ id: admin.userId, memoriesEnabled: false, diff --git a/mobile/lib/utils/image_url_builder.dart b/mobile/lib/utils/image_url_builder.dart index f830aa39e2..5d5719313e 100644 --- a/mobile/lib/utils/image_url_builder.dart +++ b/mobile/lib/utils/image_url_builder.dart @@ -77,5 +77,5 @@ String getThumbnailUrlForRemoteId( } String getFaceThumbnailUrl(final String personId) { - return '${Store.get(StoreKey.serverEndpoint)}/person/$personId/thumbnail'; + return '${Store.get(StoreKey.serverEndpoint)}/people/$personId/thumbnail'; } diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 6055742602..e23b1fea76 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -73,26 +73,26 @@ All URIs are relative to */api* Class | Method | HTTP request | Description ------------ | ------------- | ------------- | ------------- -*APIKeyApi* | [**createApiKey**](doc//APIKeyApi.md#createapikey) | **POST** /api-key | -*APIKeyApi* | [**deleteApiKey**](doc//APIKeyApi.md#deleteapikey) | **DELETE** /api-key/{id} | -*APIKeyApi* | [**getApiKey**](doc//APIKeyApi.md#getapikey) | **GET** /api-key/{id} | -*APIKeyApi* | [**getApiKeys**](doc//APIKeyApi.md#getapikeys) | **GET** /api-key | -*APIKeyApi* | [**updateApiKey**](doc//APIKeyApi.md#updateapikey) | **PUT** /api-key/{id} | -*ActivityApi* | [**createActivity**](doc//ActivityApi.md#createactivity) | **POST** /activity | -*ActivityApi* | [**deleteActivity**](doc//ActivityApi.md#deleteactivity) | **DELETE** /activity/{id} | -*ActivityApi* | [**getActivities**](doc//ActivityApi.md#getactivities) | **GET** /activity | -*ActivityApi* | [**getActivityStatistics**](doc//ActivityApi.md#getactivitystatistics) | **GET** /activity/statistics | -*AlbumApi* | [**addAssetsToAlbum**](doc//AlbumApi.md#addassetstoalbum) | **PUT** /album/{id}/assets | -*AlbumApi* | [**addUsersToAlbum**](doc//AlbumApi.md#adduserstoalbum) | **PUT** /album/{id}/users | -*AlbumApi* | [**createAlbum**](doc//AlbumApi.md#createalbum) | **POST** /album | -*AlbumApi* | [**deleteAlbum**](doc//AlbumApi.md#deletealbum) | **DELETE** /album/{id} | -*AlbumApi* | [**getAlbumCount**](doc//AlbumApi.md#getalbumcount) | **GET** /album/count | -*AlbumApi* | [**getAlbumInfo**](doc//AlbumApi.md#getalbuminfo) | **GET** /album/{id} | -*AlbumApi* | [**getAllAlbums**](doc//AlbumApi.md#getallalbums) | **GET** /album | -*AlbumApi* | [**removeAssetFromAlbum**](doc//AlbumApi.md#removeassetfromalbum) | **DELETE** /album/{id}/assets | -*AlbumApi* | [**removeUserFromAlbum**](doc//AlbumApi.md#removeuserfromalbum) | **DELETE** /album/{id}/user/{userId} | -*AlbumApi* | [**updateAlbumInfo**](doc//AlbumApi.md#updatealbuminfo) | **PATCH** /album/{id} | -*AlbumApi* | [**updateAlbumUser**](doc//AlbumApi.md#updatealbumuser) | **PUT** /album/{id}/user/{userId} | +*APIKeyApi* | [**createApiKey**](doc//APIKeyApi.md#createapikey) | **POST** /api-keys | +*APIKeyApi* | [**deleteApiKey**](doc//APIKeyApi.md#deleteapikey) | **DELETE** /api-keys/{id} | +*APIKeyApi* | [**getApiKey**](doc//APIKeyApi.md#getapikey) | **GET** /api-keys/{id} | +*APIKeyApi* | [**getApiKeys**](doc//APIKeyApi.md#getapikeys) | **GET** /api-keys | +*APIKeyApi* | [**updateApiKey**](doc//APIKeyApi.md#updateapikey) | **PUT** /api-keys/{id} | +*ActivityApi* | [**createActivity**](doc//ActivityApi.md#createactivity) | **POST** /activities | +*ActivityApi* | [**deleteActivity**](doc//ActivityApi.md#deleteactivity) | **DELETE** /activities/{id} | +*ActivityApi* | [**getActivities**](doc//ActivityApi.md#getactivities) | **GET** /activities | +*ActivityApi* | [**getActivityStatistics**](doc//ActivityApi.md#getactivitystatistics) | **GET** /activities/statistics | +*AlbumApi* | [**addAssetsToAlbum**](doc//AlbumApi.md#addassetstoalbum) | **PUT** /albums/{id}/assets | +*AlbumApi* | [**addUsersToAlbum**](doc//AlbumApi.md#adduserstoalbum) | **PUT** /albums/{id}/users | +*AlbumApi* | [**createAlbum**](doc//AlbumApi.md#createalbum) | **POST** /albums | +*AlbumApi* | [**deleteAlbum**](doc//AlbumApi.md#deletealbum) | **DELETE** /albums/{id} | +*AlbumApi* | [**getAlbumCount**](doc//AlbumApi.md#getalbumcount) | **GET** /albums/count | +*AlbumApi* | [**getAlbumInfo**](doc//AlbumApi.md#getalbuminfo) | **GET** /albums/{id} | +*AlbumApi* | [**getAllAlbums**](doc//AlbumApi.md#getallalbums) | **GET** /albums | +*AlbumApi* | [**removeAssetFromAlbum**](doc//AlbumApi.md#removeassetfromalbum) | **DELETE** /albums/{id}/assets | +*AlbumApi* | [**removeUserFromAlbum**](doc//AlbumApi.md#removeuserfromalbum) | **DELETE** /albums/{id}/user/{userId} | +*AlbumApi* | [**updateAlbumInfo**](doc//AlbumApi.md#updatealbuminfo) | **PATCH** /albums/{id} | +*AlbumApi* | [**updateAlbumUser**](doc//AlbumApi.md#updatealbumuser) | **PUT** /albums/{id}/user/{userId} | *AssetApi* | [**checkBulkUpload**](doc//AssetApi.md#checkbulkupload) | **POST** /asset/bulk-upload-check | *AssetApi* | [**checkExistingAssets**](doc//AssetApi.md#checkexistingassets) | **POST** /asset/exist | *AssetApi* | [**deleteAssets**](doc//AssetApi.md#deleteassets) | **DELETE** /asset | @@ -121,22 +121,22 @@ Class | Method | HTTP request | Description *DownloadApi* | [**downloadFile**](doc//DownloadApi.md#downloadfile) | **POST** /download/asset/{id} | *DownloadApi* | [**getDownloadInfo**](doc//DownloadApi.md#getdownloadinfo) | **POST** /download/info | *DuplicateApi* | [**getAssetDuplicates**](doc//DuplicateApi.md#getassetduplicates) | **GET** /duplicates | -*FaceApi* | [**getFaces**](doc//FaceApi.md#getfaces) | **GET** /face | -*FaceApi* | [**reassignFacesById**](doc//FaceApi.md#reassignfacesbyid) | **PUT** /face/{id} | -*FileReportApi* | [**fixAuditFiles**](doc//FileReportApi.md#fixauditfiles) | **POST** /report/fix | -*FileReportApi* | [**getAuditFiles**](doc//FileReportApi.md#getauditfiles) | **GET** /report | -*FileReportApi* | [**getFileChecksums**](doc//FileReportApi.md#getfilechecksums) | **POST** /report/checksum | +*FaceApi* | [**getFaces**](doc//FaceApi.md#getfaces) | **GET** /faces | +*FaceApi* | [**reassignFacesById**](doc//FaceApi.md#reassignfacesbyid) | **PUT** /faces/{id} | +*FileReportApi* | [**fixAuditFiles**](doc//FileReportApi.md#fixauditfiles) | **POST** /reports/fix | +*FileReportApi* | [**getAuditFiles**](doc//FileReportApi.md#getauditfiles) | **GET** /reports | +*FileReportApi* | [**getFileChecksums**](doc//FileReportApi.md#getfilechecksums) | **POST** /reports/checksum | *JobApi* | [**getAllJobsStatus**](doc//JobApi.md#getalljobsstatus) | **GET** /jobs | *JobApi* | [**sendJobCommand**](doc//JobApi.md#sendjobcommand) | **PUT** /jobs/{id} | -*LibraryApi* | [**createLibrary**](doc//LibraryApi.md#createlibrary) | **POST** /library | -*LibraryApi* | [**deleteLibrary**](doc//LibraryApi.md#deletelibrary) | **DELETE** /library/{id} | -*LibraryApi* | [**getAllLibraries**](doc//LibraryApi.md#getalllibraries) | **GET** /library | -*LibraryApi* | [**getLibrary**](doc//LibraryApi.md#getlibrary) | **GET** /library/{id} | -*LibraryApi* | [**getLibraryStatistics**](doc//LibraryApi.md#getlibrarystatistics) | **GET** /library/{id}/statistics | -*LibraryApi* | [**removeOfflineFiles**](doc//LibraryApi.md#removeofflinefiles) | **POST** /library/{id}/removeOffline | -*LibraryApi* | [**scanLibrary**](doc//LibraryApi.md#scanlibrary) | **POST** /library/{id}/scan | -*LibraryApi* | [**updateLibrary**](doc//LibraryApi.md#updatelibrary) | **PUT** /library/{id} | -*LibraryApi* | [**validate**](doc//LibraryApi.md#validate) | **POST** /library/{id}/validate | +*LibraryApi* | [**createLibrary**](doc//LibraryApi.md#createlibrary) | **POST** /libraries | +*LibraryApi* | [**deleteLibrary**](doc//LibraryApi.md#deletelibrary) | **DELETE** /libraries/{id} | +*LibraryApi* | [**getAllLibraries**](doc//LibraryApi.md#getalllibraries) | **GET** /libraries | +*LibraryApi* | [**getLibrary**](doc//LibraryApi.md#getlibrary) | **GET** /libraries/{id} | +*LibraryApi* | [**getLibraryStatistics**](doc//LibraryApi.md#getlibrarystatistics) | **GET** /libraries/{id}/statistics | +*LibraryApi* | [**removeOfflineFiles**](doc//LibraryApi.md#removeofflinefiles) | **POST** /libraries/{id}/removeOffline | +*LibraryApi* | [**scanLibrary**](doc//LibraryApi.md#scanlibrary) | **POST** /libraries/{id}/scan | +*LibraryApi* | [**updateLibrary**](doc//LibraryApi.md#updatelibrary) | **PUT** /libraries/{id} | +*LibraryApi* | [**validate**](doc//LibraryApi.md#validate) | **POST** /libraries/{id}/validate | *MemoryApi* | [**addMemoryAssets**](doc//MemoryApi.md#addmemoryassets) | **PUT** /memories/{id}/assets | *MemoryApi* | [**createMemory**](doc//MemoryApi.md#creatememory) | **POST** /memories | *MemoryApi* | [**deleteMemory**](doc//MemoryApi.md#deletememory) | **DELETE** /memories/{id} | @@ -149,20 +149,20 @@ Class | Method | HTTP request | Description *OAuthApi* | [**redirectOAuthToMobile**](doc//OAuthApi.md#redirectoauthtomobile) | **GET** /oauth/mobile-redirect | *OAuthApi* | [**startOAuth**](doc//OAuthApi.md#startoauth) | **POST** /oauth/authorize | *OAuthApi* | [**unlinkOAuthAccount**](doc//OAuthApi.md#unlinkoauthaccount) | **POST** /oauth/unlink | -*PartnerApi* | [**createPartner**](doc//PartnerApi.md#createpartner) | **POST** /partner/{id} | -*PartnerApi* | [**getPartners**](doc//PartnerApi.md#getpartners) | **GET** /partner | -*PartnerApi* | [**removePartner**](doc//PartnerApi.md#removepartner) | **DELETE** /partner/{id} | -*PartnerApi* | [**updatePartner**](doc//PartnerApi.md#updatepartner) | **PUT** /partner/{id} | -*PersonApi* | [**createPerson**](doc//PersonApi.md#createperson) | **POST** /person | -*PersonApi* | [**getAllPeople**](doc//PersonApi.md#getallpeople) | **GET** /person | -*PersonApi* | [**getPerson**](doc//PersonApi.md#getperson) | **GET** /person/{id} | -*PersonApi* | [**getPersonAssets**](doc//PersonApi.md#getpersonassets) | **GET** /person/{id}/assets | -*PersonApi* | [**getPersonStatistics**](doc//PersonApi.md#getpersonstatistics) | **GET** /person/{id}/statistics | -*PersonApi* | [**getPersonThumbnail**](doc//PersonApi.md#getpersonthumbnail) | **GET** /person/{id}/thumbnail | -*PersonApi* | [**mergePerson**](doc//PersonApi.md#mergeperson) | **POST** /person/{id}/merge | -*PersonApi* | [**reassignFaces**](doc//PersonApi.md#reassignfaces) | **PUT** /person/{id}/reassign | -*PersonApi* | [**updatePeople**](doc//PersonApi.md#updatepeople) | **PUT** /person | -*PersonApi* | [**updatePerson**](doc//PersonApi.md#updateperson) | **PUT** /person/{id} | +*PartnerApi* | [**createPartner**](doc//PartnerApi.md#createpartner) | **POST** /partners/{id} | +*PartnerApi* | [**getPartners**](doc//PartnerApi.md#getpartners) | **GET** /partners | +*PartnerApi* | [**removePartner**](doc//PartnerApi.md#removepartner) | **DELETE** /partners/{id} | +*PartnerApi* | [**updatePartner**](doc//PartnerApi.md#updatepartner) | **PUT** /partners/{id} | +*PersonApi* | [**createPerson**](doc//PersonApi.md#createperson) | **POST** /people | +*PersonApi* | [**getAllPeople**](doc//PersonApi.md#getallpeople) | **GET** /people | +*PersonApi* | [**getPerson**](doc//PersonApi.md#getperson) | **GET** /people/{id} | +*PersonApi* | [**getPersonAssets**](doc//PersonApi.md#getpersonassets) | **GET** /people/{id}/assets | +*PersonApi* | [**getPersonStatistics**](doc//PersonApi.md#getpersonstatistics) | **GET** /people/{id}/statistics | +*PersonApi* | [**getPersonThumbnail**](doc//PersonApi.md#getpersonthumbnail) | **GET** /people/{id}/thumbnail | +*PersonApi* | [**mergePerson**](doc//PersonApi.md#mergeperson) | **POST** /people/{id}/merge | +*PersonApi* | [**reassignFaces**](doc//PersonApi.md#reassignfaces) | **PUT** /people/{id}/reassign | +*PersonApi* | [**updatePeople**](doc//PersonApi.md#updatepeople) | **PUT** /people | +*PersonApi* | [**updatePerson**](doc//PersonApi.md#updateperson) | **PUT** /people/{id} | *SearchApi* | [**getAssetsByCity**](doc//SearchApi.md#getassetsbycity) | **GET** /search/cities | *SearchApi* | [**getExploreData**](doc//SearchApi.md#getexploredata) | **GET** /search/explore | *SearchApi* | [**getSearchSuggestions**](doc//SearchApi.md#getsearchsuggestions) | **GET** /search/suggestions | @@ -182,14 +182,14 @@ Class | Method | HTTP request | Description *SessionsApi* | [**deleteAllSessions**](doc//SessionsApi.md#deleteallsessions) | **DELETE** /sessions | *SessionsApi* | [**deleteSession**](doc//SessionsApi.md#deletesession) | **DELETE** /sessions/{id} | *SessionsApi* | [**getSessions**](doc//SessionsApi.md#getsessions) | **GET** /sessions | -*SharedLinkApi* | [**addSharedLinkAssets**](doc//SharedLinkApi.md#addsharedlinkassets) | **PUT** /shared-link/{id}/assets | -*SharedLinkApi* | [**createSharedLink**](doc//SharedLinkApi.md#createsharedlink) | **POST** /shared-link | -*SharedLinkApi* | [**getAllSharedLinks**](doc//SharedLinkApi.md#getallsharedlinks) | **GET** /shared-link | -*SharedLinkApi* | [**getMySharedLink**](doc//SharedLinkApi.md#getmysharedlink) | **GET** /shared-link/me | -*SharedLinkApi* | [**getSharedLinkById**](doc//SharedLinkApi.md#getsharedlinkbyid) | **GET** /shared-link/{id} | -*SharedLinkApi* | [**removeSharedLink**](doc//SharedLinkApi.md#removesharedlink) | **DELETE** /shared-link/{id} | -*SharedLinkApi* | [**removeSharedLinkAssets**](doc//SharedLinkApi.md#removesharedlinkassets) | **DELETE** /shared-link/{id}/assets | -*SharedLinkApi* | [**updateSharedLink**](doc//SharedLinkApi.md#updatesharedlink) | **PATCH** /shared-link/{id} | +*SharedLinkApi* | [**addSharedLinkAssets**](doc//SharedLinkApi.md#addsharedlinkassets) | **PUT** /shared-links/{id}/assets | +*SharedLinkApi* | [**createSharedLink**](doc//SharedLinkApi.md#createsharedlink) | **POST** /shared-links | +*SharedLinkApi* | [**getAllSharedLinks**](doc//SharedLinkApi.md#getallsharedlinks) | **GET** /shared-links | +*SharedLinkApi* | [**getMySharedLink**](doc//SharedLinkApi.md#getmysharedlink) | **GET** /shared-links/me | +*SharedLinkApi* | [**getSharedLinkById**](doc//SharedLinkApi.md#getsharedlinkbyid) | **GET** /shared-links/{id} | +*SharedLinkApi* | [**removeSharedLink**](doc//SharedLinkApi.md#removesharedlink) | **DELETE** /shared-links/{id} | +*SharedLinkApi* | [**removeSharedLinkAssets**](doc//SharedLinkApi.md#removesharedlinkassets) | **DELETE** /shared-links/{id}/assets | +*SharedLinkApi* | [**updateSharedLink**](doc//SharedLinkApi.md#updatesharedlink) | **PATCH** /shared-links/{id} | *SyncApi* | [**getDeltaSync**](doc//SyncApi.md#getdeltasync) | **POST** /sync/delta-sync | *SyncApi* | [**getFullSyncForUser**](doc//SyncApi.md#getfullsyncforuser) | **POST** /sync/full-sync | *SystemConfigApi* | [**getConfig**](doc//SystemConfigApi.md#getconfig) | **GET** /system-config | @@ -200,29 +200,29 @@ Class | Method | HTTP request | Description *SystemMetadataApi* | [**getAdminOnboarding**](doc//SystemMetadataApi.md#getadminonboarding) | **GET** /system-metadata/admin-onboarding | *SystemMetadataApi* | [**getReverseGeocodingState**](doc//SystemMetadataApi.md#getreversegeocodingstate) | **GET** /system-metadata/reverse-geocoding-state | *SystemMetadataApi* | [**updateAdminOnboarding**](doc//SystemMetadataApi.md#updateadminonboarding) | **POST** /system-metadata/admin-onboarding | -*TagApi* | [**createTag**](doc//TagApi.md#createtag) | **POST** /tag | -*TagApi* | [**deleteTag**](doc//TagApi.md#deletetag) | **DELETE** /tag/{id} | -*TagApi* | [**getAllTags**](doc//TagApi.md#getalltags) | **GET** /tag | -*TagApi* | [**getTagAssets**](doc//TagApi.md#gettagassets) | **GET** /tag/{id}/assets | -*TagApi* | [**getTagById**](doc//TagApi.md#gettagbyid) | **GET** /tag/{id} | -*TagApi* | [**tagAssets**](doc//TagApi.md#tagassets) | **PUT** /tag/{id}/assets | -*TagApi* | [**untagAssets**](doc//TagApi.md#untagassets) | **DELETE** /tag/{id}/assets | -*TagApi* | [**updateTag**](doc//TagApi.md#updatetag) | **PATCH** /tag/{id} | +*TagApi* | [**createTag**](doc//TagApi.md#createtag) | **POST** /tags | +*TagApi* | [**deleteTag**](doc//TagApi.md#deletetag) | **DELETE** /tags/{id} | +*TagApi* | [**getAllTags**](doc//TagApi.md#getalltags) | **GET** /tags | +*TagApi* | [**getTagAssets**](doc//TagApi.md#gettagassets) | **GET** /tags/{id}/assets | +*TagApi* | [**getTagById**](doc//TagApi.md#gettagbyid) | **GET** /tags/{id} | +*TagApi* | [**tagAssets**](doc//TagApi.md#tagassets) | **PUT** /tags/{id}/assets | +*TagApi* | [**untagAssets**](doc//TagApi.md#untagassets) | **DELETE** /tags/{id}/assets | +*TagApi* | [**updateTag**](doc//TagApi.md#updatetag) | **PATCH** /tags/{id} | *TimelineApi* | [**getTimeBucket**](doc//TimelineApi.md#gettimebucket) | **GET** /timeline/bucket | *TimelineApi* | [**getTimeBuckets**](doc//TimelineApi.md#gettimebuckets) | **GET** /timeline/buckets | *TrashApi* | [**emptyTrash**](doc//TrashApi.md#emptytrash) | **POST** /trash/empty | *TrashApi* | [**restoreAssets**](doc//TrashApi.md#restoreassets) | **POST** /trash/restore/assets | *TrashApi* | [**restoreTrash**](doc//TrashApi.md#restoretrash) | **POST** /trash/restore | -*UserApi* | [**createProfileImage**](doc//UserApi.md#createprofileimage) | **POST** /user/profile-image | -*UserApi* | [**createUser**](doc//UserApi.md#createuser) | **POST** /user | -*UserApi* | [**deleteProfileImage**](doc//UserApi.md#deleteprofileimage) | **DELETE** /user/profile-image | -*UserApi* | [**deleteUser**](doc//UserApi.md#deleteuser) | **DELETE** /user/{id} | -*UserApi* | [**getAllUsers**](doc//UserApi.md#getallusers) | **GET** /user | -*UserApi* | [**getMyUserInfo**](doc//UserApi.md#getmyuserinfo) | **GET** /user/me | -*UserApi* | [**getProfileImage**](doc//UserApi.md#getprofileimage) | **GET** /user/profile-image/{id} | -*UserApi* | [**getUserById**](doc//UserApi.md#getuserbyid) | **GET** /user/info/{id} | -*UserApi* | [**restoreUser**](doc//UserApi.md#restoreuser) | **POST** /user/{id}/restore | -*UserApi* | [**updateUser**](doc//UserApi.md#updateuser) | **PUT** /user | +*UserApi* | [**createProfileImage**](doc//UserApi.md#createprofileimage) | **POST** /users/profile-image | +*UserApi* | [**createUser**](doc//UserApi.md#createuser) | **POST** /users | +*UserApi* | [**deleteProfileImage**](doc//UserApi.md#deleteprofileimage) | **DELETE** /users/profile-image | +*UserApi* | [**deleteUser**](doc//UserApi.md#deleteuser) | **DELETE** /users/{id} | +*UserApi* | [**getAllUsers**](doc//UserApi.md#getallusers) | **GET** /users | +*UserApi* | [**getMyUserInfo**](doc//UserApi.md#getmyuserinfo) | **GET** /users/me | +*UserApi* | [**getProfileImage**](doc//UserApi.md#getprofileimage) | **GET** /users/profile-image/{id} | +*UserApi* | [**getUserById**](doc//UserApi.md#getuserbyid) | **GET** /users/info/{id} | +*UserApi* | [**restoreUser**](doc//UserApi.md#restoreuser) | **POST** /users/{id}/restore | +*UserApi* | [**updateUser**](doc//UserApi.md#updateuser) | **PUT** /users | ## Documentation For Models diff --git a/mobile/openapi/lib/api/activity_api.dart b/mobile/openapi/lib/api/activity_api.dart index bf2c168fc9..52dceadc72 100644 --- a/mobile/openapi/lib/api/activity_api.dart +++ b/mobile/openapi/lib/api/activity_api.dart @@ -16,13 +16,13 @@ class ActivityApi { final ApiClient apiClient; - /// Performs an HTTP 'POST /activity' operation and returns the [Response]. + /// Performs an HTTP 'POST /activities' operation and returns the [Response]. /// Parameters: /// /// * [ActivityCreateDto] activityCreateDto (required): Future createActivityWithHttpInfo(ActivityCreateDto activityCreateDto,) async { // ignore: prefer_const_declarations - final path = r'/activity'; + final path = r'/activities'; // ignore: prefer_final_locals Object? postBody = activityCreateDto; @@ -63,13 +63,13 @@ class ActivityApi { return null; } - /// Performs an HTTP 'DELETE /activity/{id}' operation and returns the [Response]. + /// Performs an HTTP 'DELETE /activities/{id}' operation and returns the [Response]. /// Parameters: /// /// * [String] id (required): Future deleteActivityWithHttpInfo(String id,) async { // ignore: prefer_const_declarations - final path = r'/activity/{id}' + final path = r'/activities/{id}' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -103,7 +103,7 @@ class ActivityApi { } } - /// Performs an HTTP 'GET /activity' operation and returns the [Response]. + /// Performs an HTTP 'GET /activities' operation and returns the [Response]. /// Parameters: /// /// * [String] albumId (required): @@ -117,7 +117,7 @@ class ActivityApi { /// * [String] userId: Future getActivitiesWithHttpInfo(String albumId, { String? assetId, ReactionLevel? level, ReactionType? type, String? userId, }) async { // ignore: prefer_const_declarations - final path = r'/activity'; + final path = r'/activities'; // ignore: prefer_final_locals Object? postBody; @@ -183,7 +183,7 @@ class ActivityApi { return null; } - /// Performs an HTTP 'GET /activity/statistics' operation and returns the [Response]. + /// Performs an HTTP 'GET /activities/statistics' operation and returns the [Response]. /// Parameters: /// /// * [String] albumId (required): @@ -191,7 +191,7 @@ class ActivityApi { /// * [String] assetId: Future getActivityStatisticsWithHttpInfo(String albumId, { String? assetId, }) async { // ignore: prefer_const_declarations - final path = r'/activity/statistics'; + final path = r'/activities/statistics'; // ignore: prefer_final_locals Object? postBody; diff --git a/mobile/openapi/lib/api/album_api.dart b/mobile/openapi/lib/api/album_api.dart index 52b3e466b4..dbc8648a37 100644 --- a/mobile/openapi/lib/api/album_api.dart +++ b/mobile/openapi/lib/api/album_api.dart @@ -16,7 +16,7 @@ class AlbumApi { final ApiClient apiClient; - /// Performs an HTTP 'PUT /album/{id}/assets' operation and returns the [Response]. + /// Performs an HTTP 'PUT /albums/{id}/assets' operation and returns the [Response]. /// Parameters: /// /// * [String] id (required): @@ -26,7 +26,7 @@ class AlbumApi { /// * [String] key: Future addAssetsToAlbumWithHttpInfo(String id, BulkIdsDto bulkIdsDto, { String? key, }) async { // ignore: prefer_const_declarations - final path = r'/album/{id}/assets' + final path = r'/albums/{id}/assets' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -79,7 +79,7 @@ class AlbumApi { return null; } - /// Performs an HTTP 'PUT /album/{id}/users' operation and returns the [Response]. + /// Performs an HTTP 'PUT /albums/{id}/users' operation and returns the [Response]. /// Parameters: /// /// * [String] id (required): @@ -87,7 +87,7 @@ class AlbumApi { /// * [AddUsersDto] addUsersDto (required): Future addUsersToAlbumWithHttpInfo(String id, AddUsersDto addUsersDto,) async { // ignore: prefer_const_declarations - final path = r'/album/{id}/users' + final path = r'/albums/{id}/users' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -131,13 +131,13 @@ class AlbumApi { return null; } - /// Performs an HTTP 'POST /album' operation and returns the [Response]. + /// Performs an HTTP 'POST /albums' operation and returns the [Response]. /// Parameters: /// /// * [CreateAlbumDto] createAlbumDto (required): Future createAlbumWithHttpInfo(CreateAlbumDto createAlbumDto,) async { // ignore: prefer_const_declarations - final path = r'/album'; + final path = r'/albums'; // ignore: prefer_final_locals Object? postBody = createAlbumDto; @@ -178,13 +178,13 @@ class AlbumApi { return null; } - /// Performs an HTTP 'DELETE /album/{id}' operation and returns the [Response]. + /// Performs an HTTP 'DELETE /albums/{id}' operation and returns the [Response]. /// Parameters: /// /// * [String] id (required): Future deleteAlbumWithHttpInfo(String id,) async { // ignore: prefer_const_declarations - final path = r'/album/{id}' + final path = r'/albums/{id}' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -218,10 +218,10 @@ class AlbumApi { } } - /// Performs an HTTP 'GET /album/count' operation and returns the [Response]. + /// Performs an HTTP 'GET /albums/count' operation and returns the [Response]. Future getAlbumCountWithHttpInfo() async { // ignore: prefer_const_declarations - final path = r'/album/count'; + final path = r'/albums/count'; // ignore: prefer_final_locals Object? postBody; @@ -259,7 +259,7 @@ class AlbumApi { return null; } - /// Performs an HTTP 'GET /album/{id}' operation and returns the [Response]. + /// Performs an HTTP 'GET /albums/{id}' operation and returns the [Response]. /// Parameters: /// /// * [String] id (required): @@ -269,7 +269,7 @@ class AlbumApi { /// * [bool] withoutAssets: Future getAlbumInfoWithHttpInfo(String id, { String? key, bool? withoutAssets, }) async { // ignore: prefer_const_declarations - final path = r'/album/{id}' + final path = r'/albums/{id}' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -322,7 +322,7 @@ class AlbumApi { return null; } - /// Performs an HTTP 'GET /album' operation and returns the [Response]. + /// Performs an HTTP 'GET /albums' operation and returns the [Response]. /// Parameters: /// /// * [String] assetId: @@ -331,7 +331,7 @@ class AlbumApi { /// * [bool] shared: Future getAllAlbumsWithHttpInfo({ String? assetId, bool? shared, }) async { // ignore: prefer_const_declarations - final path = r'/album'; + final path = r'/albums'; // ignore: prefer_final_locals Object? postBody; @@ -385,7 +385,7 @@ class AlbumApi { return null; } - /// Performs an HTTP 'DELETE /album/{id}/assets' operation and returns the [Response]. + /// Performs an HTTP 'DELETE /albums/{id}/assets' operation and returns the [Response]. /// Parameters: /// /// * [String] id (required): @@ -393,7 +393,7 @@ class AlbumApi { /// * [BulkIdsDto] bulkIdsDto (required): Future removeAssetFromAlbumWithHttpInfo(String id, BulkIdsDto bulkIdsDto,) async { // ignore: prefer_const_declarations - final path = r'/album/{id}/assets' + final path = r'/albums/{id}/assets' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -440,7 +440,7 @@ class AlbumApi { return null; } - /// Performs an HTTP 'DELETE /album/{id}/user/{userId}' operation and returns the [Response]. + /// Performs an HTTP 'DELETE /albums/{id}/user/{userId}' operation and returns the [Response]. /// Parameters: /// /// * [String] id (required): @@ -448,7 +448,7 @@ class AlbumApi { /// * [String] userId (required): Future removeUserFromAlbumWithHttpInfo(String id, String userId,) async { // ignore: prefer_const_declarations - final path = r'/album/{id}/user/{userId}' + final path = r'/albums/{id}/user/{userId}' .replaceAll('{id}', id) .replaceAll('{userId}', userId); @@ -485,7 +485,7 @@ class AlbumApi { } } - /// Performs an HTTP 'PATCH /album/{id}' operation and returns the [Response]. + /// Performs an HTTP 'PATCH /albums/{id}' operation and returns the [Response]. /// Parameters: /// /// * [String] id (required): @@ -493,7 +493,7 @@ class AlbumApi { /// * [UpdateAlbumDto] updateAlbumDto (required): Future updateAlbumInfoWithHttpInfo(String id, UpdateAlbumDto updateAlbumDto,) async { // ignore: prefer_const_declarations - final path = r'/album/{id}' + final path = r'/albums/{id}' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -537,7 +537,7 @@ class AlbumApi { return null; } - /// Performs an HTTP 'PUT /album/{id}/user/{userId}' operation and returns the [Response]. + /// Performs an HTTP 'PUT /albums/{id}/user/{userId}' operation and returns the [Response]. /// Parameters: /// /// * [String] id (required): @@ -547,7 +547,7 @@ class AlbumApi { /// * [UpdateAlbumUserDto] updateAlbumUserDto (required): Future updateAlbumUserWithHttpInfo(String id, String userId, UpdateAlbumUserDto updateAlbumUserDto,) async { // ignore: prefer_const_declarations - final path = r'/album/{id}/user/{userId}' + final path = r'/albums/{id}/user/{userId}' .replaceAll('{id}', id) .replaceAll('{userId}', userId); diff --git a/mobile/openapi/lib/api/api_key_api.dart b/mobile/openapi/lib/api/api_key_api.dart index 43cb233114..03c6605706 100644 --- a/mobile/openapi/lib/api/api_key_api.dart +++ b/mobile/openapi/lib/api/api_key_api.dart @@ -16,13 +16,13 @@ class APIKeyApi { final ApiClient apiClient; - /// Performs an HTTP 'POST /api-key' operation and returns the [Response]. + /// Performs an HTTP 'POST /api-keys' operation and returns the [Response]. /// Parameters: /// /// * [APIKeyCreateDto] aPIKeyCreateDto (required): Future createApiKeyWithHttpInfo(APIKeyCreateDto aPIKeyCreateDto,) async { // ignore: prefer_const_declarations - final path = r'/api-key'; + final path = r'/api-keys'; // ignore: prefer_final_locals Object? postBody = aPIKeyCreateDto; @@ -63,13 +63,13 @@ class APIKeyApi { return null; } - /// Performs an HTTP 'DELETE /api-key/{id}' operation and returns the [Response]. + /// Performs an HTTP 'DELETE /api-keys/{id}' operation and returns the [Response]. /// Parameters: /// /// * [String] id (required): Future deleteApiKeyWithHttpInfo(String id,) async { // ignore: prefer_const_declarations - final path = r'/api-key/{id}' + final path = r'/api-keys/{id}' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -103,13 +103,13 @@ class APIKeyApi { } } - /// Performs an HTTP 'GET /api-key/{id}' operation and returns the [Response]. + /// Performs an HTTP 'GET /api-keys/{id}' operation and returns the [Response]. /// Parameters: /// /// * [String] id (required): Future getApiKeyWithHttpInfo(String id,) async { // ignore: prefer_const_declarations - final path = r'/api-key/{id}' + final path = r'/api-keys/{id}' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -151,10 +151,10 @@ class APIKeyApi { return null; } - /// Performs an HTTP 'GET /api-key' operation and returns the [Response]. + /// Performs an HTTP 'GET /api-keys' operation and returns the [Response]. Future getApiKeysWithHttpInfo() async { // ignore: prefer_const_declarations - final path = r'/api-key'; + final path = r'/api-keys'; // ignore: prefer_final_locals Object? postBody; @@ -195,7 +195,7 @@ class APIKeyApi { return null; } - /// Performs an HTTP 'PUT /api-key/{id}' operation and returns the [Response]. + /// Performs an HTTP 'PUT /api-keys/{id}' operation and returns the [Response]. /// Parameters: /// /// * [String] id (required): @@ -203,7 +203,7 @@ class APIKeyApi { /// * [APIKeyUpdateDto] aPIKeyUpdateDto (required): Future updateApiKeyWithHttpInfo(String id, APIKeyUpdateDto aPIKeyUpdateDto,) async { // ignore: prefer_const_declarations - final path = r'/api-key/{id}' + final path = r'/api-keys/{id}' .replaceAll('{id}', id); // ignore: prefer_final_locals diff --git a/mobile/openapi/lib/api/face_api.dart b/mobile/openapi/lib/api/face_api.dart index 5d21a223a1..cf37c30197 100644 --- a/mobile/openapi/lib/api/face_api.dart +++ b/mobile/openapi/lib/api/face_api.dart @@ -16,13 +16,13 @@ class FaceApi { final ApiClient apiClient; - /// Performs an HTTP 'GET /face' operation and returns the [Response]. + /// Performs an HTTP 'GET /faces' operation and returns the [Response]. /// Parameters: /// /// * [String] id (required): Future getFacesWithHttpInfo(String id,) async { // ignore: prefer_const_declarations - final path = r'/face'; + final path = r'/faces'; // ignore: prefer_final_locals Object? postBody; @@ -68,7 +68,7 @@ class FaceApi { return null; } - /// Performs an HTTP 'PUT /face/{id}' operation and returns the [Response]. + /// Performs an HTTP 'PUT /faces/{id}' operation and returns the [Response]. /// Parameters: /// /// * [String] id (required): @@ -76,7 +76,7 @@ class FaceApi { /// * [FaceDto] faceDto (required): Future reassignFacesByIdWithHttpInfo(String id, FaceDto faceDto,) async { // ignore: prefer_const_declarations - final path = r'/face/{id}' + final path = r'/faces/{id}' .replaceAll('{id}', id); // ignore: prefer_final_locals diff --git a/mobile/openapi/lib/api/file_report_api.dart b/mobile/openapi/lib/api/file_report_api.dart index 4919dfeaf1..a52f02d43b 100644 --- a/mobile/openapi/lib/api/file_report_api.dart +++ b/mobile/openapi/lib/api/file_report_api.dart @@ -16,13 +16,13 @@ class FileReportApi { final ApiClient apiClient; - /// Performs an HTTP 'POST /report/fix' operation and returns the [Response]. + /// Performs an HTTP 'POST /reports/fix' operation and returns the [Response]. /// Parameters: /// /// * [FileReportFixDto] fileReportFixDto (required): Future fixAuditFilesWithHttpInfo(FileReportFixDto fileReportFixDto,) async { // ignore: prefer_const_declarations - final path = r'/report/fix'; + final path = r'/reports/fix'; // ignore: prefer_final_locals Object? postBody = fileReportFixDto; @@ -55,10 +55,10 @@ class FileReportApi { } } - /// Performs an HTTP 'GET /report' operation and returns the [Response]. + /// Performs an HTTP 'GET /reports' operation and returns the [Response]. Future getAuditFilesWithHttpInfo() async { // ignore: prefer_const_declarations - final path = r'/report'; + final path = r'/reports'; // ignore: prefer_final_locals Object? postBody; @@ -96,13 +96,13 @@ class FileReportApi { return null; } - /// Performs an HTTP 'POST /report/checksum' operation and returns the [Response]. + /// Performs an HTTP 'POST /reports/checksum' operation and returns the [Response]. /// Parameters: /// /// * [FileChecksumDto] fileChecksumDto (required): Future getFileChecksumsWithHttpInfo(FileChecksumDto fileChecksumDto,) async { // ignore: prefer_const_declarations - final path = r'/report/checksum'; + final path = r'/reports/checksum'; // ignore: prefer_final_locals Object? postBody = fileChecksumDto; diff --git a/mobile/openapi/lib/api/library_api.dart b/mobile/openapi/lib/api/library_api.dart index 48f46e6e1b..e634dae836 100644 --- a/mobile/openapi/lib/api/library_api.dart +++ b/mobile/openapi/lib/api/library_api.dart @@ -16,13 +16,13 @@ class LibraryApi { final ApiClient apiClient; - /// Performs an HTTP 'POST /library' operation and returns the [Response]. + /// Performs an HTTP 'POST /libraries' operation and returns the [Response]. /// Parameters: /// /// * [CreateLibraryDto] createLibraryDto (required): Future createLibraryWithHttpInfo(CreateLibraryDto createLibraryDto,) async { // ignore: prefer_const_declarations - final path = r'/library'; + final path = r'/libraries'; // ignore: prefer_final_locals Object? postBody = createLibraryDto; @@ -63,13 +63,13 @@ class LibraryApi { return null; } - /// Performs an HTTP 'DELETE /library/{id}' operation and returns the [Response]. + /// Performs an HTTP 'DELETE /libraries/{id}' operation and returns the [Response]. /// Parameters: /// /// * [String] id (required): Future deleteLibraryWithHttpInfo(String id,) async { // ignore: prefer_const_declarations - final path = r'/library/{id}' + final path = r'/libraries/{id}' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -103,10 +103,10 @@ class LibraryApi { } } - /// Performs an HTTP 'GET /library' operation and returns the [Response]. + /// Performs an HTTP 'GET /libraries' operation and returns the [Response]. Future getAllLibrariesWithHttpInfo() async { // ignore: prefer_const_declarations - final path = r'/library'; + final path = r'/libraries'; // ignore: prefer_final_locals Object? postBody; @@ -147,13 +147,13 @@ class LibraryApi { return null; } - /// Performs an HTTP 'GET /library/{id}' operation and returns the [Response]. + /// Performs an HTTP 'GET /libraries/{id}' operation and returns the [Response]. /// Parameters: /// /// * [String] id (required): Future getLibraryWithHttpInfo(String id,) async { // ignore: prefer_const_declarations - final path = r'/library/{id}' + final path = r'/libraries/{id}' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -195,13 +195,13 @@ class LibraryApi { return null; } - /// Performs an HTTP 'GET /library/{id}/statistics' operation and returns the [Response]. + /// Performs an HTTP 'GET /libraries/{id}/statistics' operation and returns the [Response]. /// Parameters: /// /// * [String] id (required): Future getLibraryStatisticsWithHttpInfo(String id,) async { // ignore: prefer_const_declarations - final path = r'/library/{id}/statistics' + final path = r'/libraries/{id}/statistics' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -243,13 +243,13 @@ class LibraryApi { return null; } - /// Performs an HTTP 'POST /library/{id}/removeOffline' operation and returns the [Response]. + /// Performs an HTTP 'POST /libraries/{id}/removeOffline' operation and returns the [Response]. /// Parameters: /// /// * [String] id (required): Future removeOfflineFilesWithHttpInfo(String id,) async { // ignore: prefer_const_declarations - final path = r'/library/{id}/removeOffline' + final path = r'/libraries/{id}/removeOffline' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -283,7 +283,7 @@ class LibraryApi { } } - /// Performs an HTTP 'POST /library/{id}/scan' operation and returns the [Response]. + /// Performs an HTTP 'POST /libraries/{id}/scan' operation and returns the [Response]. /// Parameters: /// /// * [String] id (required): @@ -291,7 +291,7 @@ class LibraryApi { /// * [ScanLibraryDto] scanLibraryDto (required): Future scanLibraryWithHttpInfo(String id, ScanLibraryDto scanLibraryDto,) async { // ignore: prefer_const_declarations - final path = r'/library/{id}/scan' + final path = r'/libraries/{id}/scan' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -327,7 +327,7 @@ class LibraryApi { } } - /// Performs an HTTP 'PUT /library/{id}' operation and returns the [Response]. + /// Performs an HTTP 'PUT /libraries/{id}' operation and returns the [Response]. /// Parameters: /// /// * [String] id (required): @@ -335,7 +335,7 @@ class LibraryApi { /// * [UpdateLibraryDto] updateLibraryDto (required): Future updateLibraryWithHttpInfo(String id, UpdateLibraryDto updateLibraryDto,) async { // ignore: prefer_const_declarations - final path = r'/library/{id}' + final path = r'/libraries/{id}' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -379,7 +379,7 @@ class LibraryApi { return null; } - /// Performs an HTTP 'POST /library/{id}/validate' operation and returns the [Response]. + /// Performs an HTTP 'POST /libraries/{id}/validate' operation and returns the [Response]. /// Parameters: /// /// * [String] id (required): @@ -387,7 +387,7 @@ class LibraryApi { /// * [ValidateLibraryDto] validateLibraryDto (required): Future validateWithHttpInfo(String id, ValidateLibraryDto validateLibraryDto,) async { // ignore: prefer_const_declarations - final path = r'/library/{id}/validate' + final path = r'/libraries/{id}/validate' .replaceAll('{id}', id); // ignore: prefer_final_locals diff --git a/mobile/openapi/lib/api/partner_api.dart b/mobile/openapi/lib/api/partner_api.dart index 6dac6286b1..66ec2b089b 100644 --- a/mobile/openapi/lib/api/partner_api.dart +++ b/mobile/openapi/lib/api/partner_api.dart @@ -16,13 +16,13 @@ class PartnerApi { final ApiClient apiClient; - /// Performs an HTTP 'POST /partner/{id}' operation and returns the [Response]. + /// Performs an HTTP 'POST /partners/{id}' operation and returns the [Response]. /// Parameters: /// /// * [String] id (required): Future createPartnerWithHttpInfo(String id,) async { // ignore: prefer_const_declarations - final path = r'/partner/{id}' + final path = r'/partners/{id}' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -64,13 +64,13 @@ class PartnerApi { return null; } - /// Performs an HTTP 'GET /partner' operation and returns the [Response]. + /// Performs an HTTP 'GET /partners' operation and returns the [Response]. /// Parameters: /// /// * [String] direction (required): Future getPartnersWithHttpInfo(String direction,) async { // ignore: prefer_const_declarations - final path = r'/partner'; + final path = r'/partners'; // ignore: prefer_final_locals Object? postBody; @@ -116,13 +116,13 @@ class PartnerApi { return null; } - /// Performs an HTTP 'DELETE /partner/{id}' operation and returns the [Response]. + /// Performs an HTTP 'DELETE /partners/{id}' operation and returns the [Response]. /// Parameters: /// /// * [String] id (required): Future removePartnerWithHttpInfo(String id,) async { // ignore: prefer_const_declarations - final path = r'/partner/{id}' + final path = r'/partners/{id}' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -156,7 +156,7 @@ class PartnerApi { } } - /// Performs an HTTP 'PUT /partner/{id}' operation and returns the [Response]. + /// Performs an HTTP 'PUT /partners/{id}' operation and returns the [Response]. /// Parameters: /// /// * [String] id (required): @@ -164,7 +164,7 @@ class PartnerApi { /// * [UpdatePartnerDto] updatePartnerDto (required): Future updatePartnerWithHttpInfo(String id, UpdatePartnerDto updatePartnerDto,) async { // ignore: prefer_const_declarations - final path = r'/partner/{id}' + final path = r'/partners/{id}' .replaceAll('{id}', id); // ignore: prefer_final_locals diff --git a/mobile/openapi/lib/api/person_api.dart b/mobile/openapi/lib/api/person_api.dart index cf0d289388..a05aa03e1f 100644 --- a/mobile/openapi/lib/api/person_api.dart +++ b/mobile/openapi/lib/api/person_api.dart @@ -16,13 +16,13 @@ class PersonApi { final ApiClient apiClient; - /// Performs an HTTP 'POST /person' operation and returns the [Response]. + /// Performs an HTTP 'POST /people' operation and returns the [Response]. /// Parameters: /// /// * [PersonCreateDto] personCreateDto (required): Future createPersonWithHttpInfo(PersonCreateDto personCreateDto,) async { // ignore: prefer_const_declarations - final path = r'/person'; + final path = r'/people'; // ignore: prefer_final_locals Object? postBody = personCreateDto; @@ -63,13 +63,13 @@ class PersonApi { return null; } - /// Performs an HTTP 'GET /person' operation and returns the [Response]. + /// Performs an HTTP 'GET /people' operation and returns the [Response]. /// Parameters: /// /// * [bool] withHidden: Future getAllPeopleWithHttpInfo({ bool? withHidden, }) async { // ignore: prefer_const_declarations - final path = r'/person'; + final path = r'/people'; // ignore: prefer_final_locals Object? postBody; @@ -114,13 +114,13 @@ class PersonApi { return null; } - /// Performs an HTTP 'GET /person/{id}' operation and returns the [Response]. + /// Performs an HTTP 'GET /people/{id}' operation and returns the [Response]. /// Parameters: /// /// * [String] id (required): Future getPersonWithHttpInfo(String id,) async { // ignore: prefer_const_declarations - final path = r'/person/{id}' + final path = r'/people/{id}' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -162,13 +162,13 @@ class PersonApi { return null; } - /// Performs an HTTP 'GET /person/{id}/assets' operation and returns the [Response]. + /// Performs an HTTP 'GET /people/{id}/assets' operation and returns the [Response]. /// Parameters: /// /// * [String] id (required): Future getPersonAssetsWithHttpInfo(String id,) async { // ignore: prefer_const_declarations - final path = r'/person/{id}/assets' + final path = r'/people/{id}/assets' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -213,13 +213,13 @@ class PersonApi { return null; } - /// Performs an HTTP 'GET /person/{id}/statistics' operation and returns the [Response]. + /// Performs an HTTP 'GET /people/{id}/statistics' operation and returns the [Response]. /// Parameters: /// /// * [String] id (required): Future getPersonStatisticsWithHttpInfo(String id,) async { // ignore: prefer_const_declarations - final path = r'/person/{id}/statistics' + final path = r'/people/{id}/statistics' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -261,13 +261,13 @@ class PersonApi { return null; } - /// Performs an HTTP 'GET /person/{id}/thumbnail' operation and returns the [Response]. + /// Performs an HTTP 'GET /people/{id}/thumbnail' operation and returns the [Response]. /// Parameters: /// /// * [String] id (required): Future getPersonThumbnailWithHttpInfo(String id,) async { // ignore: prefer_const_declarations - final path = r'/person/{id}/thumbnail' + final path = r'/people/{id}/thumbnail' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -309,7 +309,7 @@ class PersonApi { return null; } - /// Performs an HTTP 'POST /person/{id}/merge' operation and returns the [Response]. + /// Performs an HTTP 'POST /people/{id}/merge' operation and returns the [Response]. /// Parameters: /// /// * [String] id (required): @@ -317,7 +317,7 @@ class PersonApi { /// * [MergePersonDto] mergePersonDto (required): Future mergePersonWithHttpInfo(String id, MergePersonDto mergePersonDto,) async { // ignore: prefer_const_declarations - final path = r'/person/{id}/merge' + final path = r'/people/{id}/merge' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -364,7 +364,7 @@ class PersonApi { return null; } - /// Performs an HTTP 'PUT /person/{id}/reassign' operation and returns the [Response]. + /// Performs an HTTP 'PUT /people/{id}/reassign' operation and returns the [Response]. /// Parameters: /// /// * [String] id (required): @@ -372,7 +372,7 @@ class PersonApi { /// * [AssetFaceUpdateDto] assetFaceUpdateDto (required): Future reassignFacesWithHttpInfo(String id, AssetFaceUpdateDto assetFaceUpdateDto,) async { // ignore: prefer_const_declarations - final path = r'/person/{id}/reassign' + final path = r'/people/{id}/reassign' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -419,13 +419,13 @@ class PersonApi { return null; } - /// Performs an HTTP 'PUT /person' operation and returns the [Response]. + /// Performs an HTTP 'PUT /people' operation and returns the [Response]. /// Parameters: /// /// * [PeopleUpdateDto] peopleUpdateDto (required): Future updatePeopleWithHttpInfo(PeopleUpdateDto peopleUpdateDto,) async { // ignore: prefer_const_declarations - final path = r'/person'; + final path = r'/people'; // ignore: prefer_final_locals Object? postBody = peopleUpdateDto; @@ -469,7 +469,7 @@ class PersonApi { return null; } - /// Performs an HTTP 'PUT /person/{id}' operation and returns the [Response]. + /// Performs an HTTP 'PUT /people/{id}' operation and returns the [Response]. /// Parameters: /// /// * [String] id (required): @@ -477,7 +477,7 @@ class PersonApi { /// * [PersonUpdateDto] personUpdateDto (required): Future updatePersonWithHttpInfo(String id, PersonUpdateDto personUpdateDto,) async { // ignore: prefer_const_declarations - final path = r'/person/{id}' + final path = r'/people/{id}' .replaceAll('{id}', id); // ignore: prefer_final_locals diff --git a/mobile/openapi/lib/api/shared_link_api.dart b/mobile/openapi/lib/api/shared_link_api.dart index 7d4ef098b0..80a5034dff 100644 --- a/mobile/openapi/lib/api/shared_link_api.dart +++ b/mobile/openapi/lib/api/shared_link_api.dart @@ -16,7 +16,7 @@ class SharedLinkApi { final ApiClient apiClient; - /// Performs an HTTP 'PUT /shared-link/{id}/assets' operation and returns the [Response]. + /// Performs an HTTP 'PUT /shared-links/{id}/assets' operation and returns the [Response]. /// Parameters: /// /// * [String] id (required): @@ -26,7 +26,7 @@ class SharedLinkApi { /// * [String] key: Future addSharedLinkAssetsWithHttpInfo(String id, AssetIdsDto assetIdsDto, { String? key, }) async { // ignore: prefer_const_declarations - final path = r'/shared-link/{id}/assets' + final path = r'/shared-links/{id}/assets' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -79,13 +79,13 @@ class SharedLinkApi { return null; } - /// Performs an HTTP 'POST /shared-link' operation and returns the [Response]. + /// Performs an HTTP 'POST /shared-links' operation and returns the [Response]. /// Parameters: /// /// * [SharedLinkCreateDto] sharedLinkCreateDto (required): Future createSharedLinkWithHttpInfo(SharedLinkCreateDto sharedLinkCreateDto,) async { // ignore: prefer_const_declarations - final path = r'/shared-link'; + final path = r'/shared-links'; // ignore: prefer_final_locals Object? postBody = sharedLinkCreateDto; @@ -126,10 +126,10 @@ class SharedLinkApi { return null; } - /// Performs an HTTP 'GET /shared-link' operation and returns the [Response]. + /// Performs an HTTP 'GET /shared-links' operation and returns the [Response]. Future getAllSharedLinksWithHttpInfo() async { // ignore: prefer_const_declarations - final path = r'/shared-link'; + final path = r'/shared-links'; // ignore: prefer_final_locals Object? postBody; @@ -170,7 +170,7 @@ class SharedLinkApi { return null; } - /// Performs an HTTP 'GET /shared-link/me' operation and returns the [Response]. + /// Performs an HTTP 'GET /shared-links/me' operation and returns the [Response]. /// Parameters: /// /// * [String] key: @@ -180,7 +180,7 @@ class SharedLinkApi { /// * [String] token: Future getMySharedLinkWithHttpInfo({ String? key, String? password, String? token, }) async { // ignore: prefer_const_declarations - final path = r'/shared-link/me'; + final path = r'/shared-links/me'; // ignore: prefer_final_locals Object? postBody; @@ -235,13 +235,13 @@ class SharedLinkApi { return null; } - /// Performs an HTTP 'GET /shared-link/{id}' operation and returns the [Response]. + /// Performs an HTTP 'GET /shared-links/{id}' operation and returns the [Response]. /// Parameters: /// /// * [String] id (required): Future getSharedLinkByIdWithHttpInfo(String id,) async { // ignore: prefer_const_declarations - final path = r'/shared-link/{id}' + final path = r'/shared-links/{id}' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -283,13 +283,13 @@ class SharedLinkApi { return null; } - /// Performs an HTTP 'DELETE /shared-link/{id}' operation and returns the [Response]. + /// Performs an HTTP 'DELETE /shared-links/{id}' operation and returns the [Response]. /// Parameters: /// /// * [String] id (required): Future removeSharedLinkWithHttpInfo(String id,) async { // ignore: prefer_const_declarations - final path = r'/shared-link/{id}' + final path = r'/shared-links/{id}' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -323,7 +323,7 @@ class SharedLinkApi { } } - /// Performs an HTTP 'DELETE /shared-link/{id}/assets' operation and returns the [Response]. + /// Performs an HTTP 'DELETE /shared-links/{id}/assets' operation and returns the [Response]. /// Parameters: /// /// * [String] id (required): @@ -333,7 +333,7 @@ class SharedLinkApi { /// * [String] key: Future removeSharedLinkAssetsWithHttpInfo(String id, AssetIdsDto assetIdsDto, { String? key, }) async { // ignore: prefer_const_declarations - final path = r'/shared-link/{id}/assets' + final path = r'/shared-links/{id}/assets' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -386,7 +386,7 @@ class SharedLinkApi { return null; } - /// Performs an HTTP 'PATCH /shared-link/{id}' operation and returns the [Response]. + /// Performs an HTTP 'PATCH /shared-links/{id}' operation and returns the [Response]. /// Parameters: /// /// * [String] id (required): @@ -394,7 +394,7 @@ class SharedLinkApi { /// * [SharedLinkEditDto] sharedLinkEditDto (required): Future updateSharedLinkWithHttpInfo(String id, SharedLinkEditDto sharedLinkEditDto,) async { // ignore: prefer_const_declarations - final path = r'/shared-link/{id}' + final path = r'/shared-links/{id}' .replaceAll('{id}', id); // ignore: prefer_final_locals diff --git a/mobile/openapi/lib/api/tag_api.dart b/mobile/openapi/lib/api/tag_api.dart index 539e3c8e27..961f0cb394 100644 --- a/mobile/openapi/lib/api/tag_api.dart +++ b/mobile/openapi/lib/api/tag_api.dart @@ -16,13 +16,13 @@ class TagApi { final ApiClient apiClient; - /// Performs an HTTP 'POST /tag' operation and returns the [Response]. + /// Performs an HTTP 'POST /tags' operation and returns the [Response]. /// Parameters: /// /// * [CreateTagDto] createTagDto (required): Future createTagWithHttpInfo(CreateTagDto createTagDto,) async { // ignore: prefer_const_declarations - final path = r'/tag'; + final path = r'/tags'; // ignore: prefer_final_locals Object? postBody = createTagDto; @@ -63,13 +63,13 @@ class TagApi { return null; } - /// Performs an HTTP 'DELETE /tag/{id}' operation and returns the [Response]. + /// Performs an HTTP 'DELETE /tags/{id}' operation and returns the [Response]. /// Parameters: /// /// * [String] id (required): Future deleteTagWithHttpInfo(String id,) async { // ignore: prefer_const_declarations - final path = r'/tag/{id}' + final path = r'/tags/{id}' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -103,10 +103,10 @@ class TagApi { } } - /// Performs an HTTP 'GET /tag' operation and returns the [Response]. + /// Performs an HTTP 'GET /tags' operation and returns the [Response]. Future getAllTagsWithHttpInfo() async { // ignore: prefer_const_declarations - final path = r'/tag'; + final path = r'/tags'; // ignore: prefer_final_locals Object? postBody; @@ -147,13 +147,13 @@ class TagApi { return null; } - /// Performs an HTTP 'GET /tag/{id}/assets' operation and returns the [Response]. + /// Performs an HTTP 'GET /tags/{id}/assets' operation and returns the [Response]. /// Parameters: /// /// * [String] id (required): Future getTagAssetsWithHttpInfo(String id,) async { // ignore: prefer_const_declarations - final path = r'/tag/{id}/assets' + final path = r'/tags/{id}/assets' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -198,13 +198,13 @@ class TagApi { return null; } - /// Performs an HTTP 'GET /tag/{id}' operation and returns the [Response]. + /// Performs an HTTP 'GET /tags/{id}' operation and returns the [Response]. /// Parameters: /// /// * [String] id (required): Future getTagByIdWithHttpInfo(String id,) async { // ignore: prefer_const_declarations - final path = r'/tag/{id}' + final path = r'/tags/{id}' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -246,7 +246,7 @@ class TagApi { return null; } - /// Performs an HTTP 'PUT /tag/{id}/assets' operation and returns the [Response]. + /// Performs an HTTP 'PUT /tags/{id}/assets' operation and returns the [Response]. /// Parameters: /// /// * [String] id (required): @@ -254,7 +254,7 @@ class TagApi { /// * [AssetIdsDto] assetIdsDto (required): Future tagAssetsWithHttpInfo(String id, AssetIdsDto assetIdsDto,) async { // ignore: prefer_const_declarations - final path = r'/tag/{id}/assets' + final path = r'/tags/{id}/assets' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -301,7 +301,7 @@ class TagApi { return null; } - /// Performs an HTTP 'DELETE /tag/{id}/assets' operation and returns the [Response]. + /// Performs an HTTP 'DELETE /tags/{id}/assets' operation and returns the [Response]. /// Parameters: /// /// * [String] id (required): @@ -309,7 +309,7 @@ class TagApi { /// * [AssetIdsDto] assetIdsDto (required): Future untagAssetsWithHttpInfo(String id, AssetIdsDto assetIdsDto,) async { // ignore: prefer_const_declarations - final path = r'/tag/{id}/assets' + final path = r'/tags/{id}/assets' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -356,7 +356,7 @@ class TagApi { return null; } - /// Performs an HTTP 'PATCH /tag/{id}' operation and returns the [Response]. + /// Performs an HTTP 'PATCH /tags/{id}' operation and returns the [Response]. /// Parameters: /// /// * [String] id (required): @@ -364,7 +364,7 @@ class TagApi { /// * [UpdateTagDto] updateTagDto (required): Future updateTagWithHttpInfo(String id, UpdateTagDto updateTagDto,) async { // ignore: prefer_const_declarations - final path = r'/tag/{id}' + final path = r'/tags/{id}' .replaceAll('{id}', id); // ignore: prefer_final_locals diff --git a/mobile/openapi/lib/api/user_api.dart b/mobile/openapi/lib/api/user_api.dart index 42a9532e28..1f070d436f 100644 --- a/mobile/openapi/lib/api/user_api.dart +++ b/mobile/openapi/lib/api/user_api.dart @@ -16,13 +16,13 @@ class UserApi { final ApiClient apiClient; - /// Performs an HTTP 'POST /user/profile-image' operation and returns the [Response]. + /// Performs an HTTP 'POST /users/profile-image' operation and returns the [Response]. /// Parameters: /// /// * [MultipartFile] file (required): Future createProfileImageWithHttpInfo(MultipartFile file,) async { // ignore: prefer_const_declarations - final path = r'/user/profile-image'; + final path = r'/users/profile-image'; // ignore: prefer_final_locals Object? postBody; @@ -73,13 +73,13 @@ class UserApi { return null; } - /// Performs an HTTP 'POST /user' operation and returns the [Response]. + /// Performs an HTTP 'POST /users' operation and returns the [Response]. /// Parameters: /// /// * [CreateUserDto] createUserDto (required): Future createUserWithHttpInfo(CreateUserDto createUserDto,) async { // ignore: prefer_const_declarations - final path = r'/user'; + final path = r'/users'; // ignore: prefer_final_locals Object? postBody = createUserDto; @@ -120,10 +120,10 @@ class UserApi { return null; } - /// Performs an HTTP 'DELETE /user/profile-image' operation and returns the [Response]. + /// Performs an HTTP 'DELETE /users/profile-image' operation and returns the [Response]. Future deleteProfileImageWithHttpInfo() async { // ignore: prefer_const_declarations - final path = r'/user/profile-image'; + final path = r'/users/profile-image'; // ignore: prefer_final_locals Object? postBody; @@ -153,7 +153,7 @@ class UserApi { } } - /// Performs an HTTP 'DELETE /user/{id}' operation and returns the [Response]. + /// Performs an HTTP 'DELETE /users/{id}' operation and returns the [Response]. /// Parameters: /// /// * [String] id (required): @@ -161,7 +161,7 @@ class UserApi { /// * [DeleteUserDto] deleteUserDto (required): Future deleteUserWithHttpInfo(String id, DeleteUserDto deleteUserDto,) async { // ignore: prefer_const_declarations - final path = r'/user/{id}' + final path = r'/users/{id}' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -205,13 +205,13 @@ class UserApi { return null; } - /// Performs an HTTP 'GET /user' operation and returns the [Response]. + /// Performs an HTTP 'GET /users' operation and returns the [Response]. /// Parameters: /// /// * [bool] isAll (required): Future getAllUsersWithHttpInfo(bool isAll,) async { // ignore: prefer_const_declarations - final path = r'/user'; + final path = r'/users'; // ignore: prefer_final_locals Object? postBody; @@ -257,10 +257,10 @@ class UserApi { return null; } - /// Performs an HTTP 'GET /user/me' operation and returns the [Response]. + /// Performs an HTTP 'GET /users/me' operation and returns the [Response]. Future getMyUserInfoWithHttpInfo() async { // ignore: prefer_const_declarations - final path = r'/user/me'; + final path = r'/users/me'; // ignore: prefer_final_locals Object? postBody; @@ -298,13 +298,13 @@ class UserApi { return null; } - /// Performs an HTTP 'GET /user/profile-image/{id}' operation and returns the [Response]. + /// Performs an HTTP 'GET /users/profile-image/{id}' operation and returns the [Response]. /// Parameters: /// /// * [String] id (required): Future getProfileImageWithHttpInfo(String id,) async { // ignore: prefer_const_declarations - final path = r'/user/profile-image/{id}' + final path = r'/users/profile-image/{id}' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -346,13 +346,13 @@ class UserApi { return null; } - /// Performs an HTTP 'GET /user/info/{id}' operation and returns the [Response]. + /// Performs an HTTP 'GET /users/info/{id}' operation and returns the [Response]. /// Parameters: /// /// * [String] id (required): Future getUserByIdWithHttpInfo(String id,) async { // ignore: prefer_const_declarations - final path = r'/user/info/{id}' + final path = r'/users/info/{id}' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -394,13 +394,13 @@ class UserApi { return null; } - /// Performs an HTTP 'POST /user/{id}/restore' operation and returns the [Response]. + /// Performs an HTTP 'POST /users/{id}/restore' operation and returns the [Response]. /// Parameters: /// /// * [String] id (required): Future restoreUserWithHttpInfo(String id,) async { // ignore: prefer_const_declarations - final path = r'/user/{id}/restore' + final path = r'/users/{id}/restore' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -442,13 +442,13 @@ class UserApi { return null; } - /// Performs an HTTP 'PUT /user' operation and returns the [Response]. + /// Performs an HTTP 'PUT /users' operation and returns the [Response]. /// Parameters: /// /// * [UpdateUserDto] updateUserDto (required): Future updateUserWithHttpInfo(UpdateUserDto updateUserDto,) async { // ignore: prefer_const_declarations - final path = r'/user'; + final path = r'/users'; // ignore: prefer_final_locals Object? postBody = updateUserDto; diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index c6eff20a47..929338b734 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -1,7 +1,7 @@ { "openapi": "3.0.0", "paths": { - "/activity": { + "/activities": { "get": { "operationId": "getActivities", "parameters": [ @@ -120,7 +120,7 @@ ] } }, - "/activity/statistics": { + "/activities/statistics": { "get": { "operationId": "getActivityStatistics", "parameters": [ @@ -171,7 +171,7 @@ ] } }, - "/activity/{id}": { + "/activities/{id}": { "delete": { "operationId": "deleteActivity", "parameters": [ @@ -206,7 +206,7 @@ ] } }, - "/album": { + "/albums": { "get": { "operationId": "getAllAlbums", "parameters": [ @@ -300,7 +300,7 @@ ] } }, - "/album/count": { + "/albums/count": { "get": { "operationId": "getAlbumCount", "parameters": [], @@ -332,7 +332,7 @@ ] } }, - "/album/{id}": { + "/albums/{id}": { "delete": { "operationId": "deleteAlbum", "parameters": [ @@ -473,7 +473,7 @@ ] } }, - "/album/{id}/assets": { + "/albums/{id}/assets": { "delete": { "operationId": "removeAssetFromAlbum", "parameters": [ @@ -589,7 +589,7 @@ ] } }, - "/album/{id}/user/{userId}": { + "/albums/{id}/user/{userId}": { "delete": { "operationId": "removeUserFromAlbum", "parameters": [ @@ -683,7 +683,7 @@ ] } }, - "/album/{id}/users": { + "/albums/{id}/users": { "put": { "operationId": "addUsersToAlbum", "parameters": [ @@ -735,7 +735,7 @@ ] } }, - "/api-key": { + "/api-keys": { "get": { "operationId": "getApiKeys", "parameters": [], @@ -810,7 +810,7 @@ ] } }, - "/api-key/{id}": { + "/api-keys/{id}": { "delete": { "operationId": "deleteApiKey", "parameters": [ @@ -2256,7 +2256,7 @@ ] } }, - "/face": { + "/faces": { "get": { "operationId": "getFaces", "parameters": [ @@ -2301,7 +2301,7 @@ ] } }, - "/face/{id}": { + "/faces/{id}": { "put": { "operationId": "reassignFacesById", "parameters": [ @@ -2436,7 +2436,7 @@ ] } }, - "/library": { + "/libraries": { "get": { "operationId": "getAllLibraries", "parameters": [], @@ -2511,7 +2511,7 @@ ] } }, - "/library/{id}": { + "/libraries/{id}": { "delete": { "operationId": "deleteLibrary", "parameters": [ @@ -2636,7 +2636,7 @@ ] } }, - "/library/{id}/removeOffline": { + "/libraries/{id}/removeOffline": { "post": { "operationId": "removeOfflineFiles", "parameters": [ @@ -2671,7 +2671,7 @@ ] } }, - "/library/{id}/scan": { + "/libraries/{id}/scan": { "post": { "operationId": "scanLibrary", "parameters": [ @@ -2716,7 +2716,7 @@ ] } }, - "/library/{id}/statistics": { + "/libraries/{id}/statistics": { "get": { "operationId": "getLibraryStatistics", "parameters": [ @@ -2758,7 +2758,7 @@ ] } }, - "/library/{id}/validate": { + "/libraries/{id}/validate": { "post": { "operationId": "validate", "parameters": [ @@ -3268,7 +3268,7 @@ ] } }, - "/partner": { + "/partners": { "get": { "operationId": "getPartners", "parameters": [ @@ -3316,7 +3316,7 @@ ] } }, - "/partner/{id}": { + "/partners/{id}": { "delete": { "operationId": "removePartner", "parameters": [ @@ -3441,7 +3441,7 @@ ] } }, - "/person": { + "/people": { "get": { "operationId": "getAllPeople", "parameters": [ @@ -3565,7 +3565,7 @@ ] } }, - "/person/{id}": { + "/people/{id}": { "get": { "operationId": "getPerson", "parameters": [ @@ -3657,7 +3657,7 @@ ] } }, - "/person/{id}/assets": { + "/people/{id}/assets": { "get": { "operationId": "getPersonAssets", "parameters": [ @@ -3702,7 +3702,7 @@ ] } }, - "/person/{id}/merge": { + "/people/{id}/merge": { "post": { "operationId": "mergePerson", "parameters": [ @@ -3757,7 +3757,7 @@ ] } }, - "/person/{id}/reassign": { + "/people/{id}/reassign": { "put": { "operationId": "reassignFaces", "parameters": [ @@ -3812,7 +3812,7 @@ ] } }, - "/person/{id}/statistics": { + "/people/{id}/statistics": { "get": { "operationId": "getPersonStatistics", "parameters": [ @@ -3854,7 +3854,7 @@ ] } }, - "/person/{id}/thumbnail": { + "/people/{id}/thumbnail": { "get": { "operationId": "getPersonThumbnail", "parameters": [ @@ -3897,7 +3897,7 @@ ] } }, - "/report": { + "/reports": { "get": { "operationId": "getAuditFiles", "parameters": [], @@ -3929,7 +3929,7 @@ ] } }, - "/report/checksum": { + "/reports/checksum": { "post": { "operationId": "getFileChecksums", "parameters": [], @@ -3974,7 +3974,7 @@ ] } }, - "/report/fix": { + "/reports/fix": { "post": { "operationId": "fixAuditFiles", "parameters": [], @@ -4656,7 +4656,7 @@ ] } }, - "/shared-link": { + "/shared-links": { "get": { "operationId": "getAllSharedLinks", "parameters": [], @@ -4731,7 +4731,7 @@ ] } }, - "/shared-link/me": { + "/shared-links/me": { "get": { "operationId": "getMySharedLink", "parameters": [ @@ -4789,7 +4789,7 @@ ] } }, - "/shared-link/{id}": { + "/shared-links/{id}": { "delete": { "operationId": "removeSharedLink", "parameters": [ @@ -4914,7 +4914,7 @@ ] } }, - "/shared-link/{id}/assets": { + "/shared-links/{id}/assets": { "delete": { "operationId": "removeSharedLinkAssets", "parameters": [ @@ -5407,7 +5407,7 @@ ] } }, - "/tag": { + "/tags": { "get": { "operationId": "getAllTags", "parameters": [], @@ -5482,7 +5482,7 @@ ] } }, - "/tag/{id}": { + "/tags/{id}": { "delete": { "operationId": "deleteTag", "parameters": [ @@ -5607,7 +5607,7 @@ ] } }, - "/tag/{id}/assets": { + "/tags/{id}/assets": { "delete": { "operationId": "untagAssets", "parameters": [ @@ -6105,7 +6105,7 @@ ] } }, - "/user": { + "/users": { "get": { "operationId": "getAllUsers", "parameters": [ @@ -6229,7 +6229,7 @@ ] } }, - "/user/info/{id}": { + "/users/info/{id}": { "get": { "operationId": "getUserById", "parameters": [ @@ -6271,7 +6271,7 @@ ] } }, - "/user/me": { + "/users/me": { "get": { "operationId": "getMyUserInfo", "parameters": [], @@ -6303,7 +6303,7 @@ ] } }, - "/user/profile-image": { + "/users/profile-image": { "delete": { "operationId": "deleteProfileImage", "parameters": [], @@ -6369,7 +6369,7 @@ ] } }, - "/user/profile-image/{id}": { + "/users/profile-image/{id}": { "get": { "operationId": "getProfileImage", "parameters": [ @@ -6412,7 +6412,7 @@ ] } }, - "/user/{id}": { + "/users/{id}": { "delete": { "operationId": "deleteUser", "parameters": [ @@ -6464,7 +6464,7 @@ ] } }, - "/user/{id}/restore": { + "/users/{id}/restore": { "post": { "operationId": "restoreUser", "parameters": [ diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 050dbfeb9a..02ff002f01 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -1106,7 +1106,7 @@ export function getActivities({ albumId, assetId, level, $type, userId }: { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; data: ActivityResponseDto[]; - }>(`/activity${QS.query(QS.explode({ + }>(`/activities${QS.query(QS.explode({ albumId, assetId, level, @@ -1122,7 +1122,7 @@ export function createActivity({ activityCreateDto }: { return oazapfts.ok(oazapfts.fetchJson<{ status: 201; data: ActivityResponseDto; - }>("/activity", oazapfts.json({ + }>("/activities", oazapfts.json({ ...opts, method: "POST", body: activityCreateDto @@ -1135,7 +1135,7 @@ export function getActivityStatistics({ albumId, assetId }: { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; data: ActivityStatisticsResponseDto; - }>(`/activity/statistics${QS.query(QS.explode({ + }>(`/activities/statistics${QS.query(QS.explode({ albumId, assetId }))}`, { @@ -1145,7 +1145,7 @@ export function getActivityStatistics({ albumId, assetId }: { export function deleteActivity({ id }: { id: string; }, opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchText(`/activity/${encodeURIComponent(id)}`, { + return oazapfts.ok(oazapfts.fetchText(`/activities/${encodeURIComponent(id)}`, { ...opts, method: "DELETE" })); @@ -1157,7 +1157,7 @@ export function getAllAlbums({ assetId, shared }: { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; data: AlbumResponseDto[]; - }>(`/album${QS.query(QS.explode({ + }>(`/albums${QS.query(QS.explode({ assetId, shared }))}`, { @@ -1170,7 +1170,7 @@ export function createAlbum({ createAlbumDto }: { return oazapfts.ok(oazapfts.fetchJson<{ status: 201; data: AlbumResponseDto; - }>("/album", oazapfts.json({ + }>("/albums", oazapfts.json({ ...opts, method: "POST", body: createAlbumDto @@ -1180,14 +1180,14 @@ export function getAlbumCount(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; data: AlbumCountResponseDto; - }>("/album/count", { + }>("/albums/count", { ...opts })); } export function deleteAlbum({ id }: { id: string; }, opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchText(`/album/${encodeURIComponent(id)}`, { + return oazapfts.ok(oazapfts.fetchText(`/albums/${encodeURIComponent(id)}`, { ...opts, method: "DELETE" })); @@ -1200,7 +1200,7 @@ export function getAlbumInfo({ id, key, withoutAssets }: { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; data: AlbumResponseDto; - }>(`/album/${encodeURIComponent(id)}${QS.query(QS.explode({ + }>(`/albums/${encodeURIComponent(id)}${QS.query(QS.explode({ key, withoutAssets }))}`, { @@ -1214,7 +1214,7 @@ export function updateAlbumInfo({ id, updateAlbumDto }: { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; data: AlbumResponseDto; - }>(`/album/${encodeURIComponent(id)}`, oazapfts.json({ + }>(`/albums/${encodeURIComponent(id)}`, oazapfts.json({ ...opts, method: "PATCH", body: updateAlbumDto @@ -1227,7 +1227,7 @@ export function removeAssetFromAlbum({ id, bulkIdsDto }: { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; data: BulkIdResponseDto[]; - }>(`/album/${encodeURIComponent(id)}/assets`, oazapfts.json({ + }>(`/albums/${encodeURIComponent(id)}/assets`, oazapfts.json({ ...opts, method: "DELETE", body: bulkIdsDto @@ -1241,7 +1241,7 @@ export function addAssetsToAlbum({ id, key, bulkIdsDto }: { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; data: BulkIdResponseDto[]; - }>(`/album/${encodeURIComponent(id)}/assets${QS.query(QS.explode({ + }>(`/albums/${encodeURIComponent(id)}/assets${QS.query(QS.explode({ key }))}`, oazapfts.json({ ...opts, @@ -1253,7 +1253,7 @@ export function removeUserFromAlbum({ id, userId }: { id: string; userId: string; }, opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchText(`/album/${encodeURIComponent(id)}/user/${encodeURIComponent(userId)}`, { + return oazapfts.ok(oazapfts.fetchText(`/albums/${encodeURIComponent(id)}/user/${encodeURIComponent(userId)}`, { ...opts, method: "DELETE" })); @@ -1263,7 +1263,7 @@ export function updateAlbumUser({ id, userId, updateAlbumUserDto }: { userId: string; updateAlbumUserDto: UpdateAlbumUserDto; }, opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchText(`/album/${encodeURIComponent(id)}/user/${encodeURIComponent(userId)}`, oazapfts.json({ + return oazapfts.ok(oazapfts.fetchText(`/albums/${encodeURIComponent(id)}/user/${encodeURIComponent(userId)}`, oazapfts.json({ ...opts, method: "PUT", body: updateAlbumUserDto @@ -1276,7 +1276,7 @@ export function addUsersToAlbum({ id, addUsersDto }: { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; data: AlbumResponseDto; - }>(`/album/${encodeURIComponent(id)}/users`, oazapfts.json({ + }>(`/albums/${encodeURIComponent(id)}/users`, oazapfts.json({ ...opts, method: "PUT", body: addUsersDto @@ -1286,7 +1286,7 @@ export function getApiKeys(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; data: ApiKeyResponseDto[]; - }>("/api-key", { + }>("/api-keys", { ...opts })); } @@ -1296,7 +1296,7 @@ export function createApiKey({ apiKeyCreateDto }: { return oazapfts.ok(oazapfts.fetchJson<{ status: 201; data: ApiKeyCreateResponseDto; - }>("/api-key", oazapfts.json({ + }>("/api-keys", oazapfts.json({ ...opts, method: "POST", body: apiKeyCreateDto @@ -1305,7 +1305,7 @@ export function createApiKey({ apiKeyCreateDto }: { export function deleteApiKey({ id }: { id: string; }, opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchText(`/api-key/${encodeURIComponent(id)}`, { + return oazapfts.ok(oazapfts.fetchText(`/api-keys/${encodeURIComponent(id)}`, { ...opts, method: "DELETE" })); @@ -1316,7 +1316,7 @@ export function getApiKey({ id }: { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; data: ApiKeyResponseDto; - }>(`/api-key/${encodeURIComponent(id)}`, { + }>(`/api-keys/${encodeURIComponent(id)}`, { ...opts })); } @@ -1327,7 +1327,7 @@ export function updateApiKey({ id, apiKeyUpdateDto }: { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; data: ApiKeyResponseDto; - }>(`/api-key/${encodeURIComponent(id)}`, oazapfts.json({ + }>(`/api-keys/${encodeURIComponent(id)}`, oazapfts.json({ ...opts, method: "PUT", body: apiKeyUpdateDto @@ -1712,7 +1712,7 @@ export function getFaces({ id }: { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; data: AssetFaceResponseDto[]; - }>(`/face${QS.query(QS.explode({ + }>(`/faces${QS.query(QS.explode({ id }))}`, { ...opts @@ -1725,7 +1725,7 @@ export function reassignFacesById({ id, faceDto }: { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; data: PersonResponseDto; - }>(`/face/${encodeURIComponent(id)}`, oazapfts.json({ + }>(`/faces/${encodeURIComponent(id)}`, oazapfts.json({ ...opts, method: "PUT", body: faceDto @@ -1756,7 +1756,7 @@ export function getAllLibraries(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; data: LibraryResponseDto[]; - }>("/library", { + }>("/libraries", { ...opts })); } @@ -1766,7 +1766,7 @@ export function createLibrary({ createLibraryDto }: { return oazapfts.ok(oazapfts.fetchJson<{ status: 201; data: LibraryResponseDto; - }>("/library", oazapfts.json({ + }>("/libraries", oazapfts.json({ ...opts, method: "POST", body: createLibraryDto @@ -1775,7 +1775,7 @@ export function createLibrary({ createLibraryDto }: { export function deleteLibrary({ id }: { id: string; }, opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchText(`/library/${encodeURIComponent(id)}`, { + return oazapfts.ok(oazapfts.fetchText(`/libraries/${encodeURIComponent(id)}`, { ...opts, method: "DELETE" })); @@ -1786,7 +1786,7 @@ export function getLibrary({ id }: { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; data: LibraryResponseDto; - }>(`/library/${encodeURIComponent(id)}`, { + }>(`/libraries/${encodeURIComponent(id)}`, { ...opts })); } @@ -1797,7 +1797,7 @@ export function updateLibrary({ id, updateLibraryDto }: { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; data: LibraryResponseDto; - }>(`/library/${encodeURIComponent(id)}`, oazapfts.json({ + }>(`/libraries/${encodeURIComponent(id)}`, oazapfts.json({ ...opts, method: "PUT", body: updateLibraryDto @@ -1806,7 +1806,7 @@ export function updateLibrary({ id, updateLibraryDto }: { export function removeOfflineFiles({ id }: { id: string; }, opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchText(`/library/${encodeURIComponent(id)}/removeOffline`, { + return oazapfts.ok(oazapfts.fetchText(`/libraries/${encodeURIComponent(id)}/removeOffline`, { ...opts, method: "POST" })); @@ -1815,7 +1815,7 @@ export function scanLibrary({ id, scanLibraryDto }: { id: string; scanLibraryDto: ScanLibraryDto; }, opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchText(`/library/${encodeURIComponent(id)}/scan`, oazapfts.json({ + return oazapfts.ok(oazapfts.fetchText(`/libraries/${encodeURIComponent(id)}/scan`, oazapfts.json({ ...opts, method: "POST", body: scanLibraryDto @@ -1827,7 +1827,7 @@ export function getLibraryStatistics({ id }: { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; data: LibraryStatsResponseDto; - }>(`/library/${encodeURIComponent(id)}/statistics`, { + }>(`/libraries/${encodeURIComponent(id)}/statistics`, { ...opts })); } @@ -1838,7 +1838,7 @@ export function validate({ id, validateLibraryDto }: { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; data: ValidateLibraryResponseDto; - }>(`/library/${encodeURIComponent(id)}/validate`, oazapfts.json({ + }>(`/libraries/${encodeURIComponent(id)}/validate`, oazapfts.json({ ...opts, method: "POST", body: validateLibraryDto @@ -1977,7 +1977,7 @@ export function getPartners({ direction }: { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; data: PartnerResponseDto[]; - }>(`/partner${QS.query(QS.explode({ + }>(`/partners${QS.query(QS.explode({ direction }))}`, { ...opts @@ -1986,7 +1986,7 @@ export function getPartners({ direction }: { export function removePartner({ id }: { id: string; }, opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchText(`/partner/${encodeURIComponent(id)}`, { + return oazapfts.ok(oazapfts.fetchText(`/partners/${encodeURIComponent(id)}`, { ...opts, method: "DELETE" })); @@ -1997,7 +1997,7 @@ export function createPartner({ id }: { return oazapfts.ok(oazapfts.fetchJson<{ status: 201; data: PartnerResponseDto; - }>(`/partner/${encodeURIComponent(id)}`, { + }>(`/partners/${encodeURIComponent(id)}`, { ...opts, method: "POST" })); @@ -2009,7 +2009,7 @@ export function updatePartner({ id, updatePartnerDto }: { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; data: PartnerResponseDto; - }>(`/partner/${encodeURIComponent(id)}`, oazapfts.json({ + }>(`/partners/${encodeURIComponent(id)}`, oazapfts.json({ ...opts, method: "PUT", body: updatePartnerDto @@ -2021,7 +2021,7 @@ export function getAllPeople({ withHidden }: { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; data: PeopleResponseDto; - }>(`/person${QS.query(QS.explode({ + }>(`/people${QS.query(QS.explode({ withHidden }))}`, { ...opts @@ -2033,7 +2033,7 @@ export function createPerson({ personCreateDto }: { return oazapfts.ok(oazapfts.fetchJson<{ status: 201; data: PersonResponseDto; - }>("/person", oazapfts.json({ + }>("/people", oazapfts.json({ ...opts, method: "POST", body: personCreateDto @@ -2045,7 +2045,7 @@ export function updatePeople({ peopleUpdateDto }: { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; data: BulkIdResponseDto[]; - }>("/person", oazapfts.json({ + }>("/people", oazapfts.json({ ...opts, method: "PUT", body: peopleUpdateDto @@ -2057,7 +2057,7 @@ export function getPerson({ id }: { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; data: PersonResponseDto; - }>(`/person/${encodeURIComponent(id)}`, { + }>(`/people/${encodeURIComponent(id)}`, { ...opts })); } @@ -2068,7 +2068,7 @@ export function updatePerson({ id, personUpdateDto }: { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; data: PersonResponseDto; - }>(`/person/${encodeURIComponent(id)}`, oazapfts.json({ + }>(`/people/${encodeURIComponent(id)}`, oazapfts.json({ ...opts, method: "PUT", body: personUpdateDto @@ -2080,7 +2080,7 @@ export function getPersonAssets({ id }: { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; data: AssetResponseDto[]; - }>(`/person/${encodeURIComponent(id)}/assets`, { + }>(`/people/${encodeURIComponent(id)}/assets`, { ...opts })); } @@ -2091,7 +2091,7 @@ export function mergePerson({ id, mergePersonDto }: { return oazapfts.ok(oazapfts.fetchJson<{ status: 201; data: BulkIdResponseDto[]; - }>(`/person/${encodeURIComponent(id)}/merge`, oazapfts.json({ + }>(`/people/${encodeURIComponent(id)}/merge`, oazapfts.json({ ...opts, method: "POST", body: mergePersonDto @@ -2104,7 +2104,7 @@ export function reassignFaces({ id, assetFaceUpdateDto }: { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; data: PersonResponseDto[]; - }>(`/person/${encodeURIComponent(id)}/reassign`, oazapfts.json({ + }>(`/people/${encodeURIComponent(id)}/reassign`, oazapfts.json({ ...opts, method: "PUT", body: assetFaceUpdateDto @@ -2116,7 +2116,7 @@ export function getPersonStatistics({ id }: { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; data: PersonStatisticsResponseDto; - }>(`/person/${encodeURIComponent(id)}/statistics`, { + }>(`/people/${encodeURIComponent(id)}/statistics`, { ...opts })); } @@ -2126,7 +2126,7 @@ export function getPersonThumbnail({ id }: { return oazapfts.ok(oazapfts.fetchBlob<{ status: 200; data: Blob; - }>(`/person/${encodeURIComponent(id)}/thumbnail`, { + }>(`/people/${encodeURIComponent(id)}/thumbnail`, { ...opts })); } @@ -2134,7 +2134,7 @@ export function getAuditFiles(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; data: FileReportDto; - }>("/report", { + }>("/reports", { ...opts })); } @@ -2144,7 +2144,7 @@ export function getFileChecksums({ fileChecksumDto }: { return oazapfts.ok(oazapfts.fetchJson<{ status: 201; data: FileChecksumResponseDto[]; - }>("/report/checksum", oazapfts.json({ + }>("/reports/checksum", oazapfts.json({ ...opts, method: "POST", body: fileChecksumDto @@ -2153,7 +2153,7 @@ export function getFileChecksums({ fileChecksumDto }: { export function fixAuditFiles({ fileReportFixDto }: { fileReportFixDto: FileReportFixDto; }, opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchText("/report/fix", oazapfts.json({ + return oazapfts.ok(oazapfts.fetchText("/reports/fix", oazapfts.json({ ...opts, method: "POST", body: fileReportFixDto @@ -2346,7 +2346,7 @@ export function getAllSharedLinks(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; data: SharedLinkResponseDto[]; - }>("/shared-link", { + }>("/shared-links", { ...opts })); } @@ -2356,7 +2356,7 @@ export function createSharedLink({ sharedLinkCreateDto }: { return oazapfts.ok(oazapfts.fetchJson<{ status: 201; data: SharedLinkResponseDto; - }>("/shared-link", oazapfts.json({ + }>("/shared-links", oazapfts.json({ ...opts, method: "POST", body: sharedLinkCreateDto @@ -2370,7 +2370,7 @@ export function getMySharedLink({ key, password, token }: { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; data: SharedLinkResponseDto; - }>(`/shared-link/me${QS.query(QS.explode({ + }>(`/shared-links/me${QS.query(QS.explode({ key, password, token @@ -2381,7 +2381,7 @@ export function getMySharedLink({ key, password, token }: { export function removeSharedLink({ id }: { id: string; }, opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchText(`/shared-link/${encodeURIComponent(id)}`, { + return oazapfts.ok(oazapfts.fetchText(`/shared-links/${encodeURIComponent(id)}`, { ...opts, method: "DELETE" })); @@ -2392,7 +2392,7 @@ export function getSharedLinkById({ id }: { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; data: SharedLinkResponseDto; - }>(`/shared-link/${encodeURIComponent(id)}`, { + }>(`/shared-links/${encodeURIComponent(id)}`, { ...opts })); } @@ -2403,7 +2403,7 @@ export function updateSharedLink({ id, sharedLinkEditDto }: { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; data: SharedLinkResponseDto; - }>(`/shared-link/${encodeURIComponent(id)}`, oazapfts.json({ + }>(`/shared-links/${encodeURIComponent(id)}`, oazapfts.json({ ...opts, method: "PATCH", body: sharedLinkEditDto @@ -2417,7 +2417,7 @@ export function removeSharedLinkAssets({ id, key, assetIdsDto }: { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; data: AssetIdsResponseDto[]; - }>(`/shared-link/${encodeURIComponent(id)}/assets${QS.query(QS.explode({ + }>(`/shared-links/${encodeURIComponent(id)}/assets${QS.query(QS.explode({ key }))}`, oazapfts.json({ ...opts, @@ -2433,7 +2433,7 @@ export function addSharedLinkAssets({ id, key, assetIdsDto }: { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; data: AssetIdsResponseDto[]; - }>(`/shared-link/${encodeURIComponent(id)}/assets${QS.query(QS.explode({ + }>(`/shared-links/${encodeURIComponent(id)}/assets${QS.query(QS.explode({ key }))}`, oazapfts.json({ ...opts, @@ -2544,7 +2544,7 @@ export function getAllTags(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; data: TagResponseDto[]; - }>("/tag", { + }>("/tags", { ...opts })); } @@ -2554,7 +2554,7 @@ export function createTag({ createTagDto }: { return oazapfts.ok(oazapfts.fetchJson<{ status: 201; data: TagResponseDto; - }>("/tag", oazapfts.json({ + }>("/tags", oazapfts.json({ ...opts, method: "POST", body: createTagDto @@ -2563,7 +2563,7 @@ export function createTag({ createTagDto }: { export function deleteTag({ id }: { id: string; }, opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchText(`/tag/${encodeURIComponent(id)}`, { + return oazapfts.ok(oazapfts.fetchText(`/tags/${encodeURIComponent(id)}`, { ...opts, method: "DELETE" })); @@ -2574,7 +2574,7 @@ export function getTagById({ id }: { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; data: TagResponseDto; - }>(`/tag/${encodeURIComponent(id)}`, { + }>(`/tags/${encodeURIComponent(id)}`, { ...opts })); } @@ -2585,7 +2585,7 @@ export function updateTag({ id, updateTagDto }: { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; data: TagResponseDto; - }>(`/tag/${encodeURIComponent(id)}`, oazapfts.json({ + }>(`/tags/${encodeURIComponent(id)}`, oazapfts.json({ ...opts, method: "PATCH", body: updateTagDto @@ -2598,7 +2598,7 @@ export function untagAssets({ id, assetIdsDto }: { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; data: AssetIdsResponseDto[]; - }>(`/tag/${encodeURIComponent(id)}/assets`, oazapfts.json({ + }>(`/tags/${encodeURIComponent(id)}/assets`, oazapfts.json({ ...opts, method: "DELETE", body: assetIdsDto @@ -2610,7 +2610,7 @@ export function getTagAssets({ id }: { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; data: AssetResponseDto[]; - }>(`/tag/${encodeURIComponent(id)}/assets`, { + }>(`/tags/${encodeURIComponent(id)}/assets`, { ...opts })); } @@ -2621,7 +2621,7 @@ export function tagAssets({ id, assetIdsDto }: { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; data: AssetIdsResponseDto[]; - }>(`/tag/${encodeURIComponent(id)}/assets`, oazapfts.json({ + }>(`/tags/${encodeURIComponent(id)}/assets`, oazapfts.json({ ...opts, method: "PUT", body: assetIdsDto @@ -2720,7 +2720,7 @@ export function getAllUsers({ isAll }: { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; data: UserResponseDto[]; - }>(`/user${QS.query(QS.explode({ + }>(`/users${QS.query(QS.explode({ isAll }))}`, { ...opts @@ -2732,7 +2732,7 @@ export function createUser({ createUserDto }: { return oazapfts.ok(oazapfts.fetchJson<{ status: 201; data: UserResponseDto; - }>("/user", oazapfts.json({ + }>("/users", oazapfts.json({ ...opts, method: "POST", body: createUserDto @@ -2744,7 +2744,7 @@ export function updateUser({ updateUserDto }: { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; data: UserResponseDto; - }>("/user", oazapfts.json({ + }>("/users", oazapfts.json({ ...opts, method: "PUT", body: updateUserDto @@ -2756,7 +2756,7 @@ export function getUserById({ id }: { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; data: UserResponseDto; - }>(`/user/info/${encodeURIComponent(id)}`, { + }>(`/users/info/${encodeURIComponent(id)}`, { ...opts })); } @@ -2764,12 +2764,12 @@ export function getMyUserInfo(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; data: UserResponseDto; - }>("/user/me", { + }>("/users/me", { ...opts })); } export function deleteProfileImage(opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchText("/user/profile-image", { + return oazapfts.ok(oazapfts.fetchText("/users/profile-image", { ...opts, method: "DELETE" })); @@ -2780,7 +2780,7 @@ export function createProfileImage({ createProfileImageDto }: { return oazapfts.ok(oazapfts.fetchJson<{ status: 201; data: CreateProfileImageResponseDto; - }>("/user/profile-image", oazapfts.multipart({ + }>("/users/profile-image", oazapfts.multipart({ ...opts, method: "POST", body: createProfileImageDto @@ -2792,7 +2792,7 @@ export function getProfileImage({ id }: { return oazapfts.ok(oazapfts.fetchBlob<{ status: 200; data: Blob; - }>(`/user/profile-image/${encodeURIComponent(id)}`, { + }>(`/users/profile-image/${encodeURIComponent(id)}`, { ...opts })); } @@ -2803,7 +2803,7 @@ export function deleteUser({ id, deleteUserDto }: { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; data: UserResponseDto; - }>(`/user/${encodeURIComponent(id)}`, oazapfts.json({ + }>(`/users/${encodeURIComponent(id)}`, oazapfts.json({ ...opts, method: "DELETE", body: deleteUserDto @@ -2815,7 +2815,7 @@ export function restoreUser({ id }: { return oazapfts.ok(oazapfts.fetchJson<{ status: 201; data: UserResponseDto; - }>(`/user/${encodeURIComponent(id)}/restore`, { + }>(`/users/${encodeURIComponent(id)}/restore`, { ...opts, method: "POST" })); diff --git a/server/src/controllers/activity.controller.ts b/server/src/controllers/activity.controller.ts index 9a5fc41885..de59437a89 100644 --- a/server/src/controllers/activity.controller.ts +++ b/server/src/controllers/activity.controller.ts @@ -14,7 +14,7 @@ import { ActivityService } from 'src/services/activity.service'; import { UUIDParamDto } from 'src/validation'; @ApiTags('Activity') -@Controller('activity') +@Controller('activities') export class ActivityController { constructor(private service: ActivityService) {} diff --git a/server/src/controllers/album.controller.ts b/server/src/controllers/album.controller.ts index c38f733b42..ea42ed4d79 100644 --- a/server/src/controllers/album.controller.ts +++ b/server/src/controllers/album.controller.ts @@ -17,7 +17,7 @@ import { AlbumService } from 'src/services/album.service'; import { ParseMeUUIDPipe, UUIDParamDto } from 'src/validation'; @ApiTags('Album') -@Controller('album') +@Controller('albums') export class AlbumController { constructor(private service: AlbumService) {} diff --git a/server/src/controllers/api-key.controller.ts b/server/src/controllers/api-key.controller.ts index e0b07ede50..4225bdc1bc 100644 --- a/server/src/controllers/api-key.controller.ts +++ b/server/src/controllers/api-key.controller.ts @@ -7,7 +7,7 @@ import { APIKeyService } from 'src/services/api-key.service'; import { UUIDParamDto } from 'src/validation'; @ApiTags('API Key') -@Controller('api-key') +@Controller('api-keys') export class APIKeyController { constructor(private service: APIKeyService) {} diff --git a/server/src/controllers/face.controller.ts b/server/src/controllers/face.controller.ts index 5b45432944..cb4bc080c8 100644 --- a/server/src/controllers/face.controller.ts +++ b/server/src/controllers/face.controller.ts @@ -7,7 +7,7 @@ import { PersonService } from 'src/services/person.service'; import { UUIDParamDto } from 'src/validation'; @ApiTags('Face') -@Controller('face') +@Controller('faces') export class FaceController { constructor(private service: PersonService) {} diff --git a/server/src/controllers/file-report.controller.ts b/server/src/controllers/file-report.controller.ts index 1f9ebe52dd..523debfb4c 100644 --- a/server/src/controllers/file-report.controller.ts +++ b/server/src/controllers/file-report.controller.ts @@ -5,7 +5,7 @@ import { Authenticated } from 'src/middleware/auth.guard'; import { AuditService } from 'src/services/audit.service'; @ApiTags('File Report') -@Controller('report') +@Controller('reports') export class ReportController { constructor(private service: AuditService) {} diff --git a/server/src/controllers/library.controller.ts b/server/src/controllers/library.controller.ts index adbae8af0f..b6d65874ca 100644 --- a/server/src/controllers/library.controller.ts +++ b/server/src/controllers/library.controller.ts @@ -14,7 +14,7 @@ import { LibraryService } from 'src/services/library.service'; import { UUIDParamDto } from 'src/validation'; @ApiTags('Library') -@Controller('library') +@Controller('libraries') export class LibraryController { constructor(private service: LibraryService) {} diff --git a/server/src/controllers/partner.controller.ts b/server/src/controllers/partner.controller.ts index 1faf82898c..102f6f10ce 100644 --- a/server/src/controllers/partner.controller.ts +++ b/server/src/controllers/partner.controller.ts @@ -8,7 +8,7 @@ import { PartnerService } from 'src/services/partner.service'; import { UUIDParamDto } from 'src/validation'; @ApiTags('Partner') -@Controller('partner') +@Controller('partners') export class PartnerController { constructor(private service: PartnerService) {} diff --git a/server/src/controllers/person.controller.ts b/server/src/controllers/person.controller.ts index 1d3371f82a..26f9df2e1f 100644 --- a/server/src/controllers/person.controller.ts +++ b/server/src/controllers/person.controller.ts @@ -22,7 +22,7 @@ import { sendFile } from 'src/utils/file'; import { UUIDParamDto } from 'src/validation'; @ApiTags('Person') -@Controller('person') +@Controller('people') export class PersonController { constructor( private service: PersonService, diff --git a/server/src/controllers/shared-link.controller.ts b/server/src/controllers/shared-link.controller.ts index 64d3e38e7e..a7be1911d9 100644 --- a/server/src/controllers/shared-link.controller.ts +++ b/server/src/controllers/shared-link.controller.ts @@ -17,7 +17,7 @@ import { respondWithCookie } from 'src/utils/response'; import { UUIDParamDto } from 'src/validation'; @ApiTags('Shared Link') -@Controller('shared-link') +@Controller('shared-links') export class SharedLinkController { constructor(private service: SharedLinkService) {} diff --git a/server/src/controllers/tag.controller.ts b/server/src/controllers/tag.controller.ts index 2a46fdec71..1f8a44dd5b 100644 --- a/server/src/controllers/tag.controller.ts +++ b/server/src/controllers/tag.controller.ts @@ -10,7 +10,7 @@ import { TagService } from 'src/services/tag.service'; import { UUIDParamDto } from 'src/validation'; @ApiTags('Tag') -@Controller('tag') +@Controller('tags') export class TagController { constructor(private service: TagService) {} diff --git a/server/src/middleware/file-upload.interceptor.ts b/server/src/middleware/file-upload.interceptor.ts index 1b8405fe6e..6af502786e 100644 --- a/server/src/middleware/file-upload.interceptor.ts +++ b/server/src/middleware/file-upload.interceptor.ts @@ -13,7 +13,7 @@ import { AssetService, UploadFile } from 'src/services/asset.service'; export enum Route { ASSET = 'asset', - USER = 'user', + USER = 'users', } export interface ImmichFile extends Express.Multer.File { diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts index 2baabd0a44..5c055b875d 100644 --- a/web/src/lib/utils.ts +++ b/web/src/lib/utils.ts @@ -174,7 +174,7 @@ export const getProfileImageUrl = (...[userId]: [string]) => { }; export const getPeopleThumbnailUrl = (personId: string) => { - const path = `/person/${personId}/thumbnail`; + const path = `/people/${personId}/thumbnail`; return createUrl(path); }; From ecd018a82685b5be065b3b07b740b68d9bd4005d Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Wed, 22 May 2024 14:15:33 -0400 Subject: [PATCH 130/163] refactor(server): user info endpoint (#9668) * refactor(server): user info endpoint * chore: open api --- e2e/src/api/specs/user.e2e-spec.ts | 6 +- mobile/openapi/README.md | 2 +- mobile/openapi/lib/api/user_api.dart | 4 +- open-api/immich-openapi-specs.json | 82 ++++++++++----------- open-api/typescript-sdk/src/fetch-client.ts | 20 ++--- server/src/controllers/user.controller.ts | 16 ++-- 6 files changed, 64 insertions(+), 66 deletions(-) diff --git a/e2e/src/api/specs/user.e2e-spec.ts b/e2e/src/api/specs/user.e2e-spec.ts index 7518e732ec..08b2d34ef6 100644 --- a/e2e/src/api/specs/user.e2e-spec.ts +++ b/e2e/src/api/specs/user.e2e-spec.ts @@ -93,15 +93,15 @@ describe('/users', () => { }); }); - describe('GET /users/info/:id', () => { + describe('GET /users/:id', () => { it('should require authentication', async () => { - const { status } = await request(app).get(`/users/info/${admin.userId}`); + const { status } = await request(app).get(`/users/${admin.userId}`); expect(status).toEqual(401); }); it('should get the user info', async () => { const { status, body } = await request(app) - .get(`/users/info/${admin.userId}`) + .get(`/users/${admin.userId}`) .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(200); expect(body).toMatchObject({ diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index e23b1fea76..ad38d3f49e 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -220,7 +220,7 @@ Class | Method | HTTP request | Description *UserApi* | [**getAllUsers**](doc//UserApi.md#getallusers) | **GET** /users | *UserApi* | [**getMyUserInfo**](doc//UserApi.md#getmyuserinfo) | **GET** /users/me | *UserApi* | [**getProfileImage**](doc//UserApi.md#getprofileimage) | **GET** /users/profile-image/{id} | -*UserApi* | [**getUserById**](doc//UserApi.md#getuserbyid) | **GET** /users/info/{id} | +*UserApi* | [**getUserById**](doc//UserApi.md#getuserbyid) | **GET** /users/{id} | *UserApi* | [**restoreUser**](doc//UserApi.md#restoreuser) | **POST** /users/{id}/restore | *UserApi* | [**updateUser**](doc//UserApi.md#updateuser) | **PUT** /users | diff --git a/mobile/openapi/lib/api/user_api.dart b/mobile/openapi/lib/api/user_api.dart index 1f070d436f..09c0504710 100644 --- a/mobile/openapi/lib/api/user_api.dart +++ b/mobile/openapi/lib/api/user_api.dart @@ -346,13 +346,13 @@ class UserApi { return null; } - /// Performs an HTTP 'GET /users/info/{id}' operation and returns the [Response]. + /// Performs an HTTP 'GET /users/{id}' operation and returns the [Response]. /// Parameters: /// /// * [String] id (required): Future getUserByIdWithHttpInfo(String id,) async { // ignore: prefer_const_declarations - final path = r'/users/info/{id}' + final path = r'/users/{id}' .replaceAll('{id}', id); // ignore: prefer_final_locals diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 929338b734..cb334534bd 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -6229,48 +6229,6 @@ ] } }, - "/users/info/{id}": { - "get": { - "operationId": "getUserById", - "parameters": [ - { - "name": "id", - "required": true, - "in": "path", - "schema": { - "format": "uuid", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UserResponseDto" - } - } - }, - "description": "" - } - }, - "security": [ - { - "bearer": [] - }, - { - "cookie": [] - }, - { - "api_key": [] - } - ], - "tags": [ - "User" - ] - } - }, "/users/me": { "get": { "operationId": "getMyUserInfo", @@ -6462,6 +6420,46 @@ "tags": [ "User" ] + }, + "get": { + "operationId": "getUserById", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "User" + ] } }, "/users/{id}/restore": { diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 02ff002f01..29a8f5c0fe 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -2750,16 +2750,6 @@ export function updateUser({ updateUserDto }: { body: updateUserDto }))); } -export function getUserById({ id }: { - id: string; -}, opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchJson<{ - status: 200; - data: UserResponseDto; - }>(`/users/info/${encodeURIComponent(id)}`, { - ...opts - })); -} export function getMyUserInfo(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; @@ -2809,6 +2799,16 @@ export function deleteUser({ id, deleteUserDto }: { body: deleteUserDto }))); } +export function getUserById({ id }: { + id: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: UserResponseDto; + }>(`/users/${encodeURIComponent(id)}`, { + ...opts + })); +} export function restoreUser({ id }: { id: string; }, opts?: Oazapfts.RequestOpts) { diff --git a/server/src/controllers/user.controller.ts b/server/src/controllers/user.controller.ts index a1720dd404..4c058e7aae 100644 --- a/server/src/controllers/user.controller.ts +++ b/server/src/controllers/user.controller.ts @@ -41,10 +41,10 @@ export class UserController { return this.service.getAll(auth, isAll); } - @Get('info/:id') - @Authenticated() - getUserById(@Param() { id }: UUIDParamDto): Promise { - return this.service.get(id); + @Post() + @Authenticated({ admin: true }) + createUser(@Body() createUserDto: CreateUserDto): Promise { + return this.service.create(createUserDto); } @Get('me') @@ -53,10 +53,10 @@ export class UserController { return this.service.getMe(auth); } - @Post() - @Authenticated({ admin: true }) - createUser(@Body() createUserDto: CreateUserDto): Promise { - return this.service.create(createUserDto); + @Get(':id') + @Authenticated() + getUserById(@Param() { id }: UUIDParamDto): Promise { + return this.service.get(id); } @Delete('profile-image') From 8f37784eae09f6f56389eb099cc26de9d862d051 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Wed, 22 May 2024 14:31:12 -0400 Subject: [PATCH 131/163] refactor(server): /user profile endpoint (#9669) * refactor(server): user profile endpoint * chore: open api --- mobile/lib/widgets/common/user_avatar.dart | 2 +- .../widgets/common/user_circle_avatar.dart | 2 +- mobile/openapi/README.md | 2 +- mobile/openapi/lib/api/user_api.dart | 4 +- open-api/immich-openapi-specs.json | 86 +++++++++---------- open-api/typescript-sdk/src/fetch-client.ts | 20 ++--- server/src/controllers/user.controller.ts | 2 +- web/src/lib/utils.ts | 2 +- 8 files changed, 60 insertions(+), 60 deletions(-) diff --git a/mobile/lib/widgets/common/user_avatar.dart b/mobile/lib/widgets/common/user_avatar.dart index c61a3adbeb..8dfe00b2b9 100644 --- a/mobile/lib/widgets/common/user_avatar.dart +++ b/mobile/lib/widgets/common/user_avatar.dart @@ -6,7 +6,7 @@ import 'package:immich_mobile/entities/user.entity.dart'; Widget userAvatar(BuildContext context, User u, {double? radius}) { final url = - "${Store.get(StoreKey.serverEndpoint)}/user/profile-image/${u.id}"; + "${Store.get(StoreKey.serverEndpoint)}/users/${u.id}/profile-image"; final nameFirstLetter = u.name.isNotEmpty ? u.name[0] : ""; return CircleAvatar( radius: radius, diff --git a/mobile/lib/widgets/common/user_circle_avatar.dart b/mobile/lib/widgets/common/user_circle_avatar.dart index 1f8529a80a..9dd924d98e 100644 --- a/mobile/lib/widgets/common/user_circle_avatar.dart +++ b/mobile/lib/widgets/common/user_circle_avatar.dart @@ -24,7 +24,7 @@ class UserCircleAvatar extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { bool isDarkTheme = Theme.of(context).brightness == Brightness.dark; final profileImageUrl = - '${Store.get(StoreKey.serverEndpoint)}/user/profile-image/${user.id}?d=${Random().nextInt(1024)}'; + '${Store.get(StoreKey.serverEndpoint)}/users/${user.id}/profile-image?d=${Random().nextInt(1024)}'; final textIcon = Text( user.name[0].toUpperCase(), diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index ad38d3f49e..c3a4921601 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -219,7 +219,7 @@ Class | Method | HTTP request | Description *UserApi* | [**deleteUser**](doc//UserApi.md#deleteuser) | **DELETE** /users/{id} | *UserApi* | [**getAllUsers**](doc//UserApi.md#getallusers) | **GET** /users | *UserApi* | [**getMyUserInfo**](doc//UserApi.md#getmyuserinfo) | **GET** /users/me | -*UserApi* | [**getProfileImage**](doc//UserApi.md#getprofileimage) | **GET** /users/profile-image/{id} | +*UserApi* | [**getProfileImage**](doc//UserApi.md#getprofileimage) | **GET** /users/{id}/profile-image | *UserApi* | [**getUserById**](doc//UserApi.md#getuserbyid) | **GET** /users/{id} | *UserApi* | [**restoreUser**](doc//UserApi.md#restoreuser) | **POST** /users/{id}/restore | *UserApi* | [**updateUser**](doc//UserApi.md#updateuser) | **PUT** /users | diff --git a/mobile/openapi/lib/api/user_api.dart b/mobile/openapi/lib/api/user_api.dart index 09c0504710..301169cb9a 100644 --- a/mobile/openapi/lib/api/user_api.dart +++ b/mobile/openapi/lib/api/user_api.dart @@ -298,13 +298,13 @@ class UserApi { return null; } - /// Performs an HTTP 'GET /users/profile-image/{id}' operation and returns the [Response]. + /// Performs an HTTP 'GET /users/{id}/profile-image' operation and returns the [Response]. /// Parameters: /// /// * [String] id (required): Future getProfileImageWithHttpInfo(String id,) async { // ignore: prefer_const_declarations - final path = r'/users/profile-image/{id}' + final path = r'/users/{id}/profile-image' .replaceAll('{id}', id); // ignore: prefer_final_locals diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index cb334534bd..03dafc33cf 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -6327,49 +6327,6 @@ ] } }, - "/users/profile-image/{id}": { - "get": { - "operationId": "getProfileImage", - "parameters": [ - { - "name": "id", - "required": true, - "in": "path", - "schema": { - "format": "uuid", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/octet-stream": { - "schema": { - "format": "binary", - "type": "string" - } - } - }, - "description": "" - } - }, - "security": [ - { - "bearer": [] - }, - { - "cookie": [] - }, - { - "api_key": [] - } - ], - "tags": [ - "User" - ] - } - }, "/users/{id}": { "delete": { "operationId": "deleteUser", @@ -6462,6 +6419,49 @@ ] } }, + "/users/{id}/profile-image": { + "get": { + "operationId": "getProfileImage", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/octet-stream": { + "schema": { + "format": "binary", + "type": "string" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "User" + ] + } + }, "/users/{id}/restore": { "post": { "operationId": "restoreUser", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 29a8f5c0fe..b830f0c280 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -2776,16 +2776,6 @@ export function createProfileImage({ createProfileImageDto }: { body: createProfileImageDto }))); } -export function getProfileImage({ id }: { - id: string; -}, opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchBlob<{ - status: 200; - data: Blob; - }>(`/users/profile-image/${encodeURIComponent(id)}`, { - ...opts - })); -} export function deleteUser({ id, deleteUserDto }: { id: string; deleteUserDto: DeleteUserDto; @@ -2809,6 +2799,16 @@ export function getUserById({ id }: { ...opts })); } +export function getProfileImage({ id }: { + id: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchBlob<{ + status: 200; + data: Blob; + }>(`/users/${encodeURIComponent(id)}/profile-image`, { + ...opts + })); +} export function restoreUser({ id }: { id: string; }, opts?: Oazapfts.RequestOpts) { diff --git a/server/src/controllers/user.controller.ts b/server/src/controllers/user.controller.ts index 4c058e7aae..1b995c5944 100644 --- a/server/src/controllers/user.controller.ts +++ b/server/src/controllers/user.controller.ts @@ -101,7 +101,7 @@ export class UserController { return this.service.createProfileImage(auth, fileInfo); } - @Get('profile-image/:id') + @Get(':id/profile-image') @FileResponse() @Authenticated() async getProfileImage(@Res() res: Response, @Next() next: NextFunction, @Param() { id }: UUIDParamDto) { diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts index 5c055b875d..60add8ae7a 100644 --- a/web/src/lib/utils.ts +++ b/web/src/lib/utils.ts @@ -169,7 +169,7 @@ export const getAssetThumbnailUrl = (...[assetId, format]: [string, ThumbnailFor }; export const getProfileImageUrl = (...[userId]: [string]) => { - const path = `/user/profile-image/${userId}`; + const path = `/users/${userId}/profile-image`; return createUrl(path); }; From 967d195a0519c3f0ce6ddbe02e4467cf1e3beb96 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Wed, 22 May 2024 15:53:57 -0400 Subject: [PATCH 132/163] chore(server): remove unused code (#9670) --- server/src/dtos/user.dto.spec.ts | 18 +----------------- server/src/dtos/user.dto.ts | 15 --------------- 2 files changed, 1 insertion(+), 32 deletions(-) diff --git a/server/src/dtos/user.dto.spec.ts b/server/src/dtos/user.dto.spec.ts index d07399f0ef..95e625a1a8 100644 --- a/server/src/dtos/user.dto.spec.ts +++ b/server/src/dtos/user.dto.spec.ts @@ -1,6 +1,6 @@ import { plainToInstance } from 'class-transformer'; import { validate } from 'class-validator'; -import { CreateAdminDto, CreateUserDto, CreateUserOAuthDto, UpdateUserDto } from 'src/dtos/user.dto'; +import { CreateUserDto, CreateUserOAuthDto, UpdateUserDto } from 'src/dtos/user.dto'; describe('update user DTO', () => { it('should allow emails without a tld', async () => { @@ -52,22 +52,6 @@ describe('create user DTO', () => { }); }); -describe('create admin DTO', () => { - it('should allow emails without a tld', async () => { - const someEmail = 'test@test'; - - const dto = plainToInstance(CreateAdminDto, { - isAdmin: true, - email: someEmail, - password: 'some password', - name: 'some name', - }); - const errors = await validate(dto); - expect(errors).toHaveLength(0); - expect(dto.email).toEqual(someEmail); - }); -}); - describe('create user oauth DTO', () => { it('should allow emails without a tld', async () => { const someEmail = 'test@test'; diff --git a/server/src/dtos/user.dto.ts b/server/src/dtos/user.dto.ts index 21cc9ae2c3..18b9d07b08 100644 --- a/server/src/dtos/user.dto.ts +++ b/server/src/dtos/user.dto.ts @@ -41,21 +41,6 @@ export class CreateUserDto { notify?: boolean; } -export class CreateAdminDto { - @IsNotEmpty() - isAdmin!: true; - - @IsEmail({ require_tld: false }) - @Transform(({ value }) => value?.toLowerCase()) - email!: string; - - @IsNotEmpty() - password!: string; - - @IsNotEmpty() - name!: string; -} - export class CreateUserOAuthDto { @IsEmail({ require_tld: false }) @Transform(({ value }) => value?.toLowerCase()) From 13cbdf6851768f763163568a3f64be9fa8b83fce Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Wed, 22 May 2024 16:23:47 -0400 Subject: [PATCH 133/163] refactor(server): cli service (#9672) --- server/src/commands/list-users.command.ts | 6 +- server/src/commands/oauth-login.ts | 14 ++-- server/src/commands/password-login.ts | 14 ++-- .../commands/reset-admin-password.command.ts | 34 ++++----- server/src/services/cli.service.spec.ts | 72 +++++++++++++++++++ server/src/services/cli.service.ts | 70 ++++++++++++++++++ server/src/services/index.ts | 2 + server/src/services/user.service.spec.ts | 41 +---------- server/src/services/user.service.ts | 14 ---- 9 files changed, 176 insertions(+), 91 deletions(-) create mode 100644 server/src/services/cli.service.spec.ts create mode 100644 server/src/services/cli.service.ts diff --git a/server/src/commands/list-users.command.ts b/server/src/commands/list-users.command.ts index ea3e745463..299ea82283 100644 --- a/server/src/commands/list-users.command.ts +++ b/server/src/commands/list-users.command.ts @@ -1,18 +1,18 @@ import { Command, CommandRunner } from 'nest-commander'; -import { UserService } from 'src/services/user.service'; +import { CliService } from 'src/services/cli.service'; @Command({ name: 'list-users', description: 'List Immich users', }) export class ListUsersCommand extends CommandRunner { - constructor(private userService: UserService) { + constructor(private service: CliService) { super(); } async run(): Promise { try { - const users = await this.userService.listUsers(); + const users = await this.service.listUsers(); console.dir(users); } catch (error) { console.error(error); diff --git a/server/src/commands/oauth-login.ts b/server/src/commands/oauth-login.ts index c9bb4d5ef4..9ec7013fa5 100644 --- a/server/src/commands/oauth-login.ts +++ b/server/src/commands/oauth-login.ts @@ -1,19 +1,17 @@ import { Command, CommandRunner } from 'nest-commander'; -import { SystemConfigService } from 'src/services/system-config.service'; +import { CliService } from 'src/services/cli.service'; @Command({ name: 'enable-oauth-login', description: 'Enable OAuth login', }) export class EnableOAuthLogin extends CommandRunner { - constructor(private configService: SystemConfigService) { + constructor(private service: CliService) { super(); } async run(): Promise { - const config = await this.configService.getConfig(); - config.oauth.enabled = true; - await this.configService.updateConfig(config); + await this.service.enableOAuthLogin(); console.log('OAuth login has been enabled.'); } } @@ -23,14 +21,12 @@ export class EnableOAuthLogin extends CommandRunner { description: 'Disable OAuth login', }) export class DisableOAuthLogin extends CommandRunner { - constructor(private configService: SystemConfigService) { + constructor(private service: CliService) { super(); } async run(): Promise { - const config = await this.configService.getConfig(); - config.oauth.enabled = false; - await this.configService.updateConfig(config); + await this.service.disableOAuthLogin(); console.log('OAuth login has been disabled.'); } } diff --git a/server/src/commands/password-login.ts b/server/src/commands/password-login.ts index 3d992f8583..057abd1649 100644 --- a/server/src/commands/password-login.ts +++ b/server/src/commands/password-login.ts @@ -1,19 +1,17 @@ import { Command, CommandRunner } from 'nest-commander'; -import { SystemConfigService } from 'src/services/system-config.service'; +import { CliService } from 'src/services/cli.service'; @Command({ name: 'enable-password-login', description: 'Enable password login', }) export class EnablePasswordLoginCommand extends CommandRunner { - constructor(private configService: SystemConfigService) { + constructor(private service: CliService) { super(); } async run(): Promise { - const config = await this.configService.getConfig(); - config.passwordLogin.enabled = true; - await this.configService.updateConfig(config); + await this.service.enablePasswordLogin(); console.log('Password login has been enabled.'); } } @@ -23,14 +21,12 @@ export class EnablePasswordLoginCommand extends CommandRunner { description: 'Disable password login', }) export class DisablePasswordLoginCommand extends CommandRunner { - constructor(private configService: SystemConfigService) { + constructor(private service: CliService) { super(); } async run(): Promise { - const config = await this.configService.getConfig(); - config.passwordLogin.enabled = false; - await this.configService.updateConfig(config); + await this.service.disablePasswordLogin(); console.log('Password login has been disabled.'); } } diff --git a/server/src/commands/reset-admin-password.command.ts b/server/src/commands/reset-admin-password.command.ts index f7c0775c8b..32f77109b0 100644 --- a/server/src/commands/reset-admin-password.command.ts +++ b/server/src/commands/reset-admin-password.command.ts @@ -1,20 +1,9 @@ import { Command, CommandRunner, InquirerService, Question, QuestionSet } from 'nest-commander'; import { UserResponseDto } from 'src/dtos/user.dto'; -import { UserService } from 'src/services/user.service'; +import { CliService } from 'src/services/cli.service'; -@Command({ - name: 'reset-admin-password', - description: 'Reset the admin password', -}) -export class ResetAdminPasswordCommand extends CommandRunner { - constructor( - private userService: UserService, - private inquirer: InquirerService, - ) { - super(); - } - - ask = (admin: UserResponseDto) => { +const prompt = (inquirer: InquirerService) => { + return function ask(admin: UserResponseDto) { const { id, oauthId, email, name } = admin; console.log(`Found Admin: - ID=${id} @@ -22,12 +11,25 @@ export class ResetAdminPasswordCommand extends CommandRunner { - Email=${email} - Name=${name}`); - return this.inquirer.ask<{ password: string }>('prompt-password', {}).then(({ password }) => password); + return inquirer.ask<{ password: string }>('prompt-password', {}).then(({ password }) => password); }; +}; + +@Command({ + name: 'reset-admin-password', + description: 'Reset the admin password', +}) +export class ResetAdminPasswordCommand extends CommandRunner { + constructor( + private service: CliService, + private inquirer: InquirerService, + ) { + super(); + } async run(): Promise { try { - const { password, provided } = await this.userService.resetAdminPassword(this.ask); + const { password, provided } = await this.service.resetAdminPassword(prompt(this.inquirer)); if (provided) { console.log(`The admin password has been updated.`); diff --git a/server/src/services/cli.service.spec.ts b/server/src/services/cli.service.spec.ts new file mode 100644 index 0000000000..016045fc17 --- /dev/null +++ b/server/src/services/cli.service.spec.ts @@ -0,0 +1,72 @@ +import { ICryptoRepository } from 'src/interfaces/crypto.interface'; +import { ILibraryRepository } from 'src/interfaces/library.interface'; +import { ILoggerRepository } from 'src/interfaces/logger.interface'; +import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +import { IUserRepository } from 'src/interfaces/user.interface'; +import { CliService } from 'src/services/cli.service'; +import { userStub } from 'test/fixtures/user.stub'; +import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock'; +import { newLibraryRepositoryMock } from 'test/repositories/library.repository.mock'; +import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; +import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; +import { newUserRepositoryMock } from 'test/repositories/user.repository.mock'; +import { Mocked, describe, it } from 'vitest'; + +describe(CliService.name, () => { + let sut: CliService; + + let userMock: Mocked; + let cryptoMock: Mocked; + let libraryMock: Mocked; + let systemMock: Mocked; + let loggerMock: Mocked; + + beforeEach(() => { + cryptoMock = newCryptoRepositoryMock(); + libraryMock = newLibraryRepositoryMock(); + systemMock = newSystemMetadataRepositoryMock(); + userMock = newUserRepositoryMock(); + loggerMock = newLoggerRepositoryMock(); + + sut = new CliService(cryptoMock, libraryMock, systemMock, userMock, loggerMock); + }); + + describe('resetAdminPassword', () => { + it('should only work when there is an admin account', async () => { + userMock.getAdmin.mockResolvedValue(null); + const ask = vitest.fn().mockResolvedValue('new-password'); + + await expect(sut.resetAdminPassword(ask)).rejects.toThrowError('Admin account does not exist'); + + expect(ask).not.toHaveBeenCalled(); + }); + + it('should default to a random password', async () => { + userMock.getAdmin.mockResolvedValue(userStub.admin); + const ask = vitest.fn().mockImplementation(() => {}); + + const response = await sut.resetAdminPassword(ask); + + const [id, update] = userMock.update.mock.calls[0]; + + expect(response.provided).toBe(false); + expect(ask).toHaveBeenCalled(); + expect(id).toEqual(userStub.admin.id); + expect(update.password).toBeDefined(); + }); + + it('should use the supplied password', async () => { + userMock.getAdmin.mockResolvedValue(userStub.admin); + const ask = vitest.fn().mockResolvedValue('new-password'); + + const response = await sut.resetAdminPassword(ask); + + const [id, update] = userMock.update.mock.calls[0]; + + expect(response.provided).toBe(true); + expect(ask).toHaveBeenCalled(); + expect(id).toEqual(userStub.admin.id); + expect(update.password).toBeDefined(); + }); + }); +}); diff --git a/server/src/services/cli.service.ts b/server/src/services/cli.service.ts new file mode 100644 index 0000000000..d4d838d0c4 --- /dev/null +++ b/server/src/services/cli.service.ts @@ -0,0 +1,70 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { SystemConfigCore } from 'src/cores/system-config.core'; +import { UserCore } from 'src/cores/user.core'; +import { UserResponseDto, mapUser } from 'src/dtos/user.dto'; +import { ICryptoRepository } from 'src/interfaces/crypto.interface'; +import { ILibraryRepository } from 'src/interfaces/library.interface'; +import { ILoggerRepository } from 'src/interfaces/logger.interface'; +import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +import { IUserRepository } from 'src/interfaces/user.interface'; + +@Injectable() +export class CliService { + private configCore: SystemConfigCore; + private userCore: UserCore; + + constructor( + @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository, + @Inject(ILibraryRepository) libraryRepository: ILibraryRepository, + @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, + @Inject(IUserRepository) private userRepository: IUserRepository, + @Inject(ILoggerRepository) private logger: ILoggerRepository, + ) { + this.userCore = UserCore.create(cryptoRepository, libraryRepository, userRepository); + this.logger.setContext(CliService.name); + this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); + } + + async listUsers(): Promise { + const users = await this.userRepository.getList({ withDeleted: true }); + return users.map((user) => mapUser(user)); + } + + async resetAdminPassword(ask: (admin: UserResponseDto) => Promise) { + const admin = await this.userRepository.getAdmin(); + if (!admin) { + throw new Error('Admin account does not exist'); + } + + const providedPassword = await ask(mapUser(admin)); + const password = providedPassword || this.cryptoRepository.newPassword(24); + + await this.userCore.updateUser(admin, admin.id, { password }); + + return { admin, password, provided: !!providedPassword }; + } + + async disablePasswordLogin(): Promise { + const config = await this.configCore.getConfig(); + config.passwordLogin.enabled = false; + await this.configCore.updateConfig(config); + } + + async enablePasswordLogin(): Promise { + const config = await this.configCore.getConfig(); + config.passwordLogin.enabled = true; + await this.configCore.updateConfig(config); + } + + async disableOAuthLogin(): Promise { + const config = await this.configCore.getConfig(); + config.oauth.enabled = false; + await this.configCore.updateConfig(config); + } + + async enableOAuthLogin(): Promise { + const config = await this.configCore.getConfig(); + config.oauth.enabled = true; + await this.configCore.updateConfig(config); + } +} diff --git a/server/src/services/index.ts b/server/src/services/index.ts index c9331c00c7..76fe7244c0 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -6,6 +6,7 @@ import { AssetServiceV1 } from 'src/services/asset-v1.service'; import { AssetService } from 'src/services/asset.service'; import { AuditService } from 'src/services/audit.service'; import { AuthService } from 'src/services/auth.service'; +import { CliService } from 'src/services/cli.service'; import { DatabaseService } from 'src/services/database.service'; import { DownloadService } from 'src/services/download.service'; import { DuplicateService } from 'src/services/duplicate.service'; @@ -44,6 +45,7 @@ export const services = [ AssetServiceV1, AuditService, AuthService, + CliService, DatabaseService, DownloadService, DuplicateService, diff --git a/server/src/services/user.service.spec.ts b/server/src/services/user.service.spec.ts index e96cb0e663..b46984ce7e 100644 --- a/server/src/services/user.service.spec.ts +++ b/server/src/services/user.service.spec.ts @@ -27,7 +27,7 @@ import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.moc import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock'; import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; import { newUserRepositoryMock } from 'test/repositories/user.repository.mock'; -import { Mocked, vitest } from 'vitest'; +import { Mocked } from 'vitest'; const makeDeletedAt = (daysAgo: number) => { const deletedAt = new Date(); @@ -436,45 +436,6 @@ describe(UserService.name, () => { }); }); - describe('resetAdminPassword', () => { - it('should only work when there is an admin account', async () => { - userMock.getAdmin.mockResolvedValue(null); - const ask = vitest.fn().mockResolvedValue('new-password'); - - await expect(sut.resetAdminPassword(ask)).rejects.toBeInstanceOf(BadRequestException); - - expect(ask).not.toHaveBeenCalled(); - }); - - it('should default to a random password', async () => { - userMock.getAdmin.mockResolvedValue(userStub.admin); - const ask = vitest.fn().mockImplementation(() => {}); - - const response = await sut.resetAdminPassword(ask); - - const [id, update] = userMock.update.mock.calls[0]; - - expect(response.provided).toBe(false); - expect(ask).toHaveBeenCalled(); - expect(id).toEqual(userStub.admin.id); - expect(update.password).toBeDefined(); - }); - - it('should use the supplied password', async () => { - userMock.getAdmin.mockResolvedValue(userStub.admin); - const ask = vitest.fn().mockResolvedValue('new-password'); - - const response = await sut.resetAdminPassword(ask); - - const [id, update] = userMock.update.mock.calls[0]; - - expect(response.provided).toBe(true); - expect(ask).toHaveBeenCalled(); - expect(id).toEqual(userStub.admin.id); - expect(update.password).toBeDefined(); - }); - }); - describe('handleQueueUserDelete', () => { it('should skip users not ready for deletion', async () => { userMock.getDeletedUsers.mockResolvedValue([ diff --git a/server/src/services/user.service.ts b/server/src/services/user.service.ts index d546705d3c..e62df2225f 100644 --- a/server/src/services/user.service.ts +++ b/server/src/services/user.service.ts @@ -170,20 +170,6 @@ export class UserService { }); } - async resetAdminPassword(ask: (admin: UserResponseDto) => Promise) { - const admin = await this.userRepository.getAdmin(); - if (!admin) { - throw new BadRequestException('Admin account does not exist'); - } - - const providedPassword = await ask(mapUser(admin)); - const password = providedPassword || this.cryptoRepository.newPassword(24); - - await this.userCore.updateUser(admin, admin.id, { password }); - - return { admin, password, provided: !!providedPassword }; - } - async handleUserSyncUsage(): Promise { await this.userRepository.syncUsage(); return JobStatus.SUCCESS; From a5e8b451b2a5af7a937ec578d7d67cbecc6e9a97 Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Wed, 22 May 2024 23:58:29 -0400 Subject: [PATCH 134/163] feat(server): qsv hardware decoding and tone-mapping (#9689) * qsv hw decoding and tone-mapping * fix vaapi * add tests * formatting * handle device name without path --- server/src/services/media.service.spec.ts | 68 +++++++++++++++++++++++ server/src/services/media.service.ts | 7 ++- server/src/utils/media.ts | 65 ++++++++++++++++++++-- 3 files changed, 132 insertions(+), 8 deletions(-) diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts index 044ca764e7..3c8200944e 100644 --- a/server/src/services/media.service.spec.ts +++ b/server/src/services/media.service.spec.ts @@ -1482,6 +1482,74 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).not.toHaveBeenCalled(); }); + it('should use hardware decoding for qsv if enabled', async () => { + storageMock.readdir.mockResolvedValue(['renderD128']); + mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); + systemMock.get.mockResolvedValue({ + ffmpeg: { accel: TranscodeHWAccel.QSV, accelDecode: true }, + }); + 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.arrayContaining(['-hwaccel qsv', '-async_depth 4', '-threads 1']), + outputOptions: expect.arrayContaining([ + expect.stringContaining('scale_qsv=-1:720:async_depth=4:mode=hq:format=nv12'), + ]), + twoPass: false, + }, + ); + }); + + it('should use hardware tone-mapping for qsv if hardware decoding is enabled and should tone map', async () => { + storageMock.readdir.mockResolvedValue(['renderD128']); + mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); + systemMock.get.mockResolvedValue({ + ffmpeg: { accel: TranscodeHWAccel.QSV, accelDecode: true }, + }); + 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.arrayContaining(['-hwaccel qsv', '-async_depth 4', '-threads 1']), + outputOptions: expect.arrayContaining([ + expect.stringContaining( + 'hwmap=derive_device=opencl,tonemap_opencl=desat=0:format=nv12:matrix=bt709:primaries=bt709:range=pc:tonemap=hable:transfer=bt709,hwmap=derive_device=vaapi:reverse=1', + ), + ]), + twoPass: false, + }, + ); + }); + + it('should use preferred device for qsv when hardware decoding', async () => { + storageMock.readdir.mockResolvedValue(['renderD128', 'renderD129', 'renderD130']); + mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); + systemMock.get.mockResolvedValue({ + ffmpeg: { accel: TranscodeHWAccel.QSV, accelDecode: true, preferredHwDevice: 'renderD129' }, + }); + 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.arrayContaining(['-hwaccel qsv', '-qsv_device /dev/dri/renderD129']), + outputOptions: expect.any(Array), + twoPass: false, + }, + ); + }); + it('should set options for vaapi', async () => { storageMock.readdir.mockResolvedValue(['renderD128']); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index dc252b4c1c..7e52fe384c 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -38,7 +38,8 @@ import { HEVCConfig, NvencHwDecodeConfig, NvencSwDecodeConfig, - QSVConfig, + QsvHwDecodeConfig, + QsvSwDecodeConfig, RkmppHwDecodeConfig, RkmppSwDecodeConfig, ThumbnailConfig, @@ -499,7 +500,9 @@ export class MediaService { break; } case TranscodeHWAccel.QSV: { - handler = new QSVConfig(config, await this.getDevices()); + handler = config.accelDecode + ? new QsvHwDecodeConfig(config, await this.getDevices()) + : new QsvSwDecodeConfig(config, await this.getDevices()); break; } case TranscodeHWAccel.VAAPI: { diff --git a/server/src/utils/media.ts b/server/src/utils/media.ts index 6c51c0d303..268f1f60ce 100644 --- a/server/src/utils/media.ts +++ b/server/src/utils/media.ts @@ -302,10 +302,10 @@ export class BaseHWConfig extends BaseConfig implements VideoCodecHWConfig { return this.config.gopSize; } - getPreferredHardwareDevice(): string | null { + getPreferredHardwareDevice(): string | undefined { const device = this.config.preferredHwDevice; if (device === 'auto') { - return null; + return; } const deviceName = device.replace('/dev/dri/', ''); @@ -313,7 +313,7 @@ export class BaseHWConfig extends BaseConfig implements VideoCodecHWConfig { throw new Error(`Device '${device}' does not exist`); } - return device; + return `/dev/dri/${deviceName}`; } } @@ -567,7 +567,7 @@ export class NvencHwDecodeConfig extends NvencSwDecodeConfig { } } -export class QSVConfig extends BaseHWConfig { +export class QsvSwDecodeConfig extends BaseHWConfig { getBaseInputOptions() { if (this.devices.length === 0) { throw new Error('No QSV device found'); @@ -575,7 +575,7 @@ export class QSVConfig extends BaseHWConfig { let qsvString = ''; const hwDevice = this.getPreferredHardwareDevice(); - if (hwDevice !== null) { + if (hwDevice) { qsvString = `,child_device=${hwDevice}`; } @@ -643,6 +643,59 @@ export class QSVConfig extends BaseHWConfig { } } +export class QsvHwDecodeConfig extends QsvSwDecodeConfig { + getBaseInputOptions() { + if (this.devices.length === 0) { + throw new Error('No QSV device found'); + } + + const options = ['-hwaccel qsv', '-async_depth 4', '-threads 1']; + const hwDevice = this.getPreferredHardwareDevice(); + if (hwDevice) { + options.push(`-qsv_device ${hwDevice}`); + } + + return options; + } + + getFilterOptions(videoStream: VideoStreamInfo) { + const options = []; + if (this.shouldScale(videoStream) || !this.shouldToneMap(videoStream)) { + let scaling = `scale_qsv=${this.getScaling(videoStream)}:async_depth=4:mode=hq`; + if (!this.shouldToneMap(videoStream)) { + scaling += ':format=nv12'; + } + options.push(scaling); + } + + options.push(...this.getToneMapping(videoStream)); + return options; + } + + getToneMapping(videoStream: VideoStreamInfo): string[] { + if (!this.shouldToneMap(videoStream)) { + return []; + } + + const colors = this.getColors(); + const tonemapOptions = [ + 'desat=0', + 'format=nv12', + `matrix=${colors.matrix}`, + `primaries=${colors.primaries}`, + 'range=pc', + `tonemap=${this.config.tonemap}`, + `transfer=${colors.transfer}`, + ]; + + return [ + 'hwmap=derive_device=opencl', + `tonemap_opencl=${tonemapOptions.join(':')}`, + 'hwmap=derive_device=vaapi:reverse=1', + ]; + } +} + export class VAAPIConfig extends BaseHWConfig { getBaseInputOptions() { if (this.devices.length === 0) { @@ -650,7 +703,7 @@ export class VAAPIConfig extends BaseHWConfig { } let hwDevice = this.getPreferredHardwareDevice(); - if (hwDevice === null) { + if (!hwDevice) { hwDevice = `/dev/dri/${this.devices[0]}`; } From e7aa50425c430eb0dd059410ca452f963c2c95d8 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Thu, 23 May 2024 07:40:57 -0400 Subject: [PATCH 135/163] test: sync open api spec (#9687) test: sync spec file --- .github/workflows/test.yml | 15 +++++++++++-- Makefile | 2 +- misc/release/pump-version.sh | 2 ++ open-api/bin/generate-open-api.sh | 3 ++- open-api/bin/sync-spec-version.js | 9 -------- server/package.json | 3 ++- server/src/bin/sync-open-api.ts | 23 ++++++++++++++++++++ server/src/{utils/sql.ts => bin/sync-sql.ts} | 0 server/src/utils/misc.ts | 4 ++-- 9 files changed, 45 insertions(+), 16 deletions(-) delete mode 100644 open-api/bin/sync-spec-version.js create mode 100644 server/src/bin/sync-open-api.ts rename server/src/{utils/sql.ts => bin/sync-sql.ts} (100%) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7603c6fb7c..ab22f255ee 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -260,9 +260,18 @@ jobs: name: OpenAPI Clients runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install server dependencies + run: npm --prefix=server ci + + - name: Build the app + run: npm --prefix=server run build + - name: Run API generation run: make open-api + - name: Find file changes uses: tj-actions/verify-changed-files@v20 id: verify-changed-files @@ -270,6 +279,8 @@ jobs: files: | mobile/openapi open-api/typescript-sdk + open-api/immich-openapi-specs.json + - name: Verify files have not changed if: steps.verify-changed-files.outputs.files_changed == 'true' run: | @@ -332,7 +343,7 @@ jobs: exit 1 - name: Run SQL generation - run: npm run sql:generate + run: npm run sync:sql env: DB_URL: postgres://postgres:postgres@localhost:5432/immich diff --git a/Makefile b/Makefile index ede70237fe..b7fe03591c 100644 --- a/Makefile +++ b/Makefile @@ -37,7 +37,7 @@ open-api-typescript: cd ./open-api && bash ./bin/generate-open-api.sh typescript sql: - npm --prefix server run sql:generate + npm --prefix server run sync:sql attach-server: docker exec -it docker_immich-server_1 sh diff --git a/misc/release/pump-version.sh b/misc/release/pump-version.sh index 14f0b3a817..0d0ae4b9e2 100755 --- a/misc/release/pump-version.sh +++ b/misc/release/pump-version.sh @@ -62,6 +62,8 @@ fi if [ "$CURRENT_SERVER" != "$NEXT_SERVER" ]; then echo "Pumping Server: $CURRENT_SERVER => $NEXT_SERVER" npm --prefix server version "$SERVER_PUMP" + npm --prefix server ci + npm --prefix server run build make open-api npm --prefix open-api/typescript-sdk version "$SERVER_PUMP" npm --prefix web version "$SERVER_PUMP" diff --git a/open-api/bin/generate-open-api.sh b/open-api/bin/generate-open-api.sh index 4eb121102d..a00d57d0ae 100755 --- a/open-api/bin/generate-open-api.sh +++ b/open-api/bin/generate-open-api.sh @@ -24,7 +24,8 @@ function typescript { npm --prefix typescript-sdk ci && npm --prefix typescript-sdk run build } -node ./bin/sync-spec-version.js +# requires server to be built +npm run sync:open-api --prefix=../server if [[ $1 == 'dart' ]]; then dart diff --git a/open-api/bin/sync-spec-version.js b/open-api/bin/sync-spec-version.js deleted file mode 100644 index 98c52413e6..0000000000 --- a/open-api/bin/sync-spec-version.js +++ /dev/null @@ -1,9 +0,0 @@ -const spec = require('../immich-openapi-specs.json'); -const pkg = require('../../server/package.json'); -const path = require('path'); -const fs = require('fs'); -spec.info.version = pkg.version; -fs.writeFileSync( - path.join(__dirname, '../immich-openapi-specs.json'), - JSON.stringify(spec, null, 2) -); diff --git a/server/package.json b/server/package.json index 6265c6d928..e657ca0757 100644 --- a/server/package.json +++ b/server/package.json @@ -30,7 +30,8 @@ "typeorm:migrations:revert": "typeorm migration:revert -d ./dist/database.config.js", "typeorm:schema:drop": "typeorm query -d ./dist/database.config.js 'DROP schema public cascade; CREATE schema public;'", "typeorm:schema:reset": "npm run typeorm:schema:drop && npm run typeorm:migrations:run", - "sql:generate": "node ./dist/utils/sql.js", + "sync:open-api": "node ./dist/bin/sync-open-api.js", + "sync:sql": "node ./dist/bin/sync-sql.js", "email:dev": "email dev -p 3050 --dir src/emails" }, "dependencies": { diff --git a/server/src/bin/sync-open-api.ts b/server/src/bin/sync-open-api.ts new file mode 100644 index 0000000000..70e2bb8c35 --- /dev/null +++ b/server/src/bin/sync-open-api.ts @@ -0,0 +1,23 @@ +#!/usr/bin/env node +process.env.DB_URL = 'postgres://postgres:postgres@localhost:5432/immich'; +import { NestFactory } from '@nestjs/core'; +import { NestExpressApplication } from '@nestjs/platform-express'; +import { ApiModule } from 'src/app.module'; +import { useSwagger } from 'src/utils/misc'; + +const sync = async () => { + const app = await NestFactory.create(ApiModule, { preview: true }); + useSwagger(app, true); + await app.close(); +}; + +sync() + .then(() => { + console.log('Done'); + process.exit(0); + }) + .catch((error) => { + console.error(error); + console.log('Something went wrong'); + process.exit(1); + }); diff --git a/server/src/utils/sql.ts b/server/src/bin/sync-sql.ts similarity index 100% rename from server/src/utils/sql.ts rename to server/src/bin/sync-sql.ts diff --git a/server/src/utils/misc.ts b/server/src/utils/misc.ts index 910bc0962a..f5c0105c03 100644 --- a/server/src/utils/misc.ts +++ b/server/src/utils/misc.ts @@ -174,7 +174,7 @@ const patchOpenAPI = (document: OpenAPIObject) => { return document; }; -export const useSwagger = (app: INestApplication) => { +export const useSwagger = (app: INestApplication, force = false) => { const config = new DocumentBuilder() .setTitle('Immich') .setDescription('Immich API') @@ -211,7 +211,7 @@ export const useSwagger = (app: INestApplication) => { SwaggerModule.setup('doc', app, specification, customOptions); - if (isDev()) { + if (isDev() || force) { // Generate API Documentation only in development mode const outputPath = path.resolve(process.cwd(), '../open-api/immich-openapi-specs.json'); writeFileSync(outputPath, JSON.stringify(patchOpenAPI(specification), null, 2), { encoding: 'utf8' }); From 8bfa6769a5d4d3a4104170f84195d1637e9372c1 Mon Sep 17 00:00:00 2001 From: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Date: Thu, 23 May 2024 18:39:06 +0200 Subject: [PATCH 136/163] fix(web): hide detail panel for shared links with hidden metadata (#9700) --- .../asset-viewer/detail-panel.e2e-spec.ts | 46 +++++++++++++++++++ .../asset-viewer/asset-viewer.svelte | 6 +-- 2 files changed, 49 insertions(+), 3 deletions(-) create mode 100644 e2e/src/web/specs/asset-viewer/detail-panel.e2e-spec.ts diff --git a/e2e/src/web/specs/asset-viewer/detail-panel.e2e-spec.ts b/e2e/src/web/specs/asset-viewer/detail-panel.e2e-spec.ts new file mode 100644 index 0000000000..ad847dab15 --- /dev/null +++ b/e2e/src/web/specs/asset-viewer/detail-panel.e2e-spec.ts @@ -0,0 +1,46 @@ +import { AssetFileUploadResponseDto, LoginResponseDto, SharedLinkType } from '@immich/sdk'; +import { expect, test } from '@playwright/test'; +import { utils } from 'src/utils'; + +test.describe('Detail Panel', () => { + let admin: LoginResponseDto; + let asset: AssetFileUploadResponseDto; + + test.beforeAll(async () => { + utils.initSdk(); + await utils.resetDatabase(); + admin = await utils.adminSetup(); + asset = await utils.createAsset(admin.accessToken); + }); + + test('can be opened for shared links', async ({ page }) => { + const sharedLink = await utils.createSharedLink(admin.accessToken, { + type: SharedLinkType.Individual, + assetIds: [asset.id], + }); + await page.goto(`/share/${sharedLink.key}/photos/${asset.id}`); + await page.waitForSelector('#immich-asset-viewer'); + + await expect(page.getByRole('button', { name: 'Info' })).toBeVisible(); + await page.keyboard.press('i'); + await expect(page.locator('#detail-panel')).toBeVisible(); + await page.keyboard.press('i'); + await expect(page.locator('#detail-panel')).toHaveCount(0); + }); + + test('cannot be opened for shared links with hidden metadata', async ({ page }) => { + const sharedLink = await utils.createSharedLink(admin.accessToken, { + type: SharedLinkType.Individual, + assetIds: [asset.id], + showMetadata: false, + }); + await page.goto(`/share/${sharedLink.key}/photos/${asset.id}`); + await page.waitForSelector('#immich-asset-viewer'); + + await expect(page.getByRole('button', { name: 'Info' })).toHaveCount(0); + await page.keyboard.press('i'); + await expect(page.locator('#detail-panel')).toHaveCount(0); + await page.keyboard.press('i'); + await expect(page.locator('#detail-panel')).toHaveCount(0); + }); +}); diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index 01af94d71a..21e38bf0ec 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -88,7 +88,7 @@ let isShowProfileImageCrop = false; let sharedLink = getSharedLink(); let shouldShowDownloadButton = sharedLink ? sharedLink.allowDownload : !asset.isOffline; - let shouldShowDetailButton = asset.hasMetadata; + let enableDetailPanel = asset.hasMetadata; let shouldShowShareModal = !asset.isTrashed; let canCopyImagesToClipboard: boolean; let slideshowStateUnsubscribe: () => void; @@ -574,7 +574,7 @@ showZoomButton={asset.type === AssetTypeEnum.Image} showMotionPlayButton={!!asset.livePhotoVideoId} showDownloadButton={shouldShowDownloadButton} - showDetailButton={shouldShowDetailButton} + showDetailButton={enableDetailPanel} showSlideshow={!!assetStore} hasStackChildren={$stackAssetsStore.length > 0} showShareButton={shouldShowShareModal} @@ -701,7 +701,7 @@
{/if} - {#if $slideshowState === SlideshowState.None && $isShowDetail} + {#if enableDetailPanel && $slideshowState === SlideshowState.None && $isShowDetail}
Date: Thu, 23 May 2024 19:56:48 +0200 Subject: [PATCH 137/163] refactor(web): svelte actions (#9701) --- web/src/lib/{utils => actions}/autogrow.ts | 0 web/src/lib/{utils => actions}/click-outside.ts | 2 +- web/src/lib/{utils => actions}/focus-outside.ts | 0 web/src/lib/actions/focus.ts | 3 +++ web/src/lib/{utils => actions}/list-navigation.ts | 2 +- web/src/lib/{utils => actions}/shortcut.ts | 0 web/src/lib/components/album-page/album-description.svelte | 4 ++-- web/src/lib/components/album-page/album-title.svelte | 2 +- web/src/lib/components/album-page/album-viewer.svelte | 2 +- web/src/lib/components/asset-viewer/activity-viewer.svelte | 6 +++--- .../lib/components/asset-viewer/asset-viewer-nav-bar.svelte | 2 +- web/src/lib/components/asset-viewer/asset-viewer.svelte | 2 +- web/src/lib/components/asset-viewer/detail-panel.svelte | 6 +++--- web/src/lib/components/asset-viewer/photo-viewer.svelte | 2 +- web/src/lib/components/assets/thumbnail/thumbnail.svelte | 2 +- web/src/lib/components/elements/dropdown.svelte | 2 +- web/src/lib/components/faces-page/people-search.svelte | 5 +---- web/src/lib/components/memory-page/memory-viewer.svelte | 2 +- web/src/lib/components/photos-page/asset-grid.svelte | 2 +- .../components/photos-page/asset-select-context-menu.svelte | 2 +- .../shared-components/album-selection-modal.svelte | 2 ++ .../lib/components/shared-components/change-location.svelte | 4 ++-- web/src/lib/components/shared-components/combobox.svelte | 6 +++--- .../shared-components/context-menu/context-menu.svelte | 2 +- web/src/lib/components/shared-components/focus-trap.svelte | 2 +- .../components/shared-components/full-screen-modal.svelte | 2 +- .../shared-components/navigation-bar/navigation-bar.svelte | 2 +- .../shared-components/search-bar/search-bar.svelte | 6 +++--- .../[[photos=photos]]/[[assetId=id]]/+page.svelte | 2 +- web/src/routes/(user)/people/+page.svelte | 2 +- .../[[photos=photos]]/[[assetId=id]]/+page.svelte | 4 ++-- .../search/[[photos=photos]]/[[assetId=id]]/+page.svelte | 2 +- 32 files changed, 43 insertions(+), 41 deletions(-) rename web/src/lib/{utils => actions}/autogrow.ts (100%) rename web/src/lib/{utils => actions}/click-outside.ts (95%) rename web/src/lib/{utils => actions}/focus-outside.ts (100%) create mode 100644 web/src/lib/actions/focus.ts rename web/src/lib/{utils => actions}/list-navigation.ts (95%) rename web/src/lib/{utils => actions}/shortcut.ts (100%) diff --git a/web/src/lib/utils/autogrow.ts b/web/src/lib/actions/autogrow.ts similarity index 100% rename from web/src/lib/utils/autogrow.ts rename to web/src/lib/actions/autogrow.ts diff --git a/web/src/lib/utils/click-outside.ts b/web/src/lib/actions/click-outside.ts similarity index 95% rename from web/src/lib/utils/click-outside.ts rename to web/src/lib/actions/click-outside.ts index e04bc11fdf..ddb648a4f2 100644 --- a/web/src/lib/utils/click-outside.ts +++ b/web/src/lib/actions/click-outside.ts @@ -1,5 +1,5 @@ +import { matchesShortcut } from '$lib/actions/shortcut'; import type { ActionReturn } from 'svelte/action'; -import { matchesShortcut } from './shortcut'; interface Attributes { /** @deprecated */ diff --git a/web/src/lib/utils/focus-outside.ts b/web/src/lib/actions/focus-outside.ts similarity index 100% rename from web/src/lib/utils/focus-outside.ts rename to web/src/lib/actions/focus-outside.ts diff --git a/web/src/lib/actions/focus.ts b/web/src/lib/actions/focus.ts new file mode 100644 index 0000000000..81185625f7 --- /dev/null +++ b/web/src/lib/actions/focus.ts @@ -0,0 +1,3 @@ +export const initInput = (element: HTMLInputElement) => { + element.focus(); +}; diff --git a/web/src/lib/utils/list-navigation.ts b/web/src/lib/actions/list-navigation.ts similarity index 95% rename from web/src/lib/utils/list-navigation.ts rename to web/src/lib/actions/list-navigation.ts index dff958d757..b981f67521 100644 --- a/web/src/lib/utils/list-navigation.ts +++ b/web/src/lib/actions/list-navigation.ts @@ -1,5 +1,5 @@ +import { shortcuts } from '$lib/actions/shortcut'; import type { Action } from 'svelte/action'; -import { shortcuts } from './shortcut'; export const listNavigation: Action = (node, container: HTMLElement) => { const moveFocus = (direction: 'up' | 'down') => { diff --git a/web/src/lib/utils/shortcut.ts b/web/src/lib/actions/shortcut.ts similarity index 100% rename from web/src/lib/utils/shortcut.ts rename to web/src/lib/actions/shortcut.ts diff --git a/web/src/lib/components/album-page/album-description.svelte b/web/src/lib/components/album-page/album-description.svelte index 28e726593d..1d7add6883 100644 --- a/web/src/lib/components/album-page/album-description.svelte +++ b/web/src/lib/components/album-page/album-description.svelte @@ -1,8 +1,8 @@ @@ -136,6 +139,13 @@ + + + import Button from '$lib/components/elements/buttons/button.svelte'; + import Icon from '$lib/components/elements/icon.svelte'; + import { getAssetThumbnailUrl } from '$lib/utils'; + import { ThumbnailFormat, type AssetResponseDto, type DuplicateResponseDto, getAllAlbums } from '@immich/sdk'; + import { mdiCheck, mdiTrashCanOutline } from '@mdi/js'; + import { onMount } from 'svelte'; + import { s } from '$lib/utils'; + import { getAssetResolution, getFileSize } from '$lib/utils/asset-utils'; + import { sortBy } from 'lodash-es'; + + export let duplicate: DuplicateResponseDto; + export let onResolve: (duplicateAssetIds: string[], trashIds: string[]) => void; + + let selectedAssetIds = new Set(); + + $: trashCount = duplicate.assets.length - selectedAssetIds.size; + + onMount(() => { + const suggestedAsset = sortBy(duplicate.assets, (asset) => asset.exifInfo?.fileSizeInByte).pop(); + + if (!suggestedAsset) { + selectedAssetIds = new Set(duplicate.assets[0].id); + return; + } + + selectedAssetIds.add(suggestedAsset.id); + selectedAssetIds = selectedAssetIds; + }); + + const onSelectAsset = (asset: AssetResponseDto) => { + if (selectedAssetIds.has(asset.id)) { + selectedAssetIds.delete(asset.id); + } else { + selectedAssetIds.add(asset.id); + } + + selectedAssetIds = selectedAssetIds; + }; + + const handleResolve = () => { + const trashIds = duplicate.assets.map((asset) => asset.id).filter((id) => !selectedAssetIds.has(id)); + const duplicateAssetIds = duplicate.assets.map((asset) => asset.id); + onResolve(duplicateAssetIds, trashIds); + }; + + +
+
+ {#each duplicate.assets as asset, index (index)} + {@const isSelected = selectedAssetIds.has(asset.id)} + {@const isFromExternalLibrary = !!asset.libraryId} + {@const assetData = JSON.stringify(asset, null, 2)} + +
+ + + + + + + + + + + + + + + +
{asset.originalFileName}
{getAssetResolution(asset)} - {getFileSize(asset)}
+ {#await getAllAlbums({ assetId: asset.id })} + Scanning for album... + {:then albums} + {#if albums.length === 0} + Not in any album + {:else} + In {albums.length} album{s(albums.length)} + {/if} + {/await} +
+
+ {/each} +
+ + +
+ {#if trashCount === 0} + + {:else} + + {/if} +
+
diff --git a/web/src/lib/components/utilities-page/utilities-menu.svelte b/web/src/lib/components/utilities-page/utilities-menu.svelte new file mode 100644 index 0000000000..81759fd830 --- /dev/null +++ b/web/src/lib/components/utilities-page/utilities-menu.svelte @@ -0,0 +1,18 @@ + + +
+
+

ORGANIZE YOUR LIBRARY

+ + +
+
diff --git a/web/src/lib/constants.ts b/web/src/lib/constants.ts index ec393a57b9..a9b7b8929d 100644 --- a/web/src/lib/constants.ts +++ b/web/src/lib/constants.ts @@ -39,6 +39,9 @@ export enum AppRoute { AUTH_REGISTER = '/auth/register', AUTH_CHANGE_PASSWORD = '/auth/change-password', AUTH_ONBOARDING = '/auth/onboarding', + + UTILITIES = '/utilities', + DUPLICATES = '/utilities/duplicates', } export enum ProjectionType { diff --git a/web/src/lib/utils/asset-utils.ts b/web/src/lib/utils/asset-utils.ts index dc29375ddd..f94c8b4375 100644 --- a/web/src/lib/utils/asset-utils.ts +++ b/web/src/lib/utils/asset-utils.ts @@ -7,6 +7,7 @@ import { BucketPosition, isSelectingAllAssets, type AssetStore } from '$lib/stor import { downloadManager } from '$lib/stores/download'; import { downloadRequest, getKey, s } from '$lib/utils'; import { createAlbum } from '$lib/utils/album-utils'; +import { asByteUnitString } from '$lib/utils/byte-units'; import { encodeHTMLSpecialChars } from '$lib/utils/string-utils'; import { addAssetsToAlbum as addAssets, @@ -223,6 +224,21 @@ export function isFlipped(orientation?: string | null) { return value && (isRotated270CW(value) || isRotated90CW(value)); } +export function getFileSize(asset: AssetResponseDto): string { + const size = asset.exifInfo?.fileSizeInByte || 0; + return size > 0 ? asByteUnitString(size, undefined, 4) : 'Invalid Data'; +} + +export function getAssetResolution(asset: AssetResponseDto): string { + const { width, height } = getAssetRatio(asset); + + if (width === 235 && height === 235) { + return 'Invalid Data'; + } + + return `${width} x ${height}`; +} + /** * Returns aspect ratio for the asset */ diff --git a/web/src/routes/(user)/utilities/+page.svelte b/web/src/routes/(user)/utilities/+page.svelte new file mode 100644 index 0000000000..bf18b99436 --- /dev/null +++ b/web/src/routes/(user)/utilities/+page.svelte @@ -0,0 +1,15 @@ + + + +
+
+ +
+
+
diff --git a/web/src/routes/(user)/utilities/+page.ts b/web/src/routes/(user)/utilities/+page.ts new file mode 100644 index 0000000000..1a62d6ec3f --- /dev/null +++ b/web/src/routes/(user)/utilities/+page.ts @@ -0,0 +1,15 @@ +import { authenticate } from '$lib/utils/auth'; +import { getAssetInfoFromParam } from '$lib/utils/navigation'; +import type { PageLoad } from './$types'; + +export const load = (async ({ params }) => { + await authenticate(); + const asset = await getAssetInfoFromParam(params); + + return { + asset, + meta: { + title: 'Utilities', + }, + }; +}) satisfies PageLoad; 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 new file mode 100644 index 0000000000..fbb7ebca31 --- /dev/null +++ b/web/src/routes/(user)/utilities/[duplicates]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -0,0 +1,66 @@ + + + +
+ {#if data.duplicates && data.duplicates.length > 0} +
+

Resolve each group by indicating which, if any, are duplicates.

+
+ {#key data.duplicates[0].duplicateId} + + handleResolve(data.duplicates[0].duplicateId, duplicateAssetIds, trashIds)} + /> + {/key} + {:else} +

+ No duplicates were found. +

+ {/if} +
+
diff --git a/web/src/routes/(user)/utilities/[duplicates]/[[photos=photos]]/[[assetId=id]]/+page.ts b/web/src/routes/(user)/utilities/[duplicates]/[[photos=photos]]/[[assetId=id]]/+page.ts new file mode 100644 index 0000000000..67c33b85fd --- /dev/null +++ b/web/src/routes/(user)/utilities/[duplicates]/[[photos=photos]]/[[assetId=id]]/+page.ts @@ -0,0 +1,18 @@ +import { authenticate } from '$lib/utils/auth'; +import { getAssetInfoFromParam } from '$lib/utils/navigation'; +import { getAssetDuplicates } from '@immich/sdk'; +import type { PageLoad } from './$types'; + +export const load = (async ({ params }) => { + await authenticate(); + const asset = await getAssetInfoFromParam(params); + const duplicates = await getAssetDuplicates(); + + return { + asset, + duplicates, + meta: { + title: 'Duplicates', + }, + }; +}) satisfies PageLoad; From 76fdcc9863c32ee6f89d95cf9b4b594e01b07762 Mon Sep 17 00:00:00 2001 From: Lukas Date: Thu, 23 May 2024 23:16:38 +0200 Subject: [PATCH 139/163] fix(web): show api key copy button in Firefox (#9704) --- web/src/lib/components/forms/api-key-secret.svelte | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/web/src/lib/components/forms/api-key-secret.svelte b/web/src/lib/components/forms/api-key-secret.svelte index 247b65fff6..f01758de26 100644 --- a/web/src/lib/components/forms/api-key-secret.svelte +++ b/web/src/lib/components/forms/api-key-secret.svelte @@ -1,7 +1,7 @@ handleDone()}> @@ -32,9 +26,7 @@
- {#if canCopyImagesToClipboard} - - {/if} + From 4f21f6a2e1942048e420d67fcf96274701326bc8 Mon Sep 17 00:00:00 2001 From: Min Idzelis Date: Thu, 23 May 2024 20:26:22 -0400 Subject: [PATCH 140/163] feat: API operation replaceAsset, POST /api/asset/:id/file (#9684) * impl and unit tests for replaceAsset * Remove it.only * Typo in generated spec +regen * Remove unused dtos * Dto removal fallout/bugfix * fix - missed a line * sql:generate * Review comments * Unused imports * chore: clean up --------- Co-authored-by: Jason Rasmussen --- mobile/openapi/README.md | 3 + mobile/openapi/lib/api.dart | 2 + mobile/openapi/lib/api/asset_api.dart | 115 +++++++ mobile/openapi/lib/api_client.dart | 4 + mobile/openapi/lib/api_helper.dart | 3 + .../lib/model/asset_media_response_dto.dart | 106 +++++++ .../openapi/lib/model/asset_media_status.dart | 85 ++++++ open-api/immich-openapi-specs.json | 119 ++++++++ open-api/typescript-sdk/src/fetch-client.ts | 35 +++ .../src/controllers/asset-media.controller.ts | 56 ++++ server/src/controllers/asset-v1.controller.ts | 8 +- server/src/controllers/index.ts | 2 + server/src/dtos/asset-media-response.dto.ts | 11 + server/src/dtos/asset-media.dto.ts | 35 +++ server/src/interfaces/asset.interface.ts | 5 +- server/src/interfaces/job.interface.ts | 2 +- .../src/middleware/file-upload.interceptor.ts | 24 +- .../src/services/asset-media.service.spec.ts | 280 ++++++++++++++++++ server/src/services/asset-media.service.ts | 177 +++++++++++ server/src/services/asset-v1.service.ts | 2 +- server/src/services/asset.service.ts | 15 +- server/src/services/index.ts | 2 + server/src/services/job.service.ts | 2 +- server/test/fixtures/file.stub.ts | 15 + .../components/album-page/album-viewer.svelte | 2 +- .../asset-viewer/asset-viewer-nav-bar.svelte | 9 +- .../asset-viewer/asset-viewer.svelte | 12 +- .../asset-viewer/photo-viewer.svelte | 144 ++++----- .../asset-viewer/video-native-viewer.svelte | 14 +- .../asset-viewer/video-wrapper-viewer.svelte | 3 +- .../assets/thumbnail/thumbnail.svelte | 6 +- web/src/lib/models/upload-asset.ts | 1 + web/src/lib/utils.ts | 23 +- web/src/lib/utils/file-uploader.ts | 91 +++--- .../[[assetId=id]]/+page.svelte | 2 +- web/src/routes/auth/login/+page.ts | 5 +- 36 files changed, 1270 insertions(+), 150 deletions(-) create mode 100644 mobile/openapi/lib/model/asset_media_response_dto.dart create mode 100644 mobile/openapi/lib/model/asset_media_status.dart create mode 100644 server/src/controllers/asset-media.controller.ts create mode 100644 server/src/dtos/asset-media-response.dto.ts create mode 100644 server/src/dtos/asset-media.dto.ts create mode 100644 server/src/services/asset-media.service.spec.ts create mode 100644 server/src/services/asset-media.service.ts diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index c3a4921601..4b5ef2f0dd 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -104,6 +104,7 @@ Class | Method | HTTP request | Description *AssetApi* | [**getMapMarkers**](doc//AssetApi.md#getmapmarkers) | **GET** /asset/map-marker | *AssetApi* | [**getMemoryLane**](doc//AssetApi.md#getmemorylane) | **GET** /asset/memory-lane | *AssetApi* | [**getRandom**](doc//AssetApi.md#getrandom) | **GET** /asset/random | +*AssetApi* | [**replaceAsset**](doc//AssetApi.md#replaceasset) | **PUT** /asset/{id}/file | *AssetApi* | [**runAssetJobs**](doc//AssetApi.md#runassetjobs) | **POST** /asset/jobs | *AssetApi* | [**serveFile**](doc//AssetApi.md#servefile) | **GET** /asset/file/{id} | *AssetApi* | [**updateAsset**](doc//AssetApi.md#updateasset) | **PUT** /asset/{id} | @@ -261,6 +262,8 @@ Class | Method | HTTP request | Description - [AssetIdsResponseDto](doc//AssetIdsResponseDto.md) - [AssetJobName](doc//AssetJobName.md) - [AssetJobsDto](doc//AssetJobsDto.md) + - [AssetMediaResponseDto](doc//AssetMediaResponseDto.md) + - [AssetMediaStatus](doc//AssetMediaStatus.md) - [AssetOrder](doc//AssetOrder.md) - [AssetResponseDto](doc//AssetResponseDto.md) - [AssetStatsResponseDto](doc//AssetStatsResponseDto.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index be7c4a936e..e74fe8e03e 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -92,6 +92,8 @@ part 'model/asset_ids_dto.dart'; part 'model/asset_ids_response_dto.dart'; part 'model/asset_job_name.dart'; part 'model/asset_jobs_dto.dart'; +part 'model/asset_media_response_dto.dart'; +part 'model/asset_media_status.dart'; part 'model/asset_order.dart'; part 'model/asset_response_dto.dart'; part 'model/asset_stats_response_dto.dart'; diff --git a/mobile/openapi/lib/api/asset_api.dart b/mobile/openapi/lib/api/asset_api.dart index f3c8389ab4..326237a93f 100644 --- a/mobile/openapi/lib/api/asset_api.dart +++ b/mobile/openapi/lib/api/asset_api.dart @@ -710,6 +710,121 @@ class AssetApi { return null; } + /// Replace the asset with new file, without changing its id + /// + /// Note: This method returns the HTTP [Response]. + /// + /// Parameters: + /// + /// * [String] id (required): + /// + /// * [MultipartFile] assetData (required): + /// + /// * [String] deviceAssetId (required): + /// + /// * [String] deviceId (required): + /// + /// * [DateTime] fileCreatedAt (required): + /// + /// * [DateTime] fileModifiedAt (required): + /// + /// * [String] key: + /// + /// * [String] duration: + Future replaceAssetWithHttpInfo(String id, MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? duration, }) async { + // ignore: prefer_const_declarations + final path = r'/asset/{id}/file' + .replaceAll('{id}', id); + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + if (key != null) { + queryParams.addAll(_queryParams('', 'key', key)); + } + + const contentTypes = ['multipart/form-data']; + + bool hasFields = false; + final mp = MultipartRequest('PUT', Uri.parse(path)); + if (assetData != null) { + hasFields = true; + mp.fields[r'assetData'] = assetData.field; + mp.files.add(assetData); + } + if (deviceAssetId != null) { + hasFields = true; + mp.fields[r'deviceAssetId'] = parameterToString(deviceAssetId); + } + if (deviceId != null) { + hasFields = true; + mp.fields[r'deviceId'] = parameterToString(deviceId); + } + if (duration != null) { + hasFields = true; + mp.fields[r'duration'] = parameterToString(duration); + } + if (fileCreatedAt != null) { + hasFields = true; + mp.fields[r'fileCreatedAt'] = parameterToString(fileCreatedAt); + } + if (fileModifiedAt != null) { + hasFields = true; + mp.fields[r'fileModifiedAt'] = parameterToString(fileModifiedAt); + } + if (hasFields) { + postBody = mp; + } + + return apiClient.invokeAPI( + path, + 'PUT', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Replace the asset with new file, without changing its id + /// + /// Parameters: + /// + /// * [String] id (required): + /// + /// * [MultipartFile] assetData (required): + /// + /// * [String] deviceAssetId (required): + /// + /// * [String] deviceId (required): + /// + /// * [DateTime] fileCreatedAt (required): + /// + /// * [DateTime] fileModifiedAt (required): + /// + /// * [String] key: + /// + /// * [String] duration: + Future replaceAsset(String id, MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? duration, }) async { + final response = await replaceAssetWithHttpInfo(id, assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, key: key, duration: duration, ); + 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), 'AssetMediaResponseDto',) as AssetMediaResponseDto; + + } + return null; + } + /// Performs an HTTP 'POST /asset/jobs' operation and returns the [Response]. /// Parameters: /// diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 3e2f2c15c6..1f959757da 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -250,6 +250,10 @@ class ApiClient { return AssetJobNameTypeTransformer().decode(value); case 'AssetJobsDto': return AssetJobsDto.fromJson(value); + case 'AssetMediaResponseDto': + return AssetMediaResponseDto.fromJson(value); + case 'AssetMediaStatus': + return AssetMediaStatusTypeTransformer().decode(value); case 'AssetOrder': return AssetOrderTypeTransformer().decode(value); case 'AssetResponseDto': diff --git a/mobile/openapi/lib/api_helper.dart b/mobile/openapi/lib/api_helper.dart index f945dbb115..4a8c774623 100644 --- a/mobile/openapi/lib/api_helper.dart +++ b/mobile/openapi/lib/api_helper.dart @@ -61,6 +61,9 @@ String parameterToString(dynamic value) { if (value is AssetJobName) { return AssetJobNameTypeTransformer().encode(value).toString(); } + if (value is AssetMediaStatus) { + return AssetMediaStatusTypeTransformer().encode(value).toString(); + } if (value is AssetOrder) { return AssetOrderTypeTransformer().encode(value).toString(); } diff --git a/mobile/openapi/lib/model/asset_media_response_dto.dart b/mobile/openapi/lib/model/asset_media_response_dto.dart new file mode 100644 index 0000000000..c2801c93cc --- /dev/null +++ b/mobile/openapi/lib/model/asset_media_response_dto.dart @@ -0,0 +1,106 @@ +// +// 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 AssetMediaResponseDto { + /// Returns a new [AssetMediaResponseDto] instance. + AssetMediaResponseDto({ + required this.id, + required this.status, + }); + + String id; + + AssetMediaStatus status; + + @override + bool operator ==(Object other) => identical(this, other) || other is AssetMediaResponseDto && + other.id == id && + other.status == status; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (id.hashCode) + + (status.hashCode); + + @override + String toString() => 'AssetMediaResponseDto[id=$id, status=$status]'; + + Map toJson() { + final json = {}; + json[r'id'] = this.id; + json[r'status'] = this.status; + return json; + } + + /// Returns a new [AssetMediaResponseDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static AssetMediaResponseDto? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return AssetMediaResponseDto( + id: mapValueOfType(json, r'id')!, + status: AssetMediaStatus.fromJson(json[r'status'])!, + ); + } + 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 = AssetMediaResponseDto.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 = AssetMediaResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of AssetMediaResponseDto-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] = AssetMediaResponseDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'id', + 'status', + }; +} + diff --git a/mobile/openapi/lib/model/asset_media_status.dart b/mobile/openapi/lib/model/asset_media_status.dart new file mode 100644 index 0000000000..ff6f62e33f --- /dev/null +++ b/mobile/openapi/lib/model/asset_media_status.dart @@ -0,0 +1,85 @@ +// +// 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 AssetMediaStatus { + /// Instantiate a new enum with the provided [value]. + const AssetMediaStatus._(this.value); + + /// The underlying value of this enum member. + final String value; + + @override + String toString() => value; + + String toJson() => value; + + static const replaced = AssetMediaStatus._(r'replaced'); + static const duplicate = AssetMediaStatus._(r'duplicate'); + + /// List of all possible values in this [enum][AssetMediaStatus]. + static const values = [ + replaced, + duplicate, + ]; + + static AssetMediaStatus? fromJson(dynamic value) => AssetMediaStatusTypeTransformer().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 = AssetMediaStatus.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } +} + +/// Transformation class that can [encode] an instance of [AssetMediaStatus] to String, +/// and [decode] dynamic data back to [AssetMediaStatus]. +class AssetMediaStatusTypeTransformer { + factory AssetMediaStatusTypeTransformer() => _instance ??= const AssetMediaStatusTypeTransformer._(); + + const AssetMediaStatusTypeTransformer._(); + + String encode(AssetMediaStatus data) => data.value; + + /// Decodes a [dynamic value][data] to a AssetMediaStatus. + /// + /// 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. + AssetMediaStatus? decode(dynamic data, {bool allowNull = true}) { + if (data != null) { + switch (data) { + case r'replaced': return AssetMediaStatus.replaced; + case r'duplicate': return AssetMediaStatus.duplicate; + default: + if (!allowNull) { + throw ArgumentError('Unknown enum value to decode: $data'); + } + } + } + return null; + } + + /// Singleton [AssetMediaStatusTypeTransformer] instance. + static AssetMediaStatusTypeTransformer? _instance; +} + diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 4d3ccb1ea1..4bec15e4ac 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -1840,6 +1840,70 @@ ] } }, + "/asset/{id}/file": { + "put": { + "description": "Replace the asset with new file, without changing its id", + "operationId": "replaceAsset", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "name": "key", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/AssetMediaReplaceDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssetMediaResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Asset" + ], + "x-immich-lifecycle": { + "addedAt": "v1.106.0" + } + } + }, "/audit/deletes": { "get": { "operationId": "getAuditDeletes", @@ -7330,6 +7394,61 @@ ], "type": "object" }, + "AssetMediaReplaceDto": { + "properties": { + "assetData": { + "format": "binary", + "type": "string" + }, + "deviceAssetId": { + "type": "string" + }, + "deviceId": { + "type": "string" + }, + "duration": { + "type": "string" + }, + "fileCreatedAt": { + "format": "date-time", + "type": "string" + }, + "fileModifiedAt": { + "format": "date-time", + "type": "string" + } + }, + "required": [ + "assetData", + "deviceAssetId", + "deviceId", + "fileCreatedAt", + "fileModifiedAt" + ], + "type": "object" + }, + "AssetMediaResponseDto": { + "properties": { + "id": { + "type": "string" + }, + "status": { + "$ref": "#/components/schemas/AssetMediaStatus" + } + }, + "required": [ + "id", + "status" + ], + "type": "object" + }, + "AssetMediaStatus": { + "enum": [ + "replaced", + "duplicate" + ], + "type": "string" + }, "AssetOrder": { "enum": [ "asc", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index ff776bb3bd..2f895a76e8 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -324,6 +324,18 @@ export type UpdateAssetDto = { latitude?: number; longitude?: number; }; +export type AssetMediaReplaceDto = { + assetData: Blob; + deviceAssetId: string; + deviceId: string; + duration?: string; + fileCreatedAt: string; + fileModifiedAt: string; +}; +export type AssetMediaResponseDto = { + id: string; + status: AssetMediaStatus; +}; export type AuditDeletesResponseDto = { ids: string[]; needsFullSync: boolean; @@ -1585,6 +1597,25 @@ export function updateAsset({ id, updateAssetDto }: { body: updateAssetDto }))); } +/** + * Replace the asset with new file, without changing its id + */ +export function replaceAsset({ id, key, assetMediaReplaceDto }: { + id: string; + key?: string; + assetMediaReplaceDto: AssetMediaReplaceDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: AssetMediaResponseDto; + }>(`/asset/${encodeURIComponent(id)}/file${QS.query(QS.explode({ + key + }))}`, oazapfts.multipart({ + ...opts, + method: "PUT", + body: assetMediaReplaceDto + }))); +} export function getAuditDeletes({ after, entityType, userId }: { after: string; entityType: EntityType; @@ -2892,6 +2923,10 @@ export enum ThumbnailFormat { Jpeg = "JPEG", Webp = "WEBP" } +export enum AssetMediaStatus { + Replaced = "replaced", + Duplicate = "duplicate" +} export enum EntityType { Asset = "ASSET", Album = "ALBUM" diff --git a/server/src/controllers/asset-media.controller.ts b/server/src/controllers/asset-media.controller.ts new file mode 100644 index 0000000000..cd0847142b --- /dev/null +++ b/server/src/controllers/asset-media.controller.ts @@ -0,0 +1,56 @@ +import { + Body, + Controller, + HttpStatus, + Inject, + Param, + ParseFilePipe, + Put, + Res, + UploadedFiles, + UseInterceptors, +} from '@nestjs/common'; +import { ApiConsumes, ApiTags } from '@nestjs/swagger'; +import { Response } from 'express'; +import { EndpointLifecycle } from 'src/decorators'; +import { AssetMediaResponseDto, AssetMediaStatusEnum } from 'src/dtos/asset-media-response.dto'; +import { AssetMediaReplaceDto, UploadFieldName } from 'src/dtos/asset-media.dto'; +import { AuthDto } from 'src/dtos/auth.dto'; +import { ILoggerRepository } from 'src/interfaces/logger.interface'; +import { Auth, Authenticated } from 'src/middleware/auth.guard'; +import { FileUploadInterceptor, Route, UploadFiles, getFiles } from 'src/middleware/file-upload.interceptor'; +import { AssetMediaService } from 'src/services/asset-media.service'; +import { FileNotEmptyValidator, UUIDParamDto } from 'src/validation'; + +@ApiTags('Asset') +@Controller(Route.ASSET) +export class AssetMediaController { + constructor( + @Inject(ILoggerRepository) private logger: ILoggerRepository, + private service: AssetMediaService, + ) {} + + /** + * Replace the asset with new file, without changing its id + */ + @Put(':id/file') + @UseInterceptors(FileUploadInterceptor) + @ApiConsumes('multipart/form-data') + @Authenticated({ sharedLink: true }) + @EndpointLifecycle({ addedAt: 'v1.106.0' }) + async replaceAsset( + @Auth() auth: AuthDto, + @Param() { id }: UUIDParamDto, + @UploadedFiles(new ParseFilePipe({ validators: [new FileNotEmptyValidator([UploadFieldName.ASSET_DATA])] })) + files: UploadFiles, + @Body() dto: AssetMediaReplaceDto, + @Res({ passthrough: true }) res: Response, + ): Promise { + const { file } = getFiles(files); + const responseDto = await this.service.replaceAsset(auth, id, dto, file); + if (responseDto.status === AssetMediaStatusEnum.DUPLICATE) { + res.status(HttpStatus.OK); + } + return responseDto; + } +} diff --git a/server/src/controllers/asset-v1.controller.ts b/server/src/controllers/asset-v1.controller.ts index 37a94f24a1..ba29e462cb 100644 --- a/server/src/controllers/asset-v1.controller.ts +++ b/server/src/controllers/asset-v1.controller.ts @@ -34,17 +34,11 @@ import { AuthDto, ImmichHeader } from 'src/dtos/auth.dto'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { AssetUploadInterceptor } from 'src/middleware/asset-upload.interceptor'; import { Auth, Authenticated, FileResponse } from 'src/middleware/auth.guard'; -import { FileUploadInterceptor, ImmichFile, Route, mapToUploadFile } from 'src/middleware/file-upload.interceptor'; +import { FileUploadInterceptor, Route, UploadFiles, mapToUploadFile } from 'src/middleware/file-upload.interceptor'; import { AssetServiceV1 } from 'src/services/asset-v1.service'; import { sendFile } from 'src/utils/file'; import { FileNotEmptyValidator, UUIDParamDto } from 'src/validation'; -interface UploadFiles { - assetData: ImmichFile[]; - livePhotoData?: ImmichFile[]; - sidecarData: ImmichFile[]; -} - @ApiTags('Asset') @Controller(Route.ASSET) export class AssetControllerV1 { diff --git a/server/src/controllers/index.ts b/server/src/controllers/index.ts index feec616f21..187ba4b4db 100644 --- a/server/src/controllers/index.ts +++ b/server/src/controllers/index.ts @@ -2,6 +2,7 @@ import { ActivityController } from 'src/controllers/activity.controller'; import { AlbumController } from 'src/controllers/album.controller'; import { APIKeyController } from 'src/controllers/api-key.controller'; import { AppController } from 'src/controllers/app.controller'; +import { AssetMediaController } from 'src/controllers/asset-media.controller'; import { AssetControllerV1 } from 'src/controllers/asset-v1.controller'; import { AssetController } from 'src/controllers/asset.controller'; import { AuditController } from 'src/controllers/audit.controller'; @@ -35,6 +36,7 @@ export const controllers = [ AppController, AssetController, AssetControllerV1, + AssetMediaController, AuditController, AuthController, DownloadController, diff --git a/server/src/dtos/asset-media-response.dto.ts b/server/src/dtos/asset-media-response.dto.ts new file mode 100644 index 0000000000..7b65772f76 --- /dev/null +++ b/server/src/dtos/asset-media-response.dto.ts @@ -0,0 +1,11 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export enum AssetMediaStatusEnum { + REPLACED = 'replaced', + DUPLICATE = 'duplicate', +} +export class AssetMediaResponseDto { + @ApiProperty({ enum: AssetMediaStatusEnum, enumName: 'AssetMediaStatus' }) + status!: AssetMediaStatusEnum; + id!: string; +} diff --git a/server/src/dtos/asset-media.dto.ts b/server/src/dtos/asset-media.dto.ts new file mode 100644 index 0000000000..a30ff8a107 --- /dev/null +++ b/server/src/dtos/asset-media.dto.ts @@ -0,0 +1,35 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString } from 'class-validator'; +import { Optional, ValidateDate } from 'src/validation'; + +export enum UploadFieldName { + ASSET_DATA = 'assetData', + LIVE_PHOTO_DATA = 'livePhotoData', + SIDECAR_DATA = 'sidecarData', + PROFILE_DATA = 'file', +} + +export class AssetMediaReplaceDto { + @IsNotEmpty() + @IsString() + deviceAssetId!: string; + + @IsNotEmpty() + @IsString() + deviceId!: string; + + @ValidateDate() + fileCreatedAt!: Date; + + @ValidateDate() + fileModifiedAt!: Date; + + @Optional() + @IsString() + duration?: string; + + // The properties below are added to correctly generate the API docs + // and client SDKs. Validation should be handled in the controller. + @ApiProperty({ type: 'string', format: 'binary' }) + [UploadFieldName.ASSET_DATA]!: any; +} diff --git a/server/src/interfaces/asset.interface.ts b/server/src/interfaces/asset.interface.ts index 0a8437818e..88f494c15b 100644 --- a/server/src/interfaces/asset.interface.ts +++ b/server/src/interfaces/asset.interface.ts @@ -111,7 +111,10 @@ export type AssetWithoutRelations = Omit< | 'tags' >; -export type AssetUpdateOptions = Pick & Partial; +type AssetUpdateWithoutRelations = Pick & Partial; +type AssetUpdateWithLivePhotoRelation = Pick & Pick; + +export type AssetUpdateOptions = AssetUpdateWithoutRelations | AssetUpdateWithLivePhotoRelation; export type AssetUpdateAllOptions = Omit, 'id'>; diff --git a/server/src/interfaces/job.interface.ts b/server/src/interfaces/job.interface.ts index 37401df896..6393e3167a 100644 --- a/server/src/interfaces/job.interface.ts +++ b/server/src/interfaces/job.interface.ts @@ -113,7 +113,7 @@ export interface IBaseJob { export interface IEntityJob extends IBaseJob { id: string; - source?: 'upload' | 'sidecar-write'; + source?: 'upload' | 'sidecar-write' | 'copy'; } export interface ILibraryFileJob extends IEntityJob { diff --git a/server/src/middleware/file-upload.interceptor.ts b/server/src/middleware/file-upload.interceptor.ts index 6af502786e..f4fc755100 100644 --- a/server/src/middleware/file-upload.interceptor.ts +++ b/server/src/middleware/file-upload.interceptor.ts @@ -6,10 +6,30 @@ import { NextFunction, RequestHandler } from 'express'; import multer, { StorageEngine, diskStorage } from 'multer'; import { createHash, randomUUID } from 'node:crypto'; import { Observable } from 'rxjs'; -import { UploadFieldName } from 'src/dtos/asset.dto'; +import { UploadFieldName } from 'src/dtos/asset-media.dto'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { AuthRequest } from 'src/middleware/auth.guard'; -import { AssetService, UploadFile } from 'src/services/asset.service'; +import { UploadFile } from 'src/services/asset-media.service'; +import { AssetService } from 'src/services/asset.service'; + +export interface UploadFiles { + assetData: ImmichFile[]; + livePhotoData?: ImmichFile[]; + sidecarData: ImmichFile[]; +} + +export function getFile(files: UploadFiles, property: 'assetData' | 'livePhotoData' | 'sidecarData') { + const file = files[property]?.[0]; + return file ? mapToUploadFile(file) : file; +} + +export function getFiles(files: UploadFiles) { + return { + file: getFile(files, 'assetData') as UploadFile, + livePhotoFile: getFile(files, 'livePhotoData'), + sidecarFile: getFile(files, 'sidecarData'), + }; +} export enum Route { ASSET = 'asset', diff --git a/server/src/services/asset-media.service.spec.ts b/server/src/services/asset-media.service.spec.ts new file mode 100644 index 0000000000..1779635630 --- /dev/null +++ b/server/src/services/asset-media.service.spec.ts @@ -0,0 +1,280 @@ +import { Stats } from 'node:fs'; +import { AssetMediaStatusEnum } from 'src/dtos/asset-media-response.dto'; +import { AssetMediaReplaceDto } from 'src/dtos/asset-media.dto'; +import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity, AssetType } from 'src/entities/asset.entity'; +import { ExifEntity } from 'src/entities/exif.entity'; +import { IAssetRepository } from 'src/interfaces/asset.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'; +import { IUserRepository } from 'src/interfaces/user.interface'; +import { AssetMediaService, UploadFile } from 'src/services/asset-media.service'; +import { mimeTypes } from 'src/utils/mime-types'; +import { authStub } from 'test/fixtures/auth.stub'; +import { fileStub } from 'test/fixtures/file.stub'; +import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock'; +import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock'; +import { newEventRepositoryMock } from 'test/repositories/event.repository.mock'; +import { newJobRepositoryMock } from 'test/repositories/job.repository.mock'; +import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; +import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock'; +import { newUserRepositoryMock } from 'test/repositories/user.repository.mock'; +import { QueryFailedError } from 'typeorm'; +import { Mocked } from 'vitest'; + +const _getUpdateAssetDto = (): AssetMediaReplaceDto => { + return Object.assign(new AssetMediaReplaceDto(), { + deviceAssetId: 'deviceAssetId', + deviceId: 'deviceId', + fileModifiedAt: new Date('2024-04-15T23:41:36.910Z'), + fileCreatedAt: new Date('2024-04-15T23:41:36.910Z'), + updatedAt: new Date('2024-04-15T23:41:36.910Z'), + }); +}; + +const _getAsset_1 = () => { + const asset_1 = new AssetEntity(); + + asset_1.id = 'id_1'; + asset_1.ownerId = 'user_id_1'; + asset_1.deviceAssetId = 'device_asset_id_1'; + asset_1.deviceId = 'device_id_1'; + asset_1.type = AssetType.VIDEO; + asset_1.originalPath = 'fake_path/asset_1.jpeg'; + asset_1.previewPath = ''; + asset_1.fileModifiedAt = new Date('2022-06-19T23:41:36.910Z'); + asset_1.fileCreatedAt = new Date('2022-06-19T23:41:36.910Z'); + asset_1.updatedAt = new Date('2022-06-19T23:41:36.910Z'); + asset_1.isFavorite = false; + asset_1.isArchived = false; + asset_1.thumbnailPath = ''; + asset_1.encodedVideoPath = ''; + asset_1.duration = '0:00:00.000000'; + asset_1.exifInfo = new ExifEntity(); + asset_1.exifInfo.latitude = 49.533_547; + asset_1.exifInfo.longitude = 10.703_075; + asset_1.livePhotoVideoId = null; + asset_1.sidecarPath = null; + return asset_1; +}; +const _getExistingAsset = () => { + return { + ..._getAsset_1(), + duration: null, + type: AssetType.IMAGE, + checksum: Buffer.from('_getExistingAsset', 'utf8'), + libraryId: 'libraryId', + } as AssetEntity; +}; +const _getExistingAssetWithSideCar = () => { + return { + ..._getExistingAsset(), + sidecarPath: 'sidecar-path', + checksum: Buffer.from('_getExistingAssetWithSideCar', 'utf8'), + } as AssetEntity; +}; +const _getCopiedAsset = () => { + return { + id: 'copied-asset', + originalPath: 'copied-path', + } as AssetEntity; +}; + +describe('AssetMediaService', () => { + let sut: AssetMediaService; + let accessMock: IAccessRepositoryMock; + let assetMock: Mocked; + let jobMock: Mocked; + let loggerMock: Mocked; + let storageMock: Mocked; + let userMock: Mocked; + let eventMock: Mocked; + + beforeEach(() => { + accessMock = newAccessRepositoryMock(); + assetMock = newAssetRepositoryMock(); + jobMock = newJobRepositoryMock(); + loggerMock = newLoggerRepositoryMock(); + storageMock = newStorageRepositoryMock(); + userMock = newUserRepositoryMock(); + eventMock = newEventRepositoryMock(); + + sut = new AssetMediaService(accessMock, assetMock, jobMock, storageMock, userMock, eventMock, loggerMock); + }); + + describe('replaceAsset', () => { + const expectAssetUpdate = ( + existingAsset: AssetEntity, + uploadFile: UploadFile, + dto: AssetMediaReplaceDto, + livePhotoVideo?: AssetEntity, + sidecarPath?: UploadFile, + // eslint-disable-next-line unicorn/consistent-function-scoping + ) => { + expect(assetMock.update).toHaveBeenCalledWith({ + id: existingAsset.id, + checksum: uploadFile.checksum, + originalFileName: uploadFile.originalName, + originalPath: uploadFile.originalPath, + deviceAssetId: dto.deviceAssetId, + deviceId: dto.deviceId, + fileCreatedAt: dto.fileCreatedAt, + fileModifiedAt: dto.fileModifiedAt, + localDateTime: dto.fileCreatedAt, + type: mimeTypes.assetType(uploadFile.originalPath), + duration: dto.duration || null, + livePhotoVideo: livePhotoVideo ? { id: livePhotoVideo?.id } : null, + sidecarPath: sidecarPath?.originalPath || null, + }); + }; + + // eslint-disable-next-line unicorn/consistent-function-scoping + const expectAssetCreateCopy = (existingAsset: AssetEntity) => { + expect(assetMock.create).toHaveBeenCalledWith({ + ownerId: existingAsset.ownerId, + originalPath: existingAsset.originalPath, + originalFileName: existingAsset.originalFileName, + libraryId: existingAsset.libraryId, + deviceAssetId: existingAsset.deviceAssetId, + deviceId: existingAsset.deviceId, + type: existingAsset.type, + checksum: existingAsset.checksum, + fileCreatedAt: existingAsset.fileCreatedAt, + localDateTime: existingAsset.localDateTime, + fileModifiedAt: existingAsset.fileModifiedAt, + livePhotoVideoId: existingAsset.livePhotoVideoId || null, + sidecarPath: existingAsset.sidecarPath || null, + }); + }; + + it('should error when update photo does not exist', async () => { + const dto = _getUpdateAssetDto(); + assetMock.getById.mockResolvedValueOnce(null); + + await expect(sut.replaceAsset(authStub.user1, 'id', dto, fileStub.photo)).rejects.toThrow( + 'Not found or no asset.update access', + ); + + expect(assetMock.create).not.toHaveBeenCalled(); + }); + it('should update a photo with no sidecar to photo with no sidecar', async () => { + const existingAsset = _getExistingAsset(); + const updatedFile = fileStub.photo; + const updatedAsset = { ...existingAsset, ...updatedFile }; + const dto = _getUpdateAssetDto(); + assetMock.getById.mockResolvedValueOnce(existingAsset); + assetMock.getById.mockResolvedValueOnce(updatedAsset); + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([existingAsset.id])); + // this is the original file size + storageMock.stat.mockResolvedValue({ size: 0 } as Stats); + // this is for the clone call + assetMock.create.mockResolvedValue(_getCopiedAsset()); + + await expect(sut.replaceAsset(authStub.user1, existingAsset.id, dto, updatedFile)).resolves.toEqual({ + status: AssetMediaStatusEnum.REPLACED, + id: _getCopiedAsset().id, + }); + + expectAssetUpdate(existingAsset, updatedFile, dto); + expectAssetCreateCopy(existingAsset); + + expect(assetMock.softDeleteAll).toHaveBeenCalledWith([_getCopiedAsset().id]); + expect(userMock.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, updatedFile.size); + expect(storageMock.utimes).toHaveBeenCalledWith( + updatedFile.originalPath, + expect.any(Date), + new Date(dto.fileModifiedAt), + ); + }); + it('should update a photo with sidecar to photo with sidecar', async () => { + const existingAsset = _getExistingAssetWithSideCar(); + + const updatedFile = fileStub.photo; + const sidecarFile = fileStub.photoSidecar; + const dto = _getUpdateAssetDto(); + const updatedAsset = { ...existingAsset, ...updatedFile }; + assetMock.getById.mockResolvedValueOnce(existingAsset); + assetMock.getById.mockResolvedValueOnce(updatedAsset); + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([existingAsset.id])); + // this is the original file size + storageMock.stat.mockResolvedValue({ size: 0 } as Stats); + // this is for the clone call + assetMock.create.mockResolvedValue(_getCopiedAsset()); + + await expect(sut.replaceAsset(authStub.user1, existingAsset.id, dto, updatedFile, sidecarFile)).resolves.toEqual({ + status: AssetMediaStatusEnum.REPLACED, + id: _getCopiedAsset().id, + }); + + expectAssetUpdate(existingAsset, updatedFile, dto, undefined, sidecarFile); + expectAssetCreateCopy(existingAsset); + expect(assetMock.softDeleteAll).toHaveBeenCalledWith([_getCopiedAsset().id]); + expect(userMock.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, updatedFile.size); + expect(storageMock.utimes).toHaveBeenCalledWith( + updatedFile.originalPath, + expect.any(Date), + new Date(dto.fileModifiedAt), + ); + }); + it('should update a photo with a sidecar to photo with no sidecar', async () => { + const existingAsset = _getExistingAssetWithSideCar(); + const updatedFile = fileStub.photo; + + const dto = _getUpdateAssetDto(); + const updatedAsset = { ...existingAsset, ...updatedFile }; + assetMock.getById.mockResolvedValueOnce(existingAsset); + assetMock.getById.mockResolvedValueOnce(updatedAsset); + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([existingAsset.id])); + // this is the original file size + storageMock.stat.mockResolvedValue({ size: 0 } as Stats); + // this is for the copy call + assetMock.create.mockResolvedValue(_getCopiedAsset()); + + await expect(sut.replaceAsset(authStub.user1, existingAsset.id, dto, updatedFile)).resolves.toEqual({ + status: AssetMediaStatusEnum.REPLACED, + id: _getCopiedAsset().id, + }); + + expectAssetUpdate(existingAsset, updatedFile, dto); + expectAssetCreateCopy(existingAsset); + expect(assetMock.softDeleteAll).toHaveBeenCalledWith([_getCopiedAsset().id]); + expect(userMock.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, updatedFile.size); + expect(storageMock.utimes).toHaveBeenCalledWith( + updatedFile.originalPath, + expect.any(Date), + new Date(dto.fileModifiedAt), + ); + }); + it('should handle a photo with sidecar to duplicate photo ', async () => { + const existingAsset = _getExistingAssetWithSideCar(); + const updatedFile = fileStub.photo; + const dto = _getUpdateAssetDto(); + const error = new QueryFailedError('', [], new Error('unique key violation')); + (error as any).constraint = ASSET_CHECKSUM_CONSTRAINT; + + assetMock.update.mockRejectedValue(error); + assetMock.getById.mockResolvedValueOnce(existingAsset); + assetMock.getUploadAssetIdByChecksum.mockResolvedValue(existingAsset.id); + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([existingAsset.id])); + // this is the original file size + storageMock.stat.mockResolvedValue({ size: 0 } as Stats); + // this is for the clone call + assetMock.create.mockResolvedValue(_getCopiedAsset()); + + await expect(sut.replaceAsset(authStub.user1, existingAsset.id, dto, updatedFile)).resolves.toEqual({ + status: AssetMediaStatusEnum.DUPLICATE, + id: existingAsset.id, + }); + + expectAssetUpdate(existingAsset, updatedFile, dto); + expect(assetMock.create).not.toHaveBeenCalled(); + expect(assetMock.softDeleteAll).not.toHaveBeenCalled(); + expect(jobMock.queue).toHaveBeenCalledWith({ + name: JobName.DELETE_FILES, + data: { files: [updatedFile.originalPath, undefined] }, + }); + expect(userMock.updateUsage).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/server/src/services/asset-media.service.ts b/server/src/services/asset-media.service.ts new file mode 100644 index 0000000000..ef8c46c5b7 --- /dev/null +++ b/server/src/services/asset-media.service.ts @@ -0,0 +1,177 @@ +import { BadRequestException, Inject, Injectable, InternalServerErrorException } from '@nestjs/common'; +import { AccessCore, Permission } from 'src/cores/access.core'; +import { AssetMediaResponseDto, AssetMediaStatusEnum } from 'src/dtos/asset-media-response.dto'; +import { AssetMediaReplaceDto, UploadFieldName } from 'src/dtos/asset-media.dto'; +import { AuthDto } from 'src/dtos/auth.dto'; +import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity } from 'src/entities/asset.entity'; +import { IAccessRepository } from 'src/interfaces/access.interface'; +import { IAssetRepository } from 'src/interfaces/asset.interface'; +import { ClientEvent, 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'; +import { IUserRepository } from 'src/interfaces/user.interface'; +import { mimeTypes } from 'src/utils/mime-types'; +import { QueryFailedError } from 'typeorm'; + +export interface UploadRequest { + auth: AuthDto | null; + fieldName: UploadFieldName; + file: UploadFile; +} + +export interface UploadFile { + uuid: string; + checksum: Buffer; + originalPath: string; + originalName: string; + size: number; +} + +@Injectable() +export class AssetMediaService { + private access: AccessCore; + + constructor( + @Inject(IAccessRepository) accessRepository: IAccessRepository, + @Inject(IAssetRepository) private assetRepository: IAssetRepository, + @Inject(IJobRepository) private jobRepository: IJobRepository, + @Inject(IStorageRepository) private storageRepository: IStorageRepository, + @Inject(IUserRepository) private userRepository: IUserRepository, + @Inject(IEventRepository) private eventRepository: IEventRepository, + @Inject(ILoggerRepository) private logger: ILoggerRepository, + ) { + this.logger.setContext(AssetMediaService.name); + this.access = AccessCore.create(accessRepository); + } + + public async replaceAsset( + auth: AuthDto, + id: string, + dto: AssetMediaReplaceDto, + file: UploadFile, + sidecarFile?: UploadFile, + ): Promise { + try { + await this.access.requirePermission(auth, Permission.ASSET_UPDATE, id); + const existingAssetEntity = (await this.assetRepository.getById(id)) as AssetEntity; + + this.requireQuota(auth, file.size); + + await this.replaceFileData(existingAssetEntity.id, dto, file, sidecarFile?.originalPath); + + // Next, create a backup copy of the existing record. The db record has already been updated above, + // but the local variable holds the original file data paths. + const copiedPhoto = await this.createCopy(existingAssetEntity); + // and immediate trash it + await this.assetRepository.softDeleteAll([copiedPhoto.id]); + this.eventRepository.clientSend(ClientEvent.ASSET_TRASH, auth.user.id, [copiedPhoto.id]); + + await this.userRepository.updateUsage(auth.user.id, file.size); + + return { status: AssetMediaStatusEnum.REPLACED, id: copiedPhoto.id }; + } catch (error: any) { + return await this.handleUploadError(error, auth, file, sidecarFile); + } + } + + private async handleUploadError( + error: any, + auth: AuthDto, + file: UploadFile, + sidecarFile?: UploadFile, + ): Promise { + // clean up files + await this.jobRepository.queue({ + name: JobName.DELETE_FILES, + data: { files: [file.originalPath, sidecarFile?.originalPath] }, + }); + + // handle duplicates with a success response + if (error instanceof QueryFailedError && (error as any).constraint === ASSET_CHECKSUM_CONSTRAINT) { + const duplicateId = await this.assetRepository.getUploadAssetIdByChecksum(auth.user.id, file.checksum); + if (!duplicateId) { + this.logger.error(`Error locating duplicate for checksum constraint`); + throw new InternalServerErrorException(); + } + return { status: AssetMediaStatusEnum.DUPLICATE, id: duplicateId }; + } + + this.logger.error(`Error uploading file ${error}`, error?.stack); + throw error; + } + + /** + * Updates the specified assetId to the specified photo data file properties: checksum, path, + * timestamps, deviceIds, and sidecar. Derived properties like: faces, smart search info, etc + * are UNTOUCHED. The photo data files modification times on the filesysytem are updated to + * the specified timestamps. The exif db record is upserted, and then A METADATA_EXTRACTION + * job is queued to update these derived properties. + */ + private async replaceFileData( + assetId: string, + dto: AssetMediaReplaceDto, + file: UploadFile, + sidecarPath?: string, + ): Promise { + await this.assetRepository.update({ + id: assetId, + + checksum: file.checksum, + originalPath: file.originalPath, + type: mimeTypes.assetType(file.originalPath), + originalFileName: file.originalName, + + deviceAssetId: dto.deviceAssetId, + deviceId: dto.deviceId, + fileCreatedAt: dto.fileCreatedAt, + fileModifiedAt: dto.fileModifiedAt, + localDateTime: dto.fileCreatedAt, + duration: dto.duration || null, + + livePhotoVideo: null, + sidecarPath: sidecarPath || null, + }); + + await this.storageRepository.utimes(file.originalPath, new Date(), new Date(dto.fileModifiedAt)); + await this.assetRepository.upsertExif({ assetId, fileSizeInByte: file.size }); + await this.jobRepository.queue({ + name: JobName.METADATA_EXTRACTION, + data: { id: assetId, source: 'upload' }, + }); + } + + /** + * Create a 'shallow' copy of the specified asset record creating a new asset record in the database. + * Uses only vital properties excluding things like: stacks, faces, smart search info, etc, + * and then queues a METADATA_EXTRACTION job. + */ + private async createCopy(asset: AssetEntity): Promise { + const created = await this.assetRepository.create({ + ownerId: asset.ownerId, + originalPath: asset.originalPath, + originalFileName: asset.originalFileName, + libraryId: asset.libraryId, + deviceAssetId: asset.deviceAssetId, + deviceId: asset.deviceId, + type: asset.type, + checksum: asset.checksum, + fileCreatedAt: asset.fileCreatedAt, + localDateTime: asset.localDateTime, + fileModifiedAt: asset.fileModifiedAt, + livePhotoVideoId: asset.livePhotoVideoId, + sidecarPath: asset.sidecarPath, + }); + + const { size } = await this.storageRepository.stat(created.originalPath); + await this.assetRepository.upsertExif({ assetId: created.id, fileSizeInByte: size }); + await this.jobRepository.queue({ name: JobName.METADATA_EXTRACTION, data: { id: created.id, source: 'copy' } }); + return created; + } + + private requireQuota(auth: AuthDto, size: number) { + if (auth.user.quotaSizeInBytes && auth.user.quotaSizeInBytes < auth.user.quotaUsageInBytes + size) { + throw new BadRequestException('Quota has been exceeded!'); + } + } +} diff --git a/server/src/services/asset-v1.service.ts b/server/src/services/asset-v1.service.ts index 6868ca2dad..9346204506 100644 --- a/server/src/services/asset-v1.service.ts +++ b/server/src/services/asset-v1.service.ts @@ -33,7 +33,7 @@ import { ILibraryRepository } from 'src/interfaces/library.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; -import { UploadFile } from 'src/services/asset.service'; +import { UploadFile } from 'src/services/asset-media.service'; import { CacheControl, ImmichFileResponse, getLivePhotoMotionFilename } from 'src/utils/file'; import { mimeTypes } from 'src/utils/mime-types'; import { fromChecksum } from 'src/utils/request'; diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index c37d8d8b65..053f4ba987 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -46,24 +46,11 @@ import { IPartnerRepository } from 'src/interfaces/partner.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; +import { UploadRequest } from 'src/services/asset-media.service'; import { mimeTypes } from 'src/utils/mime-types'; import { usePagination } from 'src/utils/pagination'; import { fromChecksum } from 'src/utils/request'; -export interface UploadRequest { - auth: AuthDto | null; - fieldName: UploadFieldName; - file: UploadFile; -} - -export interface UploadFile { - uuid: string; - checksum: Buffer; - originalPath: string; - originalName: string; - size: number; -} - export class AssetService { private access: AccessCore; private configCore: SystemConfigCore; diff --git a/server/src/services/index.ts b/server/src/services/index.ts index 76fe7244c0..5ea16d9e4b 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -2,6 +2,7 @@ import { ActivityService } from 'src/services/activity.service'; import { AlbumService } from 'src/services/album.service'; import { APIKeyService } from 'src/services/api-key.service'; import { ApiService } from 'src/services/api.service'; +import { AssetMediaService } from 'src/services/asset-media.service'; import { AssetServiceV1 } from 'src/services/asset-v1.service'; import { AssetService } from 'src/services/asset.service'; import { AuditService } from 'src/services/audit.service'; @@ -41,6 +42,7 @@ export const services = [ APIKeyService, ActivityService, AlbumService, + AssetMediaService, AssetService, AssetServiceV1, AuditService, diff --git a/server/src/services/job.service.ts b/server/src/services/job.service.ts index 8504631d4d..dabbf4259b 100644 --- a/server/src/services/job.service.ts +++ b/server/src/services/job.service.ts @@ -250,7 +250,7 @@ export class JobService { } case JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE: { - if (item.data.source === 'upload') { + if (item.data.source === 'upload' || item.data.source === 'copy') { await this.jobRepository.queue({ name: JobName.GENERATE_PREVIEW, data: item.data }); } break; diff --git a/server/test/fixtures/file.stub.ts b/server/test/fixtures/file.stub.ts index c2c40633d9..f63d2d10fb 100644 --- a/server/test/fixtures/file.stub.ts +++ b/server/test/fixtures/file.stub.ts @@ -13,4 +13,19 @@ export const fileStub = { originalName: 'asset_1.mp4', size: 69, }), + photo: Object.freeze({ + uuid: 'photo', + originalPath: 'fake_path/photo1.jpeg', + mimeType: 'image/jpeg', + checksum: Buffer.from('photo file hash', 'utf8'), + originalName: 'photo1.jpeg', + size: 24, + }), + photoSidecar: Object.freeze({ + uuid: 'photo-sidecar', + originalPath: 'fake_path/photo1.jpeg.xmp', + originalName: 'photo1.jpeg.xmp', + checksum: Buffer.from('photo-sidecar file hash', 'utf8'), + size: 96, + }), }; diff --git a/web/src/lib/components/album-page/album-viewer.svelte b/web/src/lib/components/album-page/album-viewer.svelte index 07fcc58834..43fbe8e7e3 100644 --- a/web/src/lib/components/album-page/album-viewer.svelte +++ b/web/src/lib/components/album-page/album-viewer.svelte @@ -75,7 +75,7 @@ {#if sharedLink.allowUpload} openFileUploadDialog(album.id)} + on:click={() => openFileUploadDialog({ albumId: album.id })} icon={mdiFileImagePlusOutline} /> {/if} 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 731148dd40..135886d978 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 @@ -5,7 +5,8 @@ import { getAssetJobName } from '$lib/utils'; import { clickOutside } from '$lib/actions/click-outside'; import { getContextMenuPosition } from '$lib/utils/context-menu'; - import { AssetJobName, AssetTypeEnum, type AssetResponseDto, type AlbumResponseDto } from '@immich/sdk'; + import { openFileUploadDialog } from '$lib/utils/file-uploader'; + import { AssetJobName, AssetTypeEnum, type AlbumResponseDto, type AssetResponseDto } from '@immich/sdk'; import { mdiAccountCircleOutline, mdiAlertOutline, @@ -32,6 +33,7 @@ mdiPlaySpeed, mdiPresentationPlay, mdiShareVariantOutline, + mdiUpload, } from '@mdi/js'; import { createEventDispatcher } from 'svelte'; import ContextMenu from '../shared-components/context-menu/context-menu.svelte'; @@ -243,6 +245,11 @@ icon={asset.isArchived ? mdiArchiveArrowUpOutline : mdiArchiveArrowDownOutline} text={asset.isArchived ? 'Unarchive' : 'Archive'} /> + openFileUploadDialog({ multiple: false, assetId: asset.id })} + text="Replace with upload" + />
void; $: isFullScreen = fullscreenElement !== null; $: { @@ -192,6 +193,11 @@ } onMount(async () => { + unsubscribe = websocketEvents.on('on_upload_success', (assetUpdate) => { + if (assetUpdate.id === asset.id) { + asset = assetUpdate; + } + }); await navigate({ targetRoute: 'current', assetId: asset.id }); slideshowStateUnsubscribe = slideshowState.subscribe((value) => { if (value === SlideshowState.PlaySlideshow) { @@ -237,6 +243,7 @@ if (shuffleSlideshowUnsubscribe) { shuffleSlideshowUnsubscribe(); } + unsubscribe?.(); }); $: asset.id && !sharedLink && handlePromiseError(handleGetAllAlbums()); // Update the album information when the asset ID changes @@ -633,6 +640,7 @@ {:else} Promise; let canCopyImagesToClipboard: () => boolean; let imageLoaded: boolean = false; + let imageError: boolean = false; + // set to true when am image has been zoomed, to force loading of the original image regardless + // of app settings + let forceLoadOriginal: boolean = false; const loadOriginalByDefault = $alwaysLoadOriginalFile && isWebCompatibleImage(asset); @@ -40,60 +42,53 @@ }); } + $: { + preload({ preloadAssets, loadOriginal: loadOriginalByDefault }); + } + + $: assetFileUrl = load(asset.id, !loadOriginalByDefault || forceLoadOriginal, false, asset.checksum); + onMount(async () => { // Import hack :( see https://github.com/vadimkorr/svelte-carousel/issues/27#issuecomment-851022295 // TODO: Move to regular import once the package correctly supports ESM. const module = await import('copy-image-clipboard'); copyImageToClipboard = module.copyImageToClipboard; canCopyImagesToClipboard = module.canCopyImagesToClipboard; - - imageLoaded = false; - await loadAssetData({ loadOriginal: loadOriginalByDefault }); }); onDestroy(() => { $boundingBoxesArray = []; - abortController?.abort(); }); - const loadAssetData = async ({ loadOriginal }: { loadOriginal: boolean }) => { - try { - abortController?.abort(); - abortController = new AbortController(); - - // TODO: Use sdk once it supports signals - const { data } = await downloadRequest({ - url: getAssetFileUrl(asset.id, !loadOriginal, false), - signal: abortController.signal, - }); - - assetData = URL.createObjectURL(data); - imageLoaded = true; - - if (!preloadAssets) { - return; + const preload = ({ + preloadAssets, + loadOriginal, + }: { + preloadAssets: AssetResponseDto[] | null; + loadOriginal: boolean; + }) => { + for (const preloadAsset of preloadAssets || []) { + if (preloadAsset.type === AssetTypeEnum.Image) { + let img = new Image(); + img.src = getAssetFileUrl(preloadAsset.id, !loadOriginal, false, preloadAsset.checksum); } - - for (const preloadAsset of preloadAssets) { - if (preloadAsset.type === AssetTypeEnum.Image) { - await downloadRequest({ - url: getAssetFileUrl(preloadAsset.id, !loadOriginal, false), - signal: abortController.signal, - }); - } - } - } catch { - imageLoaded = false; } }; + const load = (assetId: string, isWeb: boolean, isThumb: boolean, checksum: string) => { + const assetUrl = getAssetFileUrl(assetId, isWeb, isThumb, checksum); + // side effect, only flag imageLoaded when url is different + imageLoaded = assetFileUrl === assetUrl; + return assetUrl; + }; + const doCopy = async () => { if (!canCopyImagesToClipboard()) { return; } try { - await copyImageToClipboard(assetData); + await copyImageToClipboard(assetFileUrl); notificationController.show({ type: NotificationType.Info, message: 'Copied image to clipboard.', @@ -122,12 +117,7 @@ zoomImageWheelState.subscribe((state) => { photoZoomState.set(state); - - if (state.currentZoom > 1 && isWebCompatibleImage(asset) && !hasZoomed && !$alwaysLoadOriginalFile) { - hasZoomed = true; - - handlePromiseError(loadAssetData({ loadOriginal: true })); - } + forceLoadOriginal = state.currentZoom > 1 && isWebCompatibleImage(asset) ? true : false; }); const onCopyShortcut = () => { @@ -146,41 +136,53 @@ { shortcut: { key: 'c', meta: true }, onShortcut: onCopyShortcut }, ]} /> - -
+{#if imageError} +
Error loading image
+{/if} +
+ {getAltText(asset)} (imageLoaded = true)} + on:error={() => (imageError = imageLoaded = true)} + /> {#if !imageLoaded} -
+
- {:else} -
- {#if $slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground} + {:else if !imageError} + {#key assetFileUrl} +
+ {#if $slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground} + {getAltText(asset)} + {/if} {getAltText(asset)} - {/if} - {getAltText(asset)} - {#each getBoundingBox($boundingBoxesArray, $photoZoomState, $photoViewer) as boundingbox} -
- {/each} -
+ {#each getBoundingBox($boundingBoxesArray, $photoZoomState, $photoViewer) as boundingbox} +
+ {/each} +
+ {/key} {/if}
diff --git a/web/src/lib/components/asset-viewer/video-native-viewer.svelte b/web/src/lib/components/asset-viewer/video-native-viewer.svelte index 7863513bfb..0f68964dbc 100644 --- a/web/src/lib/components/asset-viewer/video-native-viewer.svelte +++ b/web/src/lib/components/asset-viewer/video-native-viewer.svelte @@ -9,9 +9,19 @@ export let assetId: string; export let loopVideo: boolean; + export let checksum: string; let element: HTMLVideoElement | undefined = undefined; let isVideoLoading = true; + let assetFileUrl: string; + + $: { + const next = getAssetFileUrl(assetId, false, true, checksum); + if (assetFileUrl !== next) { + assetFileUrl = next; + element && element.load(); + } + } const dispatch = createEventDispatcher<{ onVideoEnded: void; onVideoStarted: void }>(); @@ -44,9 +54,9 @@ on:ended={() => dispatch('onVideoEnded')} bind:muted={$videoViewerMuted} bind:volume={$videoViewerVolume} - poster={getAssetThumbnailUrl(assetId, ThumbnailFormat.Jpeg)} + poster={getAssetThumbnailUrl(assetId, ThumbnailFormat.Jpeg, checksum)} > - + 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 be4a9d5150..129b6c8be7 100644 --- a/web/src/lib/components/asset-viewer/video-wrapper-viewer.svelte +++ b/web/src/lib/components/asset-viewer/video-wrapper-viewer.svelte @@ -6,11 +6,12 @@ export let assetId: string; export let projectionType: string | null | undefined; + export let checksum: string; export let loopVideo: boolean; {#if projectionType === ProjectionType.EQUIRECTANGULAR} {:else} - + {/if} diff --git a/web/src/lib/components/assets/thumbnail/thumbnail.svelte b/web/src/lib/components/assets/thumbnail/thumbnail.svelte index 2cfb28674f..1ee4463756 100644 --- a/web/src/lib/components/assets/thumbnail/thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/thumbnail.svelte @@ -180,7 +180,7 @@ {#if asset.resized} { interface UploadRequestOptions { url: string; + method?: 'POST' | 'PUT'; data: FormData; onUploadProgress?: (event: ProgressEvent) => void; } @@ -64,7 +65,7 @@ export const uploadRequest = async (options: UploadRequestOptions): Promise<{ xhr.upload.addEventListener('progress', (event) => onProgress(event)); } - xhr.open('POST', url); + xhr.open(options.method || 'POST', url); xhr.responseType = 'json'; xhr.send(data); }); @@ -158,18 +159,28 @@ const createUrl = (path: string, parameters?: Record) => { return getBaseUrl() + url.pathname + url.search + url.hash; }; -export const getAssetFileUrl = (...[assetId, isWeb, isThumb]: [string, boolean, boolean]) => { +export const getAssetFileUrl = ( + ...[assetId, isWeb, isThumb, checksum]: + | [assetId: string, isWeb: boolean, isThumb: boolean] + | [assetId: string, isWeb: boolean, isThumb: boolean, checksum: string] +) => { const path = `/asset/file/${assetId}`; - return createUrl(path, { isThumb, isWeb, key: getKey() }); + return createUrl(path, { isThumb, isWeb, key: getKey(), c: checksum }); }; -export const getAssetThumbnailUrl = (...[assetId, format]: [string, ThumbnailFormat | undefined]) => { +export const getAssetThumbnailUrl = ( + ...[assetId, format, checksum]: + | [assetId: string, format: ThumbnailFormat | undefined] + | [assetId: string, format: ThumbnailFormat | undefined, checksum: string] +) => { + // checksum (optional) is used as a cache-buster param, since thumbs are + // served with static resource cache headers const path = `/asset/thumbnail/${assetId}`; - return createUrl(path, { format, key: getKey() }); + return createUrl(path, { format, key: getKey(), c: checksum }); }; export const getProfileImageUrl = (...[userId]: [string]) => { - const path = `/users/${userId}/profile-image`; + const path = `/users/profile-image/${userId}`; return createUrl(path); }; diff --git a/web/src/lib/utils/file-uploader.ts b/web/src/lib/utils/file-uploader.ts index 9472364d09..d1a0fce8d4 100644 --- a/web/src/lib/utils/file-uploader.ts +++ b/web/src/lib/utils/file-uploader.ts @@ -5,10 +5,12 @@ import { addAssetsToAlbum } from '$lib/utils/asset-utils'; import { ExecutorQueue } from '$lib/utils/executor-queue'; import { Action, + AssetMediaStatus, checkBulkUpload, getBaseUrl, getSupportedMediaTypes, type AssetFileUploadResponseDto, + type AssetMediaResponseDto, } from '@immich/sdk'; import { tick } from 'svelte'; import { getServerErrorMessage, handleError } from './handle-error'; @@ -25,7 +27,12 @@ const getExtensions = async () => { return _extensions; }; -export const openFileUploadDialog = async (albumId?: string | undefined) => { +type FileUploadParam = { multiple?: boolean } & ( + | { albumId?: string; assetId?: never } + | { albumId?: never; assetId?: string } +); +export const openFileUploadDialog = async (options?: FileUploadParam) => { + const { albumId, multiple, assetId } = options || { multiple: true }; const extensions = await getExtensions(); return new Promise<(string | undefined)[]>((resolve, reject) => { @@ -33,7 +40,7 @@ export const openFileUploadDialog = async (albumId?: string | undefined) => { const fileSelector = document.createElement('input'); fileSelector.type = 'file'; - fileSelector.multiple = true; + fileSelector.multiple = !!multiple; fileSelector.accept = extensions.join(','); fileSelector.addEventListener('change', (e: Event) => { const target = e.target as HTMLInputElement; @@ -42,7 +49,7 @@ export const openFileUploadDialog = async (albumId?: string | undefined) => { } const files = Array.from(target.files); - resolve(fileUploadHandler(files, albumId)); + resolve(fileUploadHandler(files, albumId, assetId)); }); fileSelector.click(); @@ -53,14 +60,14 @@ export const openFileUploadDialog = async (albumId?: string | undefined) => { }); }; -export const fileUploadHandler = async (files: File[], albumId: string | undefined = undefined): Promise => { +export const fileUploadHandler = async (files: File[], albumId?: string, assetId?: string): Promise => { const extensions = await getExtensions(); const promises = []; for (const file of files) { const name = file.name.toLowerCase(); if (extensions.some((extension) => name.endsWith(extension))) { - uploadAssetsStore.addNewUploadAsset({ id: getDeviceAssetId(file), file, albumId }); - promises.push(uploadExecutionQueue.addTask(() => fileUploader(file, albumId))); + uploadAssetsStore.addNewUploadAsset({ id: getDeviceAssetId(file), file, albumId, assetId }); + promises.push(uploadExecutionQueue.addTask(() => fileUploader(file, albumId, assetId))); } } @@ -73,9 +80,9 @@ function getDeviceAssetId(asset: File) { } // TODO: should probably use the @api SDK -async function fileUploader(asset: File, albumId: string | undefined = undefined): Promise { - const fileCreatedAt = new Date(asset.lastModified).toISOString(); - const deviceAssetId = getDeviceAssetId(asset); +async function fileUploader(assetFile: File, albumId?: string, replaceAssetId?: string): Promise { + const fileCreatedAt = new Date(assetFile.lastModified).toISOString(); + const deviceAssetId = getDeviceAssetId(assetFile); uploadAssetsStore.markStarted(deviceAssetId); @@ -85,21 +92,21 @@ async function fileUploader(asset: File, albumId: string | undefined = undefined deviceAssetId, deviceId: 'WEB', fileCreatedAt, - fileModifiedAt: new Date(asset.lastModified).toISOString(), + fileModifiedAt: new Date(assetFile.lastModified).toISOString(), isFavorite: 'false', duration: '0:00:00.000000', - assetData: new File([asset], asset.name), + assetData: new File([assetFile], assetFile.name), })) { formData.append(key, value); } - let responseData: AssetFileUploadResponseDto | undefined; + let responseData: AssetMediaResponseDto | undefined; const key = getKey(); if (crypto?.subtle?.digest && !key) { uploadAssetsStore.updateAsset(deviceAssetId, { message: 'Hashing...' }); await tick(); try { - const bytes = await asset.arrayBuffer(); + const bytes = await assetFile.arrayBuffer(); const hash = await crypto.subtle.digest('SHA-1', bytes); const checksum = Array.from(new Uint8Array(hash)) .map((b) => b.toString(16).padStart(2, '0')) @@ -107,48 +114,64 @@ async function fileUploader(asset: File, albumId: string | undefined = undefined const { results: [checkUploadResult], - } = await checkBulkUpload({ assetBulkUploadCheckDto: { assets: [{ id: asset.name, checksum }] } }); + } = await checkBulkUpload({ assetBulkUploadCheckDto: { assets: [{ id: assetFile.name, checksum }] } }); if (checkUploadResult.action === Action.Reject && checkUploadResult.assetId) { - responseData = { duplicate: true, id: checkUploadResult.assetId }; + responseData = { status: AssetMediaStatus.Duplicate, id: checkUploadResult.assetId }; } } catch (error) { - console.error(`Error calculating sha1 file=${asset.name})`, error); + console.error(`Error calculating sha1 file=${assetFile.name})`, error); } } + let status; + let id; if (!responseData) { uploadAssetsStore.updateAsset(deviceAssetId, { message: 'Uploading...' }); - const response = await uploadRequest({ - url: getBaseUrl() + '/asset/upload' + (key ? `?key=${key}` : ''), - data: formData, - onUploadProgress: (event) => uploadAssetsStore.updateProgress(deviceAssetId, event.loaded, event.total), - }); - if (![200, 201].includes(response.status)) { - throw new Error('Failed to upload file'); + if (replaceAssetId) { + const response = await uploadRequest({ + url: getBaseUrl() + '/asset/' + replaceAssetId + '/file' + (key ? `?key=${key}` : ''), + method: 'PUT', + data: formData, + onUploadProgress: (event) => uploadAssetsStore.updateProgress(deviceAssetId, event.loaded, event.total), + }); + ({ status, id } = response.data); + } else { + const response = await uploadRequest({ + url: getBaseUrl() + '/asset/upload' + (key ? `?key=${key}` : ''), + data: formData, + onUploadProgress: (event) => uploadAssetsStore.updateProgress(deviceAssetId, event.loaded, event.total), + }); + if (![200, 201].includes(response.status)) { + throw new Error('Failed to upload file'); + } + if (response.data.duplicate) { + status = AssetMediaStatus.Duplicate; + } else { + id = response.data.id; + } } - responseData = response.data; } - const { duplicate, id: assetId } = responseData; - if (duplicate) { + if (status === AssetMediaStatus.Duplicate) { uploadAssetsStore.duplicateCounter.update((count) => count + 1); } else { uploadAssetsStore.successCounter.update((c) => c + 1); + if (albumId && id) { + uploadAssetsStore.updateAsset(deviceAssetId, { message: 'Adding to album...' }); + await addAssetsToAlbum(albumId, [id]); + uploadAssetsStore.updateAsset(deviceAssetId, { message: 'Added to album' }); + } } - if (albumId && assetId) { - uploadAssetsStore.updateAsset(deviceAssetId, { message: 'Adding to album...' }); - await addAssetsToAlbum(albumId, [assetId]); - uploadAssetsStore.updateAsset(deviceAssetId, { message: 'Added to album' }); - } - - uploadAssetsStore.updateAsset(deviceAssetId, { state: duplicate ? UploadState.DUPLICATED : UploadState.DONE }); + uploadAssetsStore.updateAsset(deviceAssetId, { + state: status === AssetMediaStatus.Duplicate ? UploadState.DUPLICATED : UploadState.DONE, + }); setTimeout(() => { uploadAssetsStore.removeUploadAsset(deviceAssetId); }, 1000); - return assetId; + return id; } catch (error) { handleError(error, 'Unable to upload file'); const reason = getServerErrorMessage(error) || error; 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 f157f75c91..d63776342c 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 @@ -314,7 +314,7 @@ }; const handleSelectFromComputer = async () => { - await openFileUploadDialog(album.id); + await openFileUploadDialog({ albumId: album.id }); timelineInteractionStore.clearMultiselect(); viewMode = ViewMode.VIEW; }; diff --git a/web/src/routes/auth/login/+page.ts b/web/src/routes/auth/login/+page.ts index c24eea46bc..e308e117f7 100644 --- a/web/src/routes/auth/login/+page.ts +++ b/web/src/routes/auth/login/+page.ts @@ -1,9 +1,10 @@ import { AppRoute } from '$lib/constants'; -import { getServerConfig } from '@immich/sdk'; +import { defaults, getServerConfig } from '@immich/sdk'; import { redirect } from '@sveltejs/kit'; import type { PageLoad } from './$types'; -export const load = (async () => { +export const load = (async ({ fetch }) => { + defaults.fetch = fetch; const { isInitialized } = await getServerConfig(); if (!isInitialized) { // Admin not registered From 562c43b6f517ac1babaf1823d841f2eb50cfacdd Mon Sep 17 00:00:00 2001 From: Min Idzelis Date: Thu, 23 May 2024 22:10:38 -0400 Subject: [PATCH 141/163] test: reorder tests in asset.e2e-spec.ts (#9714) * Reorder tests; make tests independent of ordering * use it.each --- e2e/src/api/specs/asset.e2e-spec.ts | 725 ++++++++++++++-------------- 1 file changed, 370 insertions(+), 355 deletions(-) diff --git a/e2e/src/api/specs/asset.e2e-spec.ts b/e2e/src/api/specs/asset.e2e-spec.ts index 5dd3ec698b..5703d2ae72 100644 --- a/e2e/src/api/specs/asset.e2e-spec.ts +++ b/e2e/src/api/specs/asset.e2e-spec.ts @@ -72,7 +72,7 @@ describe('/asset', () => { let stackAssets: AssetFileUploadResponseDto[]; let locationAsset: AssetFileUploadResponseDto; - beforeAll(async () => { + const setupTests = async () => { await utils.resetDatabase(); admin = await utils.adminSetup({ onboarding: false }); @@ -155,7 +155,8 @@ describe('/asset', () => { assetId: user1Assets[0].id, personId: person1.id, }); - }, 30_000); + }; + beforeAll(setupTests, 30_000); afterAll(() => { utils.disconnectWebsocket(websocket); @@ -539,359 +540,6 @@ describe('/asset', () => { }); }); - describe('POST /asset/upload', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).post(`/asset/upload`); - expect(body).toEqual(errorDto.unauthorized); - expect(status).toBe(401); - }); - - const invalid = [ - { should: 'require `deviceAssetId`', dto: { ...makeUploadDto({ omit: 'deviceAssetId' }) } }, - { should: 'require `deviceId`', dto: { ...makeUploadDto({ omit: 'deviceId' }) } }, - { should: 'require `fileCreatedAt`', dto: { ...makeUploadDto({ omit: 'fileCreatedAt' }) } }, - { should: 'require `fileModifiedAt`', dto: { ...makeUploadDto({ omit: 'fileModifiedAt' }) } }, - { should: 'require `duration`', dto: { ...makeUploadDto({ omit: 'duration' }) } }, - { should: 'throw if `isFavorite` is not a boolean', dto: { ...makeUploadDto(), isFavorite: 'not-a-boolean' } }, - { should: 'throw if `isVisible` is not a boolean', dto: { ...makeUploadDto(), isVisible: 'not-a-boolean' } }, - { should: 'throw if `isArchived` is not a boolean', dto: { ...makeUploadDto(), isArchived: 'not-a-boolean' } }, - ]; - - for (const { should, dto } of invalid) { - it(`should ${should}`, async () => { - const { status, body } = await request(app) - .post('/asset/upload') - .set('Authorization', `Bearer ${user1.accessToken}`) - .attach('assetData', makeRandomImage(), 'example.png') - .field(dto); - expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest()); - }); - } - - const tests = [ - { - input: 'formats/avif/8bit-sRGB.avif', - expected: { - type: AssetTypeEnum.Image, - originalFileName: '8bit-sRGB.avif', - resized: true, - exifInfo: { - description: '', - exifImageHeight: 1080, - exifImageWidth: 1617, - fileSizeInByte: 862_424, - latitude: null, - longitude: null, - }, - }, - }, - { - input: 'formats/jpg/el_torcal_rocks.jpg', - expected: { - type: AssetTypeEnum.Image, - originalFileName: 'el_torcal_rocks.jpg', - resized: true, - exifInfo: { - dateTimeOriginal: '2012-08-05T11:39:59.000Z', - exifImageWidth: 512, - exifImageHeight: 341, - latitude: null, - longitude: null, - focalLength: 75, - iso: 200, - fNumber: 11, - exposureTime: '1/160', - fileSizeInByte: 53_493, - make: 'SONY', - model: 'DSLR-A550', - orientation: null, - description: 'SONY DSC', - }, - }, - }, - { - input: 'formats/jxl/8bit-sRGB.jxl', - expected: { - type: AssetTypeEnum.Image, - originalFileName: '8bit-sRGB.jxl', - resized: true, - exifInfo: { - description: '', - exifImageHeight: 1080, - exifImageWidth: 1440, - fileSizeInByte: 1_780_777, - latitude: null, - longitude: null, - }, - }, - }, - { - input: 'formats/heic/IMG_2682.heic', - expected: { - type: AssetTypeEnum.Image, - originalFileName: 'IMG_2682.heic', - resized: true, - fileCreatedAt: '2019-03-21T16:04:22.348Z', - exifInfo: { - dateTimeOriginal: '2019-03-21T16:04:22.348Z', - exifImageWidth: 4032, - exifImageHeight: 3024, - latitude: 41.2203, - longitude: -96.071_625, - make: 'Apple', - model: 'iPhone 7', - lensModel: 'iPhone 7 back camera 3.99mm f/1.8', - fileSizeInByte: 880_703, - exposureTime: '1/887', - iso: 20, - focalLength: 3.99, - fNumber: 1.8, - timeZone: 'America/Chicago', - }, - }, - }, - { - input: 'formats/png/density_plot.png', - expected: { - type: AssetTypeEnum.Image, - originalFileName: 'density_plot.png', - resized: true, - exifInfo: { - exifImageWidth: 800, - exifImageHeight: 800, - latitude: null, - longitude: null, - fileSizeInByte: 25_408, - }, - }, - }, - { - input: 'formats/raw/Nikon/D80/glarus.nef', - expected: { - type: AssetTypeEnum.Image, - originalFileName: 'glarus.nef', - resized: true, - fileCreatedAt: '2010-07-20T17:27:12.000Z', - exifInfo: { - make: 'NIKON CORPORATION', - model: 'NIKON D80', - exposureTime: '1/200', - fNumber: 10, - focalLength: 18, - iso: 100, - fileSizeInByte: 9_057_784, - dateTimeOriginal: '2010-07-20T17:27:12.000Z', - latitude: null, - longitude: null, - orientation: '1', - }, - }, - }, - { - input: 'formats/raw/Nikon/D700/philadelphia.nef', - expected: { - type: AssetTypeEnum.Image, - originalFileName: 'philadelphia.nef', - resized: true, - fileCreatedAt: '2016-09-22T22:10:29.060Z', - exifInfo: { - make: 'NIKON CORPORATION', - model: 'NIKON D700', - exposureTime: '1/400', - fNumber: 11, - focalLength: 85, - iso: 200, - fileSizeInByte: 15_856_335, - dateTimeOriginal: '2016-09-22T22:10:29.060Z', - latitude: null, - longitude: null, - orientation: '1', - timeZone: 'UTC-5', - }, - }, - }, - { - input: 'formats/raw/Panasonic/DMC-GH4/4_3.rw2', - expected: { - type: AssetTypeEnum.Image, - originalFileName: '4_3.rw2', - resized: true, - fileCreatedAt: '2018-05-10T08:42:37.842Z', - exifInfo: { - make: 'Panasonic', - model: 'DMC-GH4', - exifImageHeight: 3456, - exifImageWidth: 4608, - exposureTime: '1/100', - fNumber: 3.2, - focalLength: 35, - iso: 400, - fileSizeInByte: 19_587_072, - dateTimeOriginal: '2018-05-10T08:42:37.842Z', - latitude: null, - longitude: null, - orientation: '1', - }, - }, - }, - { - input: 'formats/raw/Sony/ILCE-6300/12bit-compressed-(3_2).arw', - expected: { - type: AssetTypeEnum.Image, - originalFileName: '12bit-compressed-(3_2).arw', - resized: true, - fileCreatedAt: '2016-09-27T10:51:44.000Z', - exifInfo: { - make: 'SONY', - model: 'ILCE-6300', - exifImageHeight: 4024, - exifImageWidth: 6048, - exposureTime: '1/320', - fNumber: 8, - focalLength: 97, - iso: 100, - lensModel: 'E PZ 18-105mm F4 G OSS', - fileSizeInByte: 25_001_984, - dateTimeOriginal: '2016-09-27T10:51:44.000Z', - latitude: null, - longitude: null, - orientation: '1', - }, - }, - }, - { - input: 'formats/raw/Sony/ILCE-7M2/14bit-uncompressed-(3_2).arw', - expected: { - type: AssetTypeEnum.Image, - originalFileName: '14bit-uncompressed-(3_2).arw', - resized: true, - fileCreatedAt: '2016-01-08T15:08:01.000Z', - exifInfo: { - make: 'SONY', - model: 'ILCE-7M2', - exifImageHeight: 4024, - exifImageWidth: 6048, - exposureTime: '1.3', - fNumber: 22, - focalLength: 25, - iso: 100, - lensModel: 'E 25mm F2', - fileSizeInByte: 49_512_448, - dateTimeOriginal: '2016-01-08T15:08:01.000Z', - latitude: null, - longitude: null, - orientation: '1', - }, - }, - }, - ]; - - for (const { input, expected } of tests) { - it(`should upload and generate a thumbnail for ${input}`, async () => { - const filepath = join(testAssetDir, input); - const { id, duplicate } = await utils.createAsset(admin.accessToken, { - assetData: { bytes: await readFile(filepath), filename: basename(filepath) }, - }); - - expect(duplicate).toBe(false); - - await utils.waitForWebsocketEvent({ event: 'assetUpload', id: id }); - - const asset = await utils.getAssetInfo(admin.accessToken, id); - - expect(asset.exifInfo).toBeDefined(); - expect(asset.exifInfo).toMatchObject(expected.exifInfo); - expect(asset).toMatchObject(expected); - }); - } - - it('should handle a duplicate', async () => { - const filepath = 'formats/jpeg/el_torcal_rocks.jpeg'; - const { duplicate } = await utils.createAsset(admin.accessToken, { - assetData: { - bytes: await readFile(join(testAssetDir, filepath)), - filename: basename(filepath), - }, - }); - - expect(duplicate).toBe(true); - }); - - it('should update the used quota', async () => { - const { body, status } = await request(app) - .post('/asset/upload') - .set('Authorization', `Bearer ${quotaUser.accessToken}`) - .field('deviceAssetId', 'example-image') - .field('deviceId', 'e2e') - .field('fileCreatedAt', new Date().toISOString()) - .field('fileModifiedAt', new Date().toISOString()) - .attach('assetData', makeRandomImage(), 'example.jpg'); - - expect(body).toEqual({ id: expect.any(String), duplicate: false }); - expect(status).toBe(201); - - const user = await getMyUserInfo({ headers: asBearerAuth(quotaUser.accessToken) }); - - expect(user).toEqual(expect.objectContaining({ quotaUsageInBytes: 70 })); - }); - - it('should not upload an asset if it would exceed the quota', async () => { - const { body, status } = await request(app) - .post('/asset/upload') - .set('Authorization', `Bearer ${quotaUser.accessToken}`) - .field('deviceAssetId', 'example-image') - .field('deviceId', 'e2e') - .field('fileCreatedAt', new Date().toISOString()) - .field('fileModifiedAt', new Date().toISOString()) - .attach('assetData', randomBytes(2014), 'example.jpg'); - - expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest('Quota has been exceeded!')); - }); - - // These hashes were created by copying the image files to a Samsung phone, - // exporting the video from Samsung's stock Gallery app, and hashing them locally. - // This ensures that immich+exiftool are extracting the videos the same way Samsung does. - // DO NOT assume immich+exiftool are doing things correctly and just copy whatever hash it gives - // into the test here. - const motionTests = [ - { - filepath: 'formats/motionphoto/Samsung One UI 5.jpg', - checksum: 'fr14niqCq6N20HB8rJYEvpsUVtI=', - }, - { - filepath: 'formats/motionphoto/Samsung One UI 6.jpg', - checksum: 'lT9Uviw/FFJYCjfIxAGPTjzAmmw=', - }, - { - filepath: 'formats/motionphoto/Samsung One UI 6.heic', - checksum: '/ejgzywvgvzvVhUYVfvkLzFBAF0=', - }, - ]; - - for (const { filepath, checksum } of motionTests) { - it(`should extract motionphoto video from ${filepath}`, async () => { - const response = await utils.createAsset(admin.accessToken, { - assetData: { - bytes: await readFile(join(testAssetDir, filepath)), - filename: basename(filepath), - }, - }); - - await utils.waitForWebsocketEvent({ event: 'assetUpload', id: response.id }); - - expect(response.duplicate).toBe(false); - - const asset = await utils.getAssetInfo(admin.accessToken, response.id); - expect(asset.livePhotoVideoId).toBeDefined(); - - const video = await utils.getAssetInfo(admin.accessToken, asset.livePhotoVideoId as string); - expect(video.checksum).toStrictEqual(checksum); - }); - } - }); - describe('GET /asset/thumbnail/:id', () => { it('should require authentication', async () => { const { status, body } = await request(app).get(`/asset/thumbnail/${locationAsset.id}`); @@ -963,6 +611,31 @@ describe('/asset', () => { }); describe('GET /asset/map-marker', () => { + beforeAll(async () => { + const files = [ + 'formats/avif/8bit-sRGB.avif', + 'formats/jpg/el_torcal_rocks.jpg', + 'formats/jxl/8bit-sRGB.jxl', + 'formats/heic/IMG_2682.heic', + 'formats/png/density_plot.png', + 'formats/raw/Nikon/D80/glarus.nef', + 'formats/raw/Nikon/D700/philadelphia.nef', + 'formats/raw/Panasonic/DMC-GH4/4_3.rw2', + 'formats/raw/Sony/ILCE-6300/12bit-compressed-(3_2).arw', + 'formats/raw/Sony/ILCE-7M2/14bit-uncompressed-(3_2).arw', + ]; + utils.resetEvents(); + const uploadFile = async (input: string) => { + const filepath = join(testAssetDir, input); + const { id } = await utils.createAsset(admin.accessToken, { + assetData: { bytes: await readFile(filepath), filename: basename(filepath) }, + }); + await utils.waitForWebsocketEvent({ event: 'assetUpload', id }); + }; + const uploads = files.map((f) => uploadFile(f)); + await Promise.all(uploads); + }, 30_000); + it('should require authentication', async () => { const { status, body } = await request(app).get('/asset/map-marker'); expect(status).toBe(401); @@ -1193,4 +866,346 @@ describe('/asset', () => { ); }); }); + describe('POST /asset/upload', () => { + beforeAll(setupTests, 30_000); + + it('should require authentication', async () => { + const { status, body } = await request(app).post(`/asset/upload`); + expect(body).toEqual(errorDto.unauthorized); + expect(status).toBe(401); + }); + + it.each([ + { should: 'require `deviceAssetId`', dto: { ...makeUploadDto({ omit: 'deviceAssetId' }) } }, + { should: 'require `deviceId`', dto: { ...makeUploadDto({ omit: 'deviceId' }) } }, + { should: 'require `fileCreatedAt`', dto: { ...makeUploadDto({ omit: 'fileCreatedAt' }) } }, + { should: 'require `fileModifiedAt`', dto: { ...makeUploadDto({ omit: 'fileModifiedAt' }) } }, + { should: 'require `duration`', dto: { ...makeUploadDto({ omit: 'duration' }) } }, + { should: 'throw if `isFavorite` is not a boolean', dto: { ...makeUploadDto(), isFavorite: 'not-a-boolean' } }, + { should: 'throw if `isVisible` is not a boolean', dto: { ...makeUploadDto(), isVisible: 'not-a-boolean' } }, + { should: 'throw if `isArchived` is not a boolean', dto: { ...makeUploadDto(), isArchived: 'not-a-boolean' } }, + ])('should $should', async ({ dto }) => { + const { status, body } = await request(app) + .post('/asset/upload') + .set('Authorization', `Bearer ${user1.accessToken}`) + .attach('assetData', makeRandomImage(), 'example.png') + .field(dto); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest()); + }); + + it.each([ + { + input: 'formats/avif/8bit-sRGB.avif', + expected: { + type: AssetTypeEnum.Image, + originalFileName: '8bit-sRGB.avif', + resized: true, + exifInfo: { + description: '', + exifImageHeight: 1080, + exifImageWidth: 1617, + fileSizeInByte: 862_424, + latitude: null, + longitude: null, + }, + }, + }, + { + input: 'formats/jpg/el_torcal_rocks.jpg', + expected: { + type: AssetTypeEnum.Image, + originalFileName: 'el_torcal_rocks.jpg', + resized: true, + exifInfo: { + dateTimeOriginal: '2012-08-05T11:39:59.000Z', + exifImageWidth: 512, + exifImageHeight: 341, + latitude: null, + longitude: null, + focalLength: 75, + iso: 200, + fNumber: 11, + exposureTime: '1/160', + fileSizeInByte: 53_493, + make: 'SONY', + model: 'DSLR-A550', + orientation: null, + description: 'SONY DSC', + }, + }, + }, + { + input: 'formats/jxl/8bit-sRGB.jxl', + expected: { + type: AssetTypeEnum.Image, + originalFileName: '8bit-sRGB.jxl', + resized: true, + exifInfo: { + description: '', + exifImageHeight: 1080, + exifImageWidth: 1440, + fileSizeInByte: 1_780_777, + latitude: null, + longitude: null, + }, + }, + }, + { + input: 'formats/heic/IMG_2682.heic', + expected: { + type: AssetTypeEnum.Image, + originalFileName: 'IMG_2682.heic', + resized: true, + fileCreatedAt: '2019-03-21T16:04:22.348Z', + exifInfo: { + dateTimeOriginal: '2019-03-21T16:04:22.348Z', + exifImageWidth: 4032, + exifImageHeight: 3024, + latitude: 41.2203, + longitude: -96.071_625, + make: 'Apple', + model: 'iPhone 7', + lensModel: 'iPhone 7 back camera 3.99mm f/1.8', + fileSizeInByte: 880_703, + exposureTime: '1/887', + iso: 20, + focalLength: 3.99, + fNumber: 1.8, + timeZone: 'America/Chicago', + }, + }, + }, + { + input: 'formats/png/density_plot.png', + expected: { + type: AssetTypeEnum.Image, + originalFileName: 'density_plot.png', + resized: true, + exifInfo: { + exifImageWidth: 800, + exifImageHeight: 800, + latitude: null, + longitude: null, + fileSizeInByte: 25_408, + }, + }, + }, + { + input: 'formats/raw/Nikon/D80/glarus.nef', + expected: { + type: AssetTypeEnum.Image, + originalFileName: 'glarus.nef', + resized: true, + fileCreatedAt: '2010-07-20T17:27:12.000Z', + exifInfo: { + make: 'NIKON CORPORATION', + model: 'NIKON D80', + exposureTime: '1/200', + fNumber: 10, + focalLength: 18, + iso: 100, + fileSizeInByte: 9_057_784, + dateTimeOriginal: '2010-07-20T17:27:12.000Z', + latitude: null, + longitude: null, + orientation: '1', + }, + }, + }, + { + input: 'formats/raw/Nikon/D700/philadelphia.nef', + expected: { + type: AssetTypeEnum.Image, + originalFileName: 'philadelphia.nef', + resized: true, + fileCreatedAt: '2016-09-22T22:10:29.060Z', + exifInfo: { + make: 'NIKON CORPORATION', + model: 'NIKON D700', + exposureTime: '1/400', + fNumber: 11, + focalLength: 85, + iso: 200, + fileSizeInByte: 15_856_335, + dateTimeOriginal: '2016-09-22T22:10:29.060Z', + latitude: null, + longitude: null, + orientation: '1', + timeZone: 'UTC-5', + }, + }, + }, + { + input: 'formats/raw/Panasonic/DMC-GH4/4_3.rw2', + expected: { + type: AssetTypeEnum.Image, + originalFileName: '4_3.rw2', + resized: true, + fileCreatedAt: '2018-05-10T08:42:37.842Z', + exifInfo: { + make: 'Panasonic', + model: 'DMC-GH4', + exifImageHeight: 3456, + exifImageWidth: 4608, + exposureTime: '1/100', + fNumber: 3.2, + focalLength: 35, + iso: 400, + fileSizeInByte: 19_587_072, + dateTimeOriginal: '2018-05-10T08:42:37.842Z', + latitude: null, + longitude: null, + orientation: '1', + }, + }, + }, + { + input: 'formats/raw/Sony/ILCE-6300/12bit-compressed-(3_2).arw', + expected: { + type: AssetTypeEnum.Image, + originalFileName: '12bit-compressed-(3_2).arw', + resized: true, + fileCreatedAt: '2016-09-27T10:51:44.000Z', + exifInfo: { + make: 'SONY', + model: 'ILCE-6300', + exifImageHeight: 4024, + exifImageWidth: 6048, + exposureTime: '1/320', + fNumber: 8, + focalLength: 97, + iso: 100, + lensModel: 'E PZ 18-105mm F4 G OSS', + fileSizeInByte: 25_001_984, + dateTimeOriginal: '2016-09-27T10:51:44.000Z', + latitude: null, + longitude: null, + orientation: '1', + }, + }, + }, + { + input: 'formats/raw/Sony/ILCE-7M2/14bit-uncompressed-(3_2).arw', + expected: { + type: AssetTypeEnum.Image, + originalFileName: '14bit-uncompressed-(3_2).arw', + resized: true, + fileCreatedAt: '2016-01-08T15:08:01.000Z', + exifInfo: { + make: 'SONY', + model: 'ILCE-7M2', + exifImageHeight: 4024, + exifImageWidth: 6048, + exposureTime: '1.3', + fNumber: 22, + focalLength: 25, + iso: 100, + lensModel: 'E 25mm F2', + fileSizeInByte: 49_512_448, + dateTimeOriginal: '2016-01-08T15:08:01.000Z', + latitude: null, + longitude: null, + orientation: '1', + }, + }, + }, + ])(`should upload and generate a thumbnail for $input`, async ({ input, expected }) => { + const filepath = join(testAssetDir, input); + const { id, duplicate } = await utils.createAsset(admin.accessToken, { + assetData: { bytes: await readFile(filepath), filename: basename(filepath) }, + }); + + expect(duplicate).toBe(false); + + await utils.waitForWebsocketEvent({ event: 'assetUpload', id: id }); + + const asset = await utils.getAssetInfo(admin.accessToken, id); + + expect(asset.exifInfo).toBeDefined(); + expect(asset.exifInfo).toMatchObject(expected.exifInfo); + expect(asset).toMatchObject(expected); + }); + + it('should handle a duplicate', async () => { + const filepath = 'formats/jpeg/el_torcal_rocks.jpeg'; + const { duplicate } = await utils.createAsset(admin.accessToken, { + assetData: { + bytes: await readFile(join(testAssetDir, filepath)), + filename: basename(filepath), + }, + }); + + expect(duplicate).toBe(true); + }); + + it('should update the used quota', async () => { + const { body, status } = await request(app) + .post('/asset/upload') + .set('Authorization', `Bearer ${quotaUser.accessToken}`) + .field('deviceAssetId', 'example-image') + .field('deviceId', 'e2e') + .field('fileCreatedAt', new Date().toISOString()) + .field('fileModifiedAt', new Date().toISOString()) + .attach('assetData', makeRandomImage(), 'example.jpg'); + + expect(body).toEqual({ id: expect.any(String), duplicate: false }); + expect(status).toBe(201); + + const user = await getMyUserInfo({ headers: asBearerAuth(quotaUser.accessToken) }); + + expect(user).toEqual(expect.objectContaining({ quotaUsageInBytes: 70 })); + }); + + it('should not upload an asset if it would exceed the quota', async () => { + const { body, status } = await request(app) + .post('/asset/upload') + .set('Authorization', `Bearer ${quotaUser.accessToken}`) + .field('deviceAssetId', 'example-image') + .field('deviceId', 'e2e') + .field('fileCreatedAt', new Date().toISOString()) + .field('fileModifiedAt', new Date().toISOString()) + .attach('assetData', randomBytes(2014), 'example.jpg'); + + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest('Quota has been exceeded!')); + }); + + // These hashes were created by copying the image files to a Samsung phone, + // exporting the video from Samsung's stock Gallery app, and hashing them locally. + // This ensures that immich+exiftool are extracting the videos the same way Samsung does. + // DO NOT assume immich+exiftool are doing things correctly and just copy whatever hash it gives + // into the test here. + it.each([ + { + filepath: 'formats/motionphoto/Samsung One UI 5.jpg', + checksum: 'fr14niqCq6N20HB8rJYEvpsUVtI=', + }, + { + filepath: 'formats/motionphoto/Samsung One UI 6.jpg', + checksum: 'lT9Uviw/FFJYCjfIxAGPTjzAmmw=', + }, + { + filepath: 'formats/motionphoto/Samsung One UI 6.heic', + checksum: '/ejgzywvgvzvVhUYVfvkLzFBAF0=', + }, + ])(`should extract motionphoto video from $filepath`, async ({ filepath, checksum }) => { + const response = await utils.createAsset(admin.accessToken, { + assetData: { + bytes: await readFile(join(testAssetDir, filepath)), + filename: basename(filepath), + }, + }); + + await utils.waitForWebsocketEvent({ event: 'assetUpload', id: response.id }); + + expect(response.duplicate).toBe(false); + + const asset = await utils.getAssetInfo(admin.accessToken, response.id); + expect(asset.livePhotoVideoId).toBeDefined(); + + const video = await utils.getAssetInfo(admin.accessToken, asset.livePhotoVideoId as string); + expect(video.checksum).toStrictEqual(checksum); + }); + }); }); From b2a0422efbb01ea29dbb37b3819a3c875ed3c0ee Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 24 May 2024 09:34:49 +0100 Subject: [PATCH 142/163] chore(deps): update redis:6.2-alpine docker digest to e31ca60 (#9718) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- docker/docker-compose.dev.yml | 2 +- docker/docker-compose.prod.yml | 2 +- e2e/docker-compose.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index a2b8bf0bb4..29cea1a873 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -81,7 +81,7 @@ services: redis: container_name: immich_redis - image: redis:6.2-alpine@sha256:c0634a08e74a4bb576d02d1ee993dc05dba10e8b7b9492dfa28a7af100d46c01 + image: redis:6.2-alpine@sha256:e31ca60b18f7e9b78b573d156702471d4eda038803c0b8e6f01559f350031e93 healthcheck: test: redis-cli ping || exit 1 diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml index e47b7b499c..6168ea66dd 100644 --- a/docker/docker-compose.prod.yml +++ b/docker/docker-compose.prod.yml @@ -38,7 +38,7 @@ services: redis: container_name: immich_redis - image: redis:6.2-alpine@sha256:c0634a08e74a4bb576d02d1ee993dc05dba10e8b7b9492dfa28a7af100d46c01 + image: redis:6.2-alpine@sha256:e31ca60b18f7e9b78b573d156702471d4eda038803c0b8e6f01559f350031e93 healthcheck: test: redis-cli ping || exit 1 restart: always diff --git a/e2e/docker-compose.yml b/e2e/docker-compose.yml index 47c7dfbabb..dca9ca0b2d 100644 --- a/e2e/docker-compose.yml +++ b/e2e/docker-compose.yml @@ -27,7 +27,7 @@ services: - 2283:3001 redis: - image: redis:6.2-alpine@sha256:c0634a08e74a4bb576d02d1ee993dc05dba10e8b7b9492dfa28a7af100d46c01 + image: redis:6.2-alpine@sha256:e31ca60b18f7e9b78b573d156702471d4eda038803c0b8e6f01559f350031e93 database: image: tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:90724186f0a3517cf6914295b5ab410db9ce23190a2d9d0b9dd6463e3fa298f0 From 3f44a33eac1ff2dfdc54357e13babb9215ef05c1 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 24 May 2024 09:35:59 +0100 Subject: [PATCH 143/163] chore(deps): update docker.io/redis:6.2-alpine docker digest to e31ca60 (#9717) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- docker/docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index d5d4316349..2669db5080 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -40,7 +40,7 @@ services: redis: container_name: immich_redis - image: docker.io/redis:6.2-alpine@sha256:c0634a08e74a4bb576d02d1ee993dc05dba10e8b7b9492dfa28a7af100d46c01 + image: docker.io/redis:6.2-alpine@sha256:e31ca60b18f7e9b78b573d156702471d4eda038803c0b8e6f01559f350031e93 healthcheck: test: redis-cli ping || exit 1 restart: always From 69b5eb005f055ffd05e942888a00227acc7f7d29 Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Fri, 24 May 2024 04:50:28 -0400 Subject: [PATCH 144/163] fix(server): use qsv format for hwmap (#9722) use qsv format for hwmap --- server/src/services/media.service.spec.ts | 16 +++++++++++++--- server/src/utils/media.ts | 4 ++-- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts index 3c8200944e..9fe0038232 100644 --- a/server/src/services/media.service.spec.ts +++ b/server/src/services/media.service.spec.ts @@ -1496,7 +1496,12 @@ describe(MediaService.name, () => { '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', { - inputOptions: expect.arrayContaining(['-hwaccel qsv', '-async_depth 4', '-threads 1']), + inputOptions: expect.arrayContaining([ + '-hwaccel qsv', + '-hwaccel_output_format qsv', + '-async_depth 4', + '-threads 1', + ]), outputOptions: expect.arrayContaining([ expect.stringContaining('scale_qsv=-1:720:async_depth=4:mode=hq:format=nv12'), ]), @@ -1519,10 +1524,15 @@ describe(MediaService.name, () => { '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', { - inputOptions: expect.arrayContaining(['-hwaccel qsv', '-async_depth 4', '-threads 1']), + inputOptions: expect.arrayContaining([ + '-hwaccel qsv', + '-hwaccel_output_format qsv', + '-async_depth 4', + '-threads 1', + ]), outputOptions: expect.arrayContaining([ expect.stringContaining( - 'hwmap=derive_device=opencl,tonemap_opencl=desat=0:format=nv12:matrix=bt709:primaries=bt709:range=pc:tonemap=hable:transfer=bt709,hwmap=derive_device=vaapi:reverse=1', + 'hwmap=derive_device=opencl,tonemap_opencl=desat=0:format=nv12:matrix=bt709:primaries=bt709:range=pc:tonemap=hable:transfer=bt709,hwmap=derive_device=qsv:reverse=1,format=qsv', ), ]), twoPass: false, diff --git a/server/src/utils/media.ts b/server/src/utils/media.ts index 268f1f60ce..5a57f0f0cf 100644 --- a/server/src/utils/media.ts +++ b/server/src/utils/media.ts @@ -649,7 +649,7 @@ export class QsvHwDecodeConfig extends QsvSwDecodeConfig { throw new Error('No QSV device found'); } - const options = ['-hwaccel qsv', '-async_depth 4', '-threads 1']; + const options = ['-hwaccel qsv', '-hwaccel_output_format qsv', '-async_depth 4', '-threads 1']; const hwDevice = this.getPreferredHardwareDevice(); if (hwDevice) { options.push(`-qsv_device ${hwDevice}`); @@ -691,7 +691,7 @@ export class QsvHwDecodeConfig extends QsvSwDecodeConfig { return [ 'hwmap=derive_device=opencl', `tonemap_opencl=${tonemapOptions.join(':')}`, - 'hwmap=derive_device=vaapi:reverse=1', + 'hwmap=derive_device=qsv:reverse=1,format=qsv', ]; } } From b3b258f32f0a08425466d6dff75a19bfef24fbd8 Mon Sep 17 00:00:00 2001 From: Lukas Date: Fri, 24 May 2024 10:56:36 +0200 Subject: [PATCH 145/163] fix(web): allow copying text in photo viewer (#9705) * fix(web): allow copying text in photo viewer * use default browser copy * revert changes * fix lint --- web/src/lib/components/asset-viewer/photo-viewer.svelte | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/web/src/lib/components/asset-viewer/photo-viewer.svelte b/web/src/lib/components/asset-viewer/photo-viewer.svelte index f541c1ca0a..7ad0cd51fe 100644 --- a/web/src/lib/components/asset-viewer/photo-viewer.svelte +++ b/web/src/lib/components/asset-viewer/photo-viewer.svelte @@ -120,10 +120,11 @@ forceLoadOriginal = state.currentZoom > 1 && isWebCompatibleImage(asset) ? true : false; }); - const onCopyShortcut = () => { + const onCopyShortcut = (event: KeyboardEvent) => { if (window.getSelection()?.type === 'Range') { return; } + event.preventDefault(); handlePromiseError(doCopy()); }; @@ -132,8 +133,8 @@ on:copyImage={doCopy} on:zoomImage={doZoomImage} use:shortcuts={[ - { shortcut: { key: 'c', ctrl: true }, onShortcut: onCopyShortcut }, - { shortcut: { key: 'c', meta: true }, onShortcut: onCopyShortcut }, + { shortcut: { key: 'c', ctrl: true }, onShortcut: onCopyShortcut, preventDefault: false }, + { shortcut: { key: 'c', meta: true }, onShortcut: onCopyShortcut, preventDefault: false }, ]} /> {#if imageError} From 56ea07bcba046f0d2c0a0270c0a422f4232f70fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois-Xavier=20Payet?= Date: Fri, 24 May 2024 10:59:05 +0200 Subject: [PATCH 146/163] fix(mobile): use correct Focus Node for latitude and longitude (#9699) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FIx focus node for longitude Co-authored-by: François-Xavier Payet --- mobile/lib/widgets/common/location_picker.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mobile/lib/widgets/common/location_picker.dart b/mobile/lib/widgets/common/location_picker.dart index 1fca268d63..3be3ed428a 100644 --- a/mobile/lib/widgets/common/location_picker.dart +++ b/mobile/lib/widgets/common/location_picker.dart @@ -215,7 +215,7 @@ class _ManualPicker extends HookWidget { decorationText: "location_picker_longitude", hintText: "location_picker_longitude_hint", errorText: "location_picker_longitude_error", - focusNode: latitiudeFocusNode, + focusNode: longitudeFocusNode, validator: _validateLong, onUpdated: onLongitudeEditingCompleted, ), From e98744f22291fad9f7c1f2503e4aae5b3835f31d Mon Sep 17 00:00:00 2001 From: Julian Collins <68151858+Leatryx@users.noreply.github.com> Date: Fri, 24 May 2024 12:14:07 +0300 Subject: [PATCH 147/163] chore(docs): Russian readme update (#9691) * Fix many typos, update features, add Activity and Star history sections * Add clarity * Add clarity --- readme_i18n/README_ru_RU.md | 68 +++++++++++++++++++++++-------------- 1 file changed, 43 insertions(+), 25 deletions(-) diff --git a/readme_i18n/README_ru_RU.md b/readme_i18n/README_ru_RU.md index cb31268d15..d972c2f7e1 100644 --- a/readme_i18n/README_ru_RU.md +++ b/readme_i18n/README_ru_RU.md @@ -9,12 +9,12 @@

- +

-

Immich - Высокопроизводительное решение для автономоного создания фото и видео архивов

+

Высокопроизводительное автономное решение для хранения и группировки фото и видео


- +

@@ -38,9 +38,9 @@ ## Предупреждение - ⚠️ Этот проект находится **в очень активной** разработке. -- ⚠️ Ожидайте ошибок и критических изменение. -- ⚠️ **Не используйте это приложение для бекапа ваших фото и видео.** -- ⚠️ Всегда следуйте [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) плану резервного копирования ваших драгоценных фото и видео! +- ⚠️ Ожидайте множество ошибок и глобальных изменений. +- ⚠️ **Не используйте это приложение как единственное хранилище своих фото и видео.** +- ⚠️ Всегда следуйте [плану резервного копирования «3-2-1»](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/ "Стратегии резервного копирования: Почему стратегия резервного копирования «3-2-1» — лучшая") для ваших драгоценных фотографий и видео! ## Содержание @@ -49,18 +49,18 @@ - [Демо](#demo) - [Возможности](#features) - [Введение](https://immich.app/docs/overview/introduction) -- [Инсталяция](https://immich.app/docs/install/requirements) -- [Гайд по доработке проекта](https://immich.app/docs/overview/support-the-project) +- [Установка](https://immich.app/docs/install/requirements) +- [Гид по доработке проекта](https://immich.app/docs/overview/support-the-project) ## Документация -Вы можете найти основную документация, включая инструкции по установке по ссылке https://immich.app/. +Вы можете прочитать инструкции по установке и остальную документацию [здесь](https://immich.app/) ## Демо -Вы можете посмотреть веб демо по ссылке https://demo.immich.app +Вы можете опробовать [демонстрационную версию](https://demo.immich.app/). -Для мобильного приложения вы можете использовать адрес `https://demo.immich.app/api` в поле `Server Endpoint URL` +Для мобильного приложения вы можете использовать адрес `https://demo.immich.app/api` в поле `Server Endpoint URL`. ```bash title="Демо доступ" Реквизиты доступа @@ -72,38 +72,56 @@ Spec: Free-tier Oracle VM - Amsterdam - 2.4Ghz quad-core ARM64 CPU, 24GB RAM ``` +## Активность + +![Activities](https://repobeats.axiom.co/api/embed/9e86d9dc3ddd137161f2f6d2e758d7863b1789cb.svg "Repobeats analytics image") + ## Возможности + | Возможности | Приложение | Веб | | --------------------------------------------------- | ---------- | --- | -| Выгрузка на сервер и просмотр видео и фото | Да | Да | -| Авто бекап когда приложение открыто | Да | Н/Д | -| Выбор альбома(ов) для бекапа | Да | Н/Д | -| загрузка с сервера фото и видео на устройство | Да | Да | +| Загрузка на сервер и просмотр видео и фото | Да | Да | +| Автоматический бекап, когда приложение открыто | Да | Н/Д | +| Предотвращение дупликации данных | Да | Да | +| Выбор альбома (-ов) для бекапа | Да | Н/Д | +| Скачивание с сервера фото и видео на устройство | Да | Да | | Поддержка нескольких пользователей | Да | Да | | Альбомы и общие альбомы | Да | Да | | Прокручиваемая/перетаскиваемая полоса прокрутки | Да | Да | -| Поддержка формата RAW | Да | Да | +| Поддержка raw-форматов | Да | Да | | Просмотр метаданных (EXIF, map) | Да | Да | | Поиск до метаданным, объектам, лицам и CLIP | Да | Да | -| Административные функци (управление пользователями) | Нет | Да | -| Фоновый бекпа | Да | Н/Д | +| Административные функции (управление пользователями)| Нет | Да | +| Фоновое резервное копирование | Да | Н/Д | | Виртуальная прокрутка | Да | Да | | Поддержка OAuth | Да | Да | | Ключи API | Н/Д | Да | -| LivePhoto/MotionPhoto бекап и воспроизведение | Да | Да | +| LivePhoto/MotionPhoto воспроизведение и бекап | Да | Да | +| Поддержка отображения изображений 360° | Нет | Да | | Настраиваемая структура хранилища | Да | Да | -| Публичные альбомы | Нет | Да | -| Архив и Избранное | Да | Да | +| Общий доступ к контенту | Нет | Да | +| Архив и избранное | Да | Да | | Мировая карта | Да | Да | | Совместное использование | Да | Да | -| Распознавание лиц и группировка по лицам | Да | Да | -| В этот день (x лет назад) | Да | Да | +| Распознавание и группировка по лицам | Да | Да | +| Воспоминания (в этот день x лет назад) | Да | Да | | Работа без интернета | Да | Нет | -| Галлереи только для просмотра | Да | Да | -| Колллажи | Да | Да | +| Галереи только для просмотра | Да | Да | +| Коллажи | Да | Да | ## Авторы + + +## Star History + + + + + + Star History Chart + + From 1f5d82e9d9a0cfd19b6c1841ac4ebb3ebff76521 Mon Sep 17 00:00:00 2001 From: Kedas Date: Fri, 24 May 2024 02:16:14 -0700 Subject: [PATCH 148/163] fix(mobile): respect SSL override during background sync (#9587) --- mobile/lib/services/background.service.dart | 2 ++ 1 file changed, 2 insertions(+) diff --git a/mobile/lib/services/background.service.dart b/mobile/lib/services/background.service.dart index 8e451cc271..ba8f5c01ed 100644 --- a/mobile/lib/services/background.service.dart +++ b/mobile/lib/services/background.service.dart @@ -20,6 +20,7 @@ import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/utils/backup_progress.dart'; import 'package:immich_mobile/utils/diff.dart'; +import 'package:immich_mobile/utils/http_ssl_cert_override.dart'; import 'package:isar/isar.dart'; import 'package:path_provider_ios/path_provider_ios.dart'; import 'package:photo_manager/photo_manager.dart'; @@ -590,6 +591,7 @@ enum IosBackgroundTask { fetch, processing } /// entry point called by Kotlin/Java code; needs to be a top-level function @pragma('vm:entry-point') void _nativeEntry() { + HttpOverrides.global = HttpSSLCertOverride(); WidgetsFlutterBinding.ensureInitialized(); DartPluginRegistrant.ensureInitialized(); BackgroundService backgroundService = BackgroundService(); From 39d2c4f37bdc3d4e559a018df56b583ddd024d14 Mon Sep 17 00:00:00 2001 From: Zack Pollard Date: Fri, 24 May 2024 15:37:01 +0100 Subject: [PATCH 149/163] chore: remove all deprecated endpoints/properties from server and mobile app (#9724) * chore: remove deprecated title property from MemoryLaneResponseDto * chore: remove deprecated webpPath and resizePath from MetadataSearchDto * chore: remove deprecated sharedUserIds property from Album AddUsersDto * chore: remove deprecated sharedUsers property from AlbumResponseDto * chore: remove deprecated sharedWithUserIds property from CreateAlbumDto * chore: remove deprecated isExternal and isReadOnly properties from AssetResponseDto * chore: remove deprecated /server-info endpoint * chore: bloody linters --- e2e/src/api/specs/album.e2e-spec.ts | 7 +- e2e/src/api/specs/server-info.e2e-spec.ts | 6 +- mobile/lib/entities/album.entity.dart | 7 +- mobile/lib/services/album.service.dart | 9 +- mobile/lib/services/memory.service.dart | 6 +- mobile/lib/services/sync.service.dart | 10 +- ...e_first_letter.dart => string_helper.dart} | 2 + mobile/openapi/README.md | 2 - mobile/openapi/lib/api.dart | 1 - mobile/openapi/lib/api/deprecated_api.dart | 62 ------------- mobile/openapi/lib/api/server_info_api.dart | 44 --------- mobile/openapi/lib/model/add_users_dto.dart | 16 +--- .../openapi/lib/model/album_response_dto.dart | 11 +-- .../openapi/lib/model/asset_response_dto.dart | 38 +------- .../openapi/lib/model/create_album_dto.dart | 17 +--- .../lib/model/memory_lane_response_dto.dart | 11 +-- .../lib/model/metadata_search_dto.dart | 38 +------- open-api/immich-openapi-specs.json | 92 ------------------- open-api/typescript-sdk/src/fetch-client.ts | 46 ++-------- .../src/controllers/server-info.controller.ts | 8 -- server/src/dtos/album.dto.ts | 13 --- server/src/dtos/asset-response.dto.ts | 9 -- server/src/dtos/search.dto.ts | 13 --- server/src/services/album.service.spec.ts | 4 +- server/src/services/album.service.ts | 15 +-- server/src/services/search.service.ts | 3 - server/test/fixtures/shared-link.stub.ts | 2 - .../album-page/album-options.svelte | 2 +- .../album-page/user-selection-modal.svelte | 4 +- .../asset-viewer/detail-panel.svelte | 2 +- .../[[assetId=id]]/+page.svelte | 16 ++-- web/src/test-data/factories/album-factory.ts | 1 - 32 files changed, 66 insertions(+), 451 deletions(-) rename mobile/lib/utils/{capitalize_first_letter.dart => string_helper.dart} (75%) delete mode 100644 mobile/openapi/lib/api/deprecated_api.dart diff --git a/e2e/src/api/specs/album.e2e-spec.ts b/e2e/src/api/specs/album.e2e-spec.ts index 5cebe8f42c..4a231dbf9b 100644 --- a/e2e/src/api/specs/album.e2e-spec.ts +++ b/e2e/src/api/specs/album.e2e-spec.ts @@ -383,7 +383,6 @@ describe('/albums', () => { description: '', albumThumbnailAssetId: null, shared: false, - sharedUsers: [], albumUsers: [], hasSharedLink: false, assets: [], @@ -611,7 +610,11 @@ describe('/albums', () => { expect(status).toBe(200); expect(body).toEqual( expect.objectContaining({ - sharedUsers: [expect.objectContaining({ id: user2.userId })], + albumUsers: [ + expect.objectContaining({ + user: expect.objectContaining({ id: user2.userId }), + }), + ], }), ); }); diff --git a/e2e/src/api/specs/server-info.e2e-spec.ts b/e2e/src/api/specs/server-info.e2e-spec.ts index 34af159a6c..431971ac85 100644 --- a/e2e/src/api/specs/server-info.e2e-spec.ts +++ b/e2e/src/api/specs/server-info.e2e-spec.ts @@ -15,16 +15,16 @@ describe('/server-info', () => { nonAdmin = await utils.userSetup(admin.accessToken, createUserDto.user1); }); - describe('GET /server-info', () => { + describe('GET /server-info/storage', () => { it('should require authentication', async () => { - const { status, body } = await request(app).get('/server-info'); + const { status, body } = await request(app).get('/server-info/storage'); expect(status).toBe(401); expect(body).toEqual(errorDto.unauthorized); }); it('should return the disk information', async () => { const { status, body } = await request(app) - .get('/server-info') + .get('/server-info/storage') .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(200); expect(body).toEqual({ diff --git a/mobile/lib/entities/album.entity.dart b/mobile/lib/entities/album.entity.dart index 49a38322ee..c05b849dcd 100644 --- a/mobile/lib/entities/album.entity.dart +++ b/mobile/lib/entities/album.entity.dart @@ -145,9 +145,10 @@ class Album { .remoteIdEqualTo(dto.albumThumbnailAssetId) .findFirst(); } - if (dto.sharedUsers.isNotEmpty) { - final users = await db.users - .getAllById(dto.sharedUsers.map((e) => e.id).toList(growable: false)); + if (dto.albumUsers.isNotEmpty) { + final users = await db.users.getAllById( + dto.albumUsers.map((e) => e.user.id).toList(growable: false), + ); a.sharedUsers.addAll(users.cast()); } if (dto.assets.isNotEmpty) { diff --git a/mobile/lib/services/album.service.dart b/mobile/lib/services/album.service.dart index bdf38a42af..c6d70c269a 100644 --- a/mobile/lib/services/album.service.dart +++ b/mobile/lib/services/album.service.dart @@ -180,7 +180,14 @@ class AlbumService { CreateAlbumDto( albumName: albumName, assetIds: assets.map((asset) => asset.remoteId!).toList(), - sharedWithUserIds: sharedUsers.map((e) => e.id).toList(), + albumUsers: sharedUsers + .map( + (e) => AlbumUserCreateDto( + userId: e.id, + role: AlbumUserRole.editor, + ), + ) + .toList(), ), ); if (remote != null) { diff --git a/mobile/lib/services/memory.service.dart b/mobile/lib/services/memory.service.dart index 96b8c6900e..b426214c03 100644 --- a/mobile/lib/services/memory.service.dart +++ b/mobile/lib/services/memory.service.dart @@ -8,6 +8,8 @@ import 'package:isar/isar.dart'; import 'package:logging/logging.dart'; import 'package:openapi/api.dart'; +import '../utils/string_helper.dart'; + final memoryServiceProvider = StateProvider((ref) { return MemoryService( ref.watch(apiServiceProvider), @@ -36,13 +38,13 @@ class MemoryService { } List memories = []; - for (final MemoryLaneResponseDto(:title, :assets) in data) { + for (final MemoryLaneResponseDto(:yearsAgo, :assets) in data) { final dbAssets = await _db.assets.getAllByRemoteId(assets.map((e) => e.id)); if (dbAssets.isNotEmpty) { memories.add( Memory( - title: title, + title: '$yearsAgo year${s(yearsAgo)} ago', assets: dbAssets, ), ); diff --git a/mobile/lib/services/sync.service.dart b/mobile/lib/services/sync.service.dart index acbd34d183..8ec56e925f 100644 --- a/mobile/lib/services/sync.service.dart +++ b/mobile/lib/services/sync.service.dart @@ -362,15 +362,15 @@ class SyncService { // update shared users final List sharedUsers = album.sharedUsers.toList(growable: false); sharedUsers.sort((a, b) => a.id.compareTo(b.id)); - dto.sharedUsers.sort((a, b) => a.id.compareTo(b.id)); + dto.albumUsers.sort((a, b) => a.user.id.compareTo(b.user.id)); final List userIdsToAdd = []; final List usersToUnlink = []; diffSortedListsSync( - dto.sharedUsers, + dto.albumUsers, sharedUsers, - compare: (UserResponseDto a, User b) => a.id.compareTo(b.id), + compare: (AlbumUserResponseDto a, User b) => a.user.id.compareTo(b.id), both: (a, b) => false, - onlyFirst: (UserResponseDto a) => userIdsToAdd.add(a.id), + onlyFirst: (AlbumUserResponseDto a) => userIdsToAdd.add(a.user.id), onlySecond: (User a) => usersToUnlink.add(a), ); @@ -905,7 +905,7 @@ bool _hasAlbumResponseDtoChanged(AlbumResponseDto dto, Album a) { dto.albumName != a.name || dto.albumThumbnailAssetId != a.thumbnail.value?.remoteId || dto.shared != a.shared || - dto.sharedUsers.length != a.sharedUsers.length || + dto.albumUsers.length != a.sharedUsers.length || !dto.updatedAt.isAtSameMomentAs(a.modifiedAt) || !isAtSameMomentAs(dto.startDate, a.startDate) || !isAtSameMomentAs(dto.endDate, a.endDate) || diff --git a/mobile/lib/utils/capitalize_first_letter.dart b/mobile/lib/utils/string_helper.dart similarity index 75% rename from mobile/lib/utils/capitalize_first_letter.dart rename to mobile/lib/utils/string_helper.dart index c1fbb40f8d..201d141531 100644 --- a/mobile/lib/utils/capitalize_first_letter.dart +++ b/mobile/lib/utils/string_helper.dart @@ -3,3 +3,5 @@ extension StringExtension on String { return "${this[0].toUpperCase()}${substring(1).toLowerCase()}"; } } + +String s(num count) => (count == 1 ? '' : 's'); diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 4b5ef2f0dd..402c3ccca0 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -117,7 +117,6 @@ Class | Method | HTTP request | Description *AuthenticationApi* | [**logout**](doc//AuthenticationApi.md#logout) | **POST** /auth/logout | *AuthenticationApi* | [**signUpAdmin**](doc//AuthenticationApi.md#signupadmin) | **POST** /auth/admin-sign-up | *AuthenticationApi* | [**validateAccessToken**](doc//AuthenticationApi.md#validateaccesstoken) | **POST** /auth/validateToken | -*DeprecatedApi* | [**getServerInfo**](doc//DeprecatedApi.md#getserverinfo) | **GET** /server-info | *DownloadApi* | [**downloadArchive**](doc//DownloadApi.md#downloadarchive) | **POST** /download/archive | *DownloadApi* | [**downloadFile**](doc//DownloadApi.md#downloadfile) | **POST** /download/asset/{id} | *DownloadApi* | [**getDownloadInfo**](doc//DownloadApi.md#getdownloadinfo) | **POST** /download/info | @@ -173,7 +172,6 @@ Class | Method | HTTP request | Description *SearchApi* | [**searchSmart**](doc//SearchApi.md#searchsmart) | **POST** /search/smart | *ServerInfoApi* | [**getServerConfig**](doc//ServerInfoApi.md#getserverconfig) | **GET** /server-info/config | *ServerInfoApi* | [**getServerFeatures**](doc//ServerInfoApi.md#getserverfeatures) | **GET** /server-info/features | -*ServerInfoApi* | [**getServerInfo**](doc//ServerInfoApi.md#getserverinfo) | **GET** /server-info | *ServerInfoApi* | [**getServerStatistics**](doc//ServerInfoApi.md#getserverstatistics) | **GET** /server-info/statistics | *ServerInfoApi* | [**getServerVersion**](doc//ServerInfoApi.md#getserverversion) | **GET** /server-info/version | *ServerInfoApi* | [**getStorage**](doc//ServerInfoApi.md#getstorage) | **GET** /server-info/storage | diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index e74fe8e03e..7e71c9db3e 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -35,7 +35,6 @@ part 'api/album_api.dart'; part 'api/asset_api.dart'; part 'api/audit_api.dart'; part 'api/authentication_api.dart'; -part 'api/deprecated_api.dart'; part 'api/download_api.dart'; part 'api/duplicate_api.dart'; part 'api/face_api.dart'; diff --git a/mobile/openapi/lib/api/deprecated_api.dart b/mobile/openapi/lib/api/deprecated_api.dart deleted file mode 100644 index 117735015d..0000000000 --- a/mobile/openapi/lib/api/deprecated_api.dart +++ /dev/null @@ -1,62 +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 DeprecatedApi { - DeprecatedApi([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient; - - final ApiClient apiClient; - - /// This property was deprecated in v1.106.0 - /// - /// Note: This method returns the HTTP [Response]. - Future getServerInfoWithHttpInfo() async { - // ignore: prefer_const_declarations - final path = r'/server-info'; - - // ignore: prefer_final_locals - Object? postBody; - - final queryParams = []; - final headerParams = {}; - final formParams = {}; - - const contentTypes = []; - - - return apiClient.invokeAPI( - path, - 'GET', - queryParams, - postBody, - headerParams, - formParams, - contentTypes.isEmpty ? null : contentTypes.first, - ); - } - - /// This property was deprecated in v1.106.0 - Future getServerInfo() async { - final response = await getServerInfoWithHttpInfo(); - 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), 'ServerStorageResponseDto',) as ServerStorageResponseDto; - - } - return null; - } -} diff --git a/mobile/openapi/lib/api/server_info_api.dart b/mobile/openapi/lib/api/server_info_api.dart index 6e55c62759..76da103b70 100644 --- a/mobile/openapi/lib/api/server_info_api.dart +++ b/mobile/openapi/lib/api/server_info_api.dart @@ -98,50 +98,6 @@ class ServerInfoApi { return null; } - /// This property was deprecated in v1.106.0 - /// - /// Note: This method returns the HTTP [Response]. - Future getServerInfoWithHttpInfo() async { - // ignore: prefer_const_declarations - final path = r'/server-info'; - - // ignore: prefer_final_locals - Object? postBody; - - final queryParams = []; - final headerParams = {}; - final formParams = {}; - - const contentTypes = []; - - - return apiClient.invokeAPI( - path, - 'GET', - queryParams, - postBody, - headerParams, - formParams, - contentTypes.isEmpty ? null : contentTypes.first, - ); - } - - /// This property was deprecated in v1.106.0 - Future getServerInfo() async { - final response = await getServerInfoWithHttpInfo(); - 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), 'ServerStorageResponseDto',) as ServerStorageResponseDto; - - } - return null; - } - /// Performs an HTTP 'GET /server-info/statistics' operation and returns the [Response]. Future getServerStatisticsWithHttpInfo() async { // ignore: prefer_const_declarations diff --git a/mobile/openapi/lib/model/add_users_dto.dart b/mobile/openapi/lib/model/add_users_dto.dart index 7ecb0de5e8..2daa571265 100644 --- a/mobile/openapi/lib/model/add_users_dto.dart +++ b/mobile/openapi/lib/model/add_users_dto.dart @@ -14,32 +14,25 @@ class AddUsersDto { /// Returns a new [AddUsersDto] instance. AddUsersDto({ this.albumUsers = const [], - this.sharedUserIds = const [], }); List albumUsers; - /// This property was deprecated in v1.102.0 - List sharedUserIds; - @override bool operator ==(Object other) => identical(this, other) || other is AddUsersDto && - _deepEquality.equals(other.albumUsers, albumUsers) && - _deepEquality.equals(other.sharedUserIds, sharedUserIds); + _deepEquality.equals(other.albumUsers, albumUsers); @override int get hashCode => // ignore: unnecessary_parenthesis - (albumUsers.hashCode) + - (sharedUserIds.hashCode); + (albumUsers.hashCode); @override - String toString() => 'AddUsersDto[albumUsers=$albumUsers, sharedUserIds=$sharedUserIds]'; + String toString() => 'AddUsersDto[albumUsers=$albumUsers]'; Map toJson() { final json = {}; json[r'albumUsers'] = this.albumUsers; - json[r'sharedUserIds'] = this.sharedUserIds; return json; } @@ -52,9 +45,6 @@ class AddUsersDto { return AddUsersDto( albumUsers: AlbumUserAddDto.listFromJson(json[r'albumUsers']), - sharedUserIds: json[r'sharedUserIds'] is Iterable - ? (json[r'sharedUserIds'] as Iterable).cast().toList(growable: false) - : const [], ); } return null; diff --git a/mobile/openapi/lib/model/album_response_dto.dart b/mobile/openapi/lib/model/album_response_dto.dart index fcdb6c0960..c98a95775d 100644 --- a/mobile/openapi/lib/model/album_response_dto.dart +++ b/mobile/openapi/lib/model/album_response_dto.dart @@ -29,7 +29,6 @@ class AlbumResponseDto { required this.owner, required this.ownerId, required this.shared, - this.sharedUsers = const [], this.startDate, required this.updatedAt, }); @@ -84,9 +83,6 @@ class AlbumResponseDto { bool shared; - /// This property was deprecated in v1.102.0 - List sharedUsers; - /// /// 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 @@ -115,7 +111,6 @@ class AlbumResponseDto { other.owner == owner && other.ownerId == ownerId && other.shared == shared && - _deepEquality.equals(other.sharedUsers, sharedUsers) && other.startDate == startDate && other.updatedAt == updatedAt; @@ -138,12 +133,11 @@ class AlbumResponseDto { (owner.hashCode) + (ownerId.hashCode) + (shared.hashCode) + - (sharedUsers.hashCode) + (startDate == null ? 0 : startDate!.hashCode) + (updatedAt.hashCode); @override - String toString() => 'AlbumResponseDto[albumName=$albumName, albumThumbnailAssetId=$albumThumbnailAssetId, albumUsers=$albumUsers, assetCount=$assetCount, assets=$assets, createdAt=$createdAt, description=$description, endDate=$endDate, hasSharedLink=$hasSharedLink, id=$id, isActivityEnabled=$isActivityEnabled, lastModifiedAssetTimestamp=$lastModifiedAssetTimestamp, order=$order, owner=$owner, ownerId=$ownerId, shared=$shared, sharedUsers=$sharedUsers, startDate=$startDate, updatedAt=$updatedAt]'; + String toString() => 'AlbumResponseDto[albumName=$albumName, albumThumbnailAssetId=$albumThumbnailAssetId, albumUsers=$albumUsers, assetCount=$assetCount, assets=$assets, createdAt=$createdAt, description=$description, endDate=$endDate, hasSharedLink=$hasSharedLink, id=$id, isActivityEnabled=$isActivityEnabled, lastModifiedAssetTimestamp=$lastModifiedAssetTimestamp, order=$order, owner=$owner, ownerId=$ownerId, shared=$shared, startDate=$startDate, updatedAt=$updatedAt]'; Map toJson() { final json = {}; @@ -179,7 +173,6 @@ class AlbumResponseDto { json[r'owner'] = this.owner; json[r'ownerId'] = this.ownerId; json[r'shared'] = this.shared; - json[r'sharedUsers'] = this.sharedUsers; if (this.startDate != null) { json[r'startDate'] = this.startDate!.toUtc().toIso8601String(); } else { @@ -213,7 +206,6 @@ class AlbumResponseDto { owner: UserResponseDto.fromJson(json[r'owner'])!, ownerId: mapValueOfType(json, r'ownerId')!, shared: mapValueOfType(json, r'shared')!, - sharedUsers: UserResponseDto.listFromJson(json[r'sharedUsers']), startDate: mapDateTime(json, r'startDate', r''), updatedAt: mapDateTime(json, r'updatedAt', r'')!, ); @@ -276,7 +268,6 @@ class AlbumResponseDto { 'owner', 'ownerId', 'shared', - 'sharedUsers', 'updatedAt', }; } diff --git a/mobile/openapi/lib/model/asset_response_dto.dart b/mobile/openapi/lib/model/asset_response_dto.dart index b9c31e29cb..028331d0c0 100644 --- a/mobile/openapi/lib/model/asset_response_dto.dart +++ b/mobile/openapi/lib/model/asset_response_dto.dart @@ -24,10 +24,8 @@ class AssetResponseDto { required this.hasMetadata, required this.id, required this.isArchived, - this.isExternal, required this.isFavorite, required this.isOffline, - this.isReadOnly, required this.isTrashed, this.libraryId, this.livePhotoVideoId, @@ -77,28 +75,10 @@ class AssetResponseDto { bool isArchived; - /// This property was deprecated in v1.104.0 - /// - /// 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? isExternal; - bool isFavorite; bool isOffline; - /// This property was deprecated in v1.104.0 - /// - /// 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? isReadOnly; - bool isTrashed; /// This property was deprecated in v1.106.0 @@ -161,10 +141,8 @@ class AssetResponseDto { other.hasMetadata == hasMetadata && other.id == id && other.isArchived == isArchived && - other.isExternal == isExternal && other.isFavorite == isFavorite && other.isOffline == isOffline && - other.isReadOnly == isReadOnly && other.isTrashed == isTrashed && other.libraryId == libraryId && other.livePhotoVideoId == livePhotoVideoId && @@ -198,10 +176,8 @@ class AssetResponseDto { (hasMetadata.hashCode) + (id.hashCode) + (isArchived.hashCode) + - (isExternal == null ? 0 : isExternal!.hashCode) + (isFavorite.hashCode) + (isOffline.hashCode) + - (isReadOnly == null ? 0 : isReadOnly!.hashCode) + (isTrashed.hashCode) + (libraryId == null ? 0 : libraryId!.hashCode) + (livePhotoVideoId == null ? 0 : livePhotoVideoId!.hashCode) + @@ -222,7 +198,7 @@ class AssetResponseDto { (updatedAt.hashCode); @override - String toString() => 'AssetResponseDto[checksum=$checksum, deviceAssetId=$deviceAssetId, deviceId=$deviceId, duplicateId=$duplicateId, duration=$duration, exifInfo=$exifInfo, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, hasMetadata=$hasMetadata, id=$id, isArchived=$isArchived, isExternal=$isExternal, isFavorite=$isFavorite, isOffline=$isOffline, isReadOnly=$isReadOnly, isTrashed=$isTrashed, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, originalPath=$originalPath, owner=$owner, ownerId=$ownerId, people=$people, resized=$resized, smartInfo=$smartInfo, stack=$stack, stackCount=$stackCount, stackParentId=$stackParentId, tags=$tags, thumbhash=$thumbhash, type=$type, updatedAt=$updatedAt]'; + String toString() => 'AssetResponseDto[checksum=$checksum, deviceAssetId=$deviceAssetId, deviceId=$deviceId, duplicateId=$duplicateId, duration=$duration, exifInfo=$exifInfo, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, hasMetadata=$hasMetadata, id=$id, isArchived=$isArchived, isFavorite=$isFavorite, isOffline=$isOffline, isTrashed=$isTrashed, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, originalPath=$originalPath, owner=$owner, ownerId=$ownerId, people=$people, resized=$resized, smartInfo=$smartInfo, stack=$stack, stackCount=$stackCount, stackParentId=$stackParentId, tags=$tags, thumbhash=$thumbhash, type=$type, updatedAt=$updatedAt]'; Map toJson() { final json = {}; @@ -245,18 +221,8 @@ class AssetResponseDto { json[r'hasMetadata'] = this.hasMetadata; json[r'id'] = this.id; json[r'isArchived'] = this.isArchived; - if (this.isExternal != null) { - json[r'isExternal'] = this.isExternal; - } else { - // json[r'isExternal'] = null; - } json[r'isFavorite'] = this.isFavorite; json[r'isOffline'] = this.isOffline; - if (this.isReadOnly != null) { - json[r'isReadOnly'] = this.isReadOnly; - } else { - // json[r'isReadOnly'] = null; - } json[r'isTrashed'] = this.isTrashed; if (this.libraryId != null) { json[r'libraryId'] = this.libraryId; @@ -325,10 +291,8 @@ class AssetResponseDto { hasMetadata: mapValueOfType(json, r'hasMetadata')!, id: mapValueOfType(json, r'id')!, isArchived: mapValueOfType(json, r'isArchived')!, - isExternal: mapValueOfType(json, r'isExternal'), isFavorite: mapValueOfType(json, r'isFavorite')!, isOffline: mapValueOfType(json, r'isOffline')!, - isReadOnly: mapValueOfType(json, r'isReadOnly'), isTrashed: mapValueOfType(json, r'isTrashed')!, libraryId: mapValueOfType(json, r'libraryId'), livePhotoVideoId: mapValueOfType(json, r'livePhotoVideoId'), diff --git a/mobile/openapi/lib/model/create_album_dto.dart b/mobile/openapi/lib/model/create_album_dto.dart index b6d55c967c..fa28b782ac 100644 --- a/mobile/openapi/lib/model/create_album_dto.dart +++ b/mobile/openapi/lib/model/create_album_dto.dart @@ -17,12 +17,10 @@ class CreateAlbumDto { this.albumUsers = const [], this.assetIds = const [], this.description, - this.sharedWithUserIds = const [], }); String albumName; - /// This property was added in v1.104.0 List albumUsers; List assetIds; @@ -35,16 +33,12 @@ class CreateAlbumDto { /// String? description; - /// This property was deprecated in v1.104.0 - List sharedWithUserIds; - @override bool operator ==(Object other) => identical(this, other) || other is CreateAlbumDto && other.albumName == albumName && _deepEquality.equals(other.albumUsers, albumUsers) && _deepEquality.equals(other.assetIds, assetIds) && - other.description == description && - _deepEquality.equals(other.sharedWithUserIds, sharedWithUserIds); + other.description == description; @override int get hashCode => @@ -52,11 +46,10 @@ class CreateAlbumDto { (albumName.hashCode) + (albumUsers.hashCode) + (assetIds.hashCode) + - (description == null ? 0 : description!.hashCode) + - (sharedWithUserIds.hashCode); + (description == null ? 0 : description!.hashCode); @override - String toString() => 'CreateAlbumDto[albumName=$albumName, albumUsers=$albumUsers, assetIds=$assetIds, description=$description, sharedWithUserIds=$sharedWithUserIds]'; + String toString() => 'CreateAlbumDto[albumName=$albumName, albumUsers=$albumUsers, assetIds=$assetIds, description=$description]'; Map toJson() { final json = {}; @@ -68,7 +61,6 @@ class CreateAlbumDto { } else { // json[r'description'] = null; } - json[r'sharedWithUserIds'] = this.sharedWithUserIds; return json; } @@ -86,9 +78,6 @@ class CreateAlbumDto { ? (json[r'assetIds'] as Iterable).cast().toList(growable: false) : const [], description: mapValueOfType(json, r'description'), - sharedWithUserIds: json[r'sharedWithUserIds'] is Iterable - ? (json[r'sharedWithUserIds'] as Iterable).cast().toList(growable: false) - : const [], ); } return null; diff --git a/mobile/openapi/lib/model/memory_lane_response_dto.dart b/mobile/openapi/lib/model/memory_lane_response_dto.dart index bf6e8e2cce..4abe607381 100644 --- a/mobile/openapi/lib/model/memory_lane_response_dto.dart +++ b/mobile/openapi/lib/model/memory_lane_response_dto.dart @@ -14,37 +14,30 @@ class MemoryLaneResponseDto { /// Returns a new [MemoryLaneResponseDto] instance. MemoryLaneResponseDto({ this.assets = const [], - required this.title, required this.yearsAgo, }); List assets; - /// This property was deprecated in v1.100.0 - String title; - int yearsAgo; @override bool operator ==(Object other) => identical(this, other) || other is MemoryLaneResponseDto && _deepEquality.equals(other.assets, assets) && - other.title == title && other.yearsAgo == yearsAgo; @override int get hashCode => // ignore: unnecessary_parenthesis (assets.hashCode) + - (title.hashCode) + (yearsAgo.hashCode); @override - String toString() => 'MemoryLaneResponseDto[assets=$assets, title=$title, yearsAgo=$yearsAgo]'; + String toString() => 'MemoryLaneResponseDto[assets=$assets, yearsAgo=$yearsAgo]'; Map toJson() { final json = {}; json[r'assets'] = this.assets; - json[r'title'] = this.title; json[r'yearsAgo'] = this.yearsAgo; return json; } @@ -58,7 +51,6 @@ class MemoryLaneResponseDto { return MemoryLaneResponseDto( assets: AssetResponseDto.listFromJson(json[r'assets']), - title: mapValueOfType(json, r'title')!, yearsAgo: mapValueOfType(json, r'yearsAgo')!, ); } @@ -108,7 +100,6 @@ class MemoryLaneResponseDto { /// The list of required keys that must be present in a JSON. static const requiredKeys = { 'assets', - 'title', 'yearsAgo', }; } diff --git a/mobile/openapi/lib/model/metadata_search_dto.dart b/mobile/openapi/lib/model/metadata_search_dto.dart index c5aeb11066..322373ee58 100644 --- a/mobile/openapi/lib/model/metadata_search_dto.dart +++ b/mobile/openapi/lib/model/metadata_search_dto.dart @@ -39,7 +39,6 @@ class MetadataSearchDto { this.page, this.personIds = const [], this.previewPath, - this.resizePath, this.size, this.state, this.takenAfter, @@ -50,7 +49,6 @@ class MetadataSearchDto { this.type, this.updatedAfter, this.updatedBefore, - this.webpPath, this.withArchived = false, this.withDeleted, this.withExif, @@ -261,15 +259,6 @@ class MetadataSearchDto { /// String? previewPath; - /// This property was deprecated in v1.100.0 - /// - /// 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? resizePath; - /// Minimum value: 1 /// Maximum value: 1000 /// @@ -352,15 +341,6 @@ class MetadataSearchDto { /// DateTime? updatedBefore; - /// This property was deprecated in v1.100.0 - /// - /// 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? webpPath; - bool withArchived; /// @@ -423,7 +403,6 @@ class MetadataSearchDto { other.page == page && _deepEquality.equals(other.personIds, personIds) && other.previewPath == previewPath && - other.resizePath == resizePath && other.size == size && other.state == state && other.takenAfter == takenAfter && @@ -434,7 +413,6 @@ class MetadataSearchDto { other.type == type && other.updatedAfter == updatedAfter && other.updatedBefore == updatedBefore && - other.webpPath == webpPath && other.withArchived == withArchived && other.withDeleted == withDeleted && other.withExif == withExif && @@ -470,7 +448,6 @@ class MetadataSearchDto { (page == null ? 0 : page!.hashCode) + (personIds.hashCode) + (previewPath == null ? 0 : previewPath!.hashCode) + - (resizePath == null ? 0 : resizePath!.hashCode) + (size == null ? 0 : size!.hashCode) + (state == null ? 0 : state!.hashCode) + (takenAfter == null ? 0 : takenAfter!.hashCode) + @@ -481,7 +458,6 @@ class MetadataSearchDto { (type == null ? 0 : type!.hashCode) + (updatedAfter == null ? 0 : updatedAfter!.hashCode) + (updatedBefore == null ? 0 : updatedBefore!.hashCode) + - (webpPath == null ? 0 : webpPath!.hashCode) + (withArchived.hashCode) + (withDeleted == null ? 0 : withDeleted!.hashCode) + (withExif == null ? 0 : withExif!.hashCode) + @@ -489,7 +465,7 @@ class MetadataSearchDto { (withStacked == null ? 0 : withStacked!.hashCode); @override - String toString() => 'MetadataSearchDto[checksum=$checksum, city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, deviceAssetId=$deviceAssetId, deviceId=$deviceId, encodedVideoPath=$encodedVideoPath, id=$id, isArchived=$isArchived, isEncoded=$isEncoded, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, isVisible=$isVisible, lensModel=$lensModel, libraryId=$libraryId, make=$make, model=$model, order=$order, originalFileName=$originalFileName, originalPath=$originalPath, page=$page, personIds=$personIds, previewPath=$previewPath, resizePath=$resizePath, size=$size, state=$state, takenAfter=$takenAfter, takenBefore=$takenBefore, thumbnailPath=$thumbnailPath, trashedAfter=$trashedAfter, trashedBefore=$trashedBefore, type=$type, updatedAfter=$updatedAfter, updatedBefore=$updatedBefore, webpPath=$webpPath, withArchived=$withArchived, withDeleted=$withDeleted, withExif=$withExif, withPeople=$withPeople, withStacked=$withStacked]'; + String toString() => 'MetadataSearchDto[checksum=$checksum, city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, deviceAssetId=$deviceAssetId, deviceId=$deviceId, encodedVideoPath=$encodedVideoPath, id=$id, isArchived=$isArchived, isEncoded=$isEncoded, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, isVisible=$isVisible, lensModel=$lensModel, libraryId=$libraryId, make=$make, model=$model, order=$order, originalFileName=$originalFileName, originalPath=$originalPath, page=$page, personIds=$personIds, previewPath=$previewPath, size=$size, state=$state, takenAfter=$takenAfter, takenBefore=$takenBefore, thumbnailPath=$thumbnailPath, trashedAfter=$trashedAfter, trashedBefore=$trashedBefore, type=$type, updatedAfter=$updatedAfter, updatedBefore=$updatedBefore, withArchived=$withArchived, withDeleted=$withDeleted, withExif=$withExif, withPeople=$withPeople, withStacked=$withStacked]'; Map toJson() { final json = {}; @@ -619,11 +595,6 @@ class MetadataSearchDto { } else { // json[r'previewPath'] = null; } - if (this.resizePath != null) { - json[r'resizePath'] = this.resizePath; - } else { - // json[r'resizePath'] = null; - } if (this.size != null) { json[r'size'] = this.size; } else { @@ -673,11 +644,6 @@ class MetadataSearchDto { json[r'updatedBefore'] = this.updatedBefore!.toUtc().toIso8601String(); } else { // json[r'updatedBefore'] = null; - } - if (this.webpPath != null) { - json[r'webpPath'] = this.webpPath; - } else { - // json[r'webpPath'] = null; } json[r'withArchived'] = this.withArchived; if (this.withDeleted != null) { @@ -739,7 +705,6 @@ class MetadataSearchDto { ? (json[r'personIds'] as Iterable).cast().toList(growable: false) : const [], previewPath: mapValueOfType(json, r'previewPath'), - resizePath: mapValueOfType(json, r'resizePath'), size: num.parse('${json[r'size']}'), state: mapValueOfType(json, r'state'), takenAfter: mapDateTime(json, r'takenAfter', r''), @@ -750,7 +715,6 @@ class MetadataSearchDto { type: AssetTypeEnum.fromJson(json[r'type']), updatedAfter: mapDateTime(json, r'updatedAfter', r''), updatedBefore: mapDateTime(json, r'updatedBefore', r''), - webpPath: mapValueOfType(json, r'webpPath'), withArchived: mapValueOfType(json, r'withArchived') ?? false, withDeleted: mapValueOfType(json, r'withDeleted'), withExif: mapValueOfType(json, r'withExif'), diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 4bec15e4ac..9809611535 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -4399,44 +4399,6 @@ ] } }, - "/server-info": { - "get": { - "deprecated": true, - "description": "This property was deprecated in v1.106.0", - "operationId": "getServerInfo", - "parameters": [], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ServerStorageResponseDto" - } - } - }, - "description": "" - } - }, - "security": [ - { - "bearer": [] - }, - { - "cookie": [] - }, - { - "api_key": [] - } - ], - "tags": [ - "Server Info", - "Deprecated" - ], - "x-immich-lifecycle": { - "deprecatedAt": "v1.106.0" - } - } - }, "/server-info/config": { "get": { "operationId": "getServerConfig", @@ -6738,15 +6700,6 @@ "$ref": "#/components/schemas/AlbumUserAddDto" }, "type": "array" - }, - "sharedUserIds": { - "deprecated": true, - "description": "This property was deprecated in v1.102.0", - "items": { - "format": "uuid", - "type": "string" - }, - "type": "array" } }, "required": [ @@ -6844,14 +6797,6 @@ "shared": { "type": "boolean" }, - "sharedUsers": { - "deprecated": true, - "description": "This property was deprecated in v1.102.0", - "items": { - "$ref": "#/components/schemas/UserResponseDto" - }, - "type": "array" - }, "startDate": { "format": "date-time", "type": "string" @@ -6875,7 +6820,6 @@ "owner", "ownerId", "shared", - "sharedUsers", "updatedAt" ], "type": "object" @@ -7495,22 +7439,12 @@ "isArchived": { "type": "boolean" }, - "isExternal": { - "deprecated": true, - "description": "This property was deprecated in v1.104.0", - "type": "boolean" - }, "isFavorite": { "type": "boolean" }, "isOffline": { "type": "boolean" }, - "isReadOnly": { - "deprecated": true, - "description": "This property was deprecated in v1.104.0", - "type": "boolean" - }, "isTrashed": { "type": "boolean" }, @@ -7801,7 +7735,6 @@ "type": "string" }, "albumUsers": { - "description": "This property was added in v1.104.0", "items": { "$ref": "#/components/schemas/AlbumUserCreateDto" }, @@ -7816,15 +7749,6 @@ }, "description": { "type": "string" - }, - "sharedWithUserIds": { - "deprecated": true, - "description": "This property was deprecated in v1.104.0", - "items": { - "format": "uuid", - "type": "string" - }, - "type": "array" } }, "required": [ @@ -8672,18 +8596,12 @@ }, "type": "array" }, - "title": { - "deprecated": true, - "description": "This property was deprecated in v1.100.0", - "type": "string" - }, "yearsAgo": { "type": "integer" } }, "required": [ "assets", - "title", "yearsAgo" ], "type": "object" @@ -8874,11 +8792,6 @@ "previewPath": { "type": "string" }, - "resizePath": { - "deprecated": true, - "description": "This property was deprecated in v1.100.0", - "type": "string" - }, "size": { "maximum": 1000, "minimum": 1, @@ -8917,11 +8830,6 @@ "format": "date-time", "type": "string" }, - "webpPath": { - "deprecated": true, - "description": "This property was deprecated in v1.100.0", - "type": "string" - }, "withArchived": { "default": false, "type": "boolean" diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 2f895a76e8..29ae94c428 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -123,12 +123,8 @@ export type AssetResponseDto = { hasMetadata: boolean; id: string; isArchived: boolean; - /** This property was deprecated in v1.104.0 */ - isExternal?: boolean; isFavorite: boolean; isOffline: boolean; - /** This property was deprecated in v1.104.0 */ - isReadOnly?: boolean; isTrashed: boolean; /** This property was deprecated in v1.106.0 */ libraryId?: string | null; @@ -166,8 +162,6 @@ export type AlbumResponseDto = { owner: UserResponseDto; ownerId: string; shared: boolean; - /** This property was deprecated in v1.102.0 */ - sharedUsers: UserResponseDto[]; startDate?: string; updatedAt: string; }; @@ -177,12 +171,9 @@ export type AlbumUserCreateDto = { }; export type CreateAlbumDto = { albumName: string; - /** This property was added in v1.104.0 */ albumUsers?: AlbumUserCreateDto[]; assetIds?: string[]; description?: string; - /** This property was deprecated in v1.104.0 */ - sharedWithUserIds?: string[]; }; export type AlbumCountResponseDto = { notShared: number; @@ -213,8 +204,6 @@ export type AlbumUserAddDto = { }; export type AddUsersDto = { albumUsers: AlbumUserAddDto[]; - /** This property was deprecated in v1.102.0 */ - sharedUserIds?: string[]; }; export type ApiKeyResponseDto = { createdAt: string; @@ -285,8 +274,6 @@ export type MapMarkerResponseDto = { }; export type MemoryLaneResponseDto = { assets: AssetResponseDto[]; - /** This property was deprecated in v1.100.0 */ - title: string; yearsAgo: number; }; export type UpdateStackParentDto = { @@ -660,8 +647,6 @@ export type MetadataSearchDto = { page?: number; personIds?: string[]; previewPath?: string; - /** This property was deprecated in v1.100.0 */ - resizePath?: string; size?: number; state?: string; takenAfter?: string; @@ -672,8 +657,6 @@ export type MetadataSearchDto = { "type"?: AssetTypeEnum; updatedAfter?: string; updatedBefore?: string; - /** This property was deprecated in v1.100.0 */ - webpPath?: string; withArchived?: boolean; withDeleted?: boolean; withExif?: boolean; @@ -745,15 +728,6 @@ export type SmartSearchDto = { withDeleted?: boolean; withExif?: boolean; }; -export type ServerStorageResponseDto = { - diskAvailable: string; - diskAvailableRaw: number; - diskSize: string; - diskSizeRaw: number; - diskUsagePercentage: number; - diskUse: string; - diskUseRaw: number; -}; export type ServerConfigDto = { externalDomain: string; isInitialized: boolean; @@ -801,6 +775,15 @@ export type ServerStatsResponseDto = { usageByUser: UsageByUserDto[]; videos: number; }; +export type ServerStorageResponseDto = { + diskAvailable: string; + diskAvailableRaw: number; + diskSize: string; + diskSizeRaw: number; + diskUsagePercentage: number; + diskUse: string; + diskUseRaw: number; +}; export type ServerThemeDto = { customCss: string; }; @@ -2277,17 +2260,6 @@ export function getSearchSuggestions({ country, make, model, state, $type }: { ...opts })); } -/** - * This property was deprecated in v1.106.0 - */ -export function getServerInfo(opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchJson<{ - status: 200; - data: ServerStorageResponseDto; - }>("/server-info", { - ...opts - })); -} export function getServerConfig(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; diff --git a/server/src/controllers/server-info.controller.ts b/server/src/controllers/server-info.controller.ts index 308778dbb5..03968c1f5e 100644 --- a/server/src/controllers/server-info.controller.ts +++ b/server/src/controllers/server-info.controller.ts @@ -1,6 +1,5 @@ import { Controller, Get } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; -import { EndpointLifecycle } from 'src/decorators'; import { ServerConfigDto, ServerFeaturesDto, @@ -23,13 +22,6 @@ export class ServerInfoController { private versionService: VersionService, ) {} - @Get() - @EndpointLifecycle({ deprecatedAt: 'v1.106.0' }) - @Authenticated() - getServerInfo(): Promise { - return this.service.getStorage(); - } - @Get('storage') @Authenticated() getStorage(): Promise { diff --git a/server/src/dtos/album.dto.ts b/server/src/dtos/album.dto.ts index 19d03a4d8e..21eb649e11 100644 --- a/server/src/dtos/album.dto.ts +++ b/server/src/dtos/album.dto.ts @@ -2,7 +2,6 @@ import { ApiProperty } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { ArrayNotEmpty, IsArray, IsEnum, IsString, ValidateNested } from 'class-validator'; import _ from 'lodash'; -import { PropertyLifecycle } from 'src/decorators'; import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { UserResponseDto, mapUser } from 'src/dtos/user.dto'; @@ -25,10 +24,6 @@ export class AlbumUserAddDto { } export class AddUsersDto { - @ValidateUUID({ each: true, optional: true }) - @PropertyLifecycle({ deprecatedAt: 'v1.102.0' }) - sharedUserIds?: string[]; - @ArrayNotEmpty() albumUsers!: AlbumUserAddDto[]; } @@ -55,13 +50,8 @@ export class CreateAlbumDto { @IsArray() @ValidateNested({ each: true }) @Type(() => AlbumUserCreateDto) - @PropertyLifecycle({ addedAt: 'v1.104.0' }) albumUsers?: AlbumUserCreateDto[]; - @ValidateUUID({ optional: true, each: true }) - @PropertyLifecycle({ deprecatedAt: 'v1.104.0' }) - sharedWithUserIds?: string[]; - @ValidateUUID({ optional: true, each: true }) assetIds?: string[]; } @@ -137,8 +127,6 @@ export class AlbumResponseDto { updatedAt!: Date; albumThumbnailAssetId!: string | null; shared!: boolean; - @PropertyLifecycle({ deprecatedAt: 'v1.102.0' }) - sharedUsers!: UserResponseDto[]; albumUsers!: AlbumUserResponseDto[]; hasSharedLink!: boolean; assets!: AssetResponseDto[]; @@ -192,7 +180,6 @@ export const mapAlbum = (entity: AlbumEntity, withAssets: boolean, auth?: AuthDt id: entity.id, ownerId: entity.ownerId, owner: mapUser(entity.owner), - sharedUsers, albumUsers: albumUsersSorted, shared: hasSharedUser || hasSharedLink, hasSharedLink, diff --git a/server/src/dtos/asset-response.dto.ts b/server/src/dtos/asset-response.dto.ts index 2cf12061d1..3d6cd4cad5 100644 --- a/server/src/dtos/asset-response.dto.ts +++ b/server/src/dtos/asset-response.dto.ts @@ -37,10 +37,6 @@ export class AssetResponseDto extends SanitizedAssetResponseDto { isArchived!: boolean; isTrashed!: boolean; isOffline!: boolean; - @PropertyLifecycle({ deprecatedAt: 'v1.104.0' }) - isExternal?: boolean; - @PropertyLifecycle({ deprecatedAt: 'v1.104.0' }) - isReadOnly?: boolean; exifInfo?: ExifResponseDto; smartInfo?: SmartInfoResponseDto; tags?: TagResponseDto[]; @@ -129,17 +125,12 @@ export function mapAsset(entity: AssetEntity, options: AssetMapOptions = {}): As : undefined, stackCount: entity.stack?.assets?.length ?? null, isOffline: entity.isOffline, - isExternal: false, - isReadOnly: false, hasMetadata: true, duplicateId: entity.duplicateId, }; } export class MemoryLaneResponseDto { - @PropertyLifecycle({ deprecatedAt: 'v1.100.0' }) - title!: string; - @ApiProperty({ type: 'integer' }) yearsAgo!: number; diff --git a/server/src/dtos/search.dto.ts b/server/src/dtos/search.dto.ts index 4d05b9f3aa..5927aa86fc 100644 --- a/server/src/dtos/search.dto.ts +++ b/server/src/dtos/search.dto.ts @@ -1,7 +1,6 @@ import { ApiProperty } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { IsEnum, IsInt, IsNotEmpty, IsString, Max, Min } from 'class-validator'; -import { PropertyLifecycle } from 'src/decorators'; import { AlbumResponseDto } from 'src/dtos/album.dto'; import { AssetResponseDto } from 'src/dtos/asset-response.dto'; import { AssetOrder } from 'src/entities/album.entity'; @@ -155,18 +154,6 @@ export class MetadataSearchDto extends BaseSearchDto { @Optional() originalPath?: string; - @IsString() - @IsNotEmpty() - @Optional() - @PropertyLifecycle({ deprecatedAt: 'v1.100.0' }) - resizePath?: string; - - @IsString() - @IsNotEmpty() - @Optional() - @PropertyLifecycle({ deprecatedAt: 'v1.100.0' }) - webpPath?: string; - @IsString() @IsNotEmpty() @Optional() diff --git a/server/src/services/album.service.spec.ts b/server/src/services/album.service.spec.ts index e2a7fc49c4..3d20a6a559 100644 --- a/server/src/services/album.service.spec.ts +++ b/server/src/services/album.service.spec.ts @@ -185,7 +185,7 @@ describe(AlbumService.name, () => { await sut.create(authStub.admin, { albumName: 'Empty album', - sharedWithUserIds: ['user-id'], + albumUsers: [{ userId: 'user-id', role: AlbumUserRole.EDITOR }], description: '', assetIds: ['123'], }); @@ -208,7 +208,7 @@ describe(AlbumService.name, () => { await expect( sut.create(authStub.admin, { albumName: 'Empty album', - sharedWithUserIds: ['user-3'], + albumUsers: [{ userId: 'user-3', role: AlbumUserRole.EDITOR }], }), ).rejects.toBeInstanceOf(BadRequestException); expect(userMock.get).toHaveBeenCalledWith('user-3', {}); diff --git a/server/src/services/album.service.ts b/server/src/services/album.service.ts index 38464bd75a..643d060494 100644 --- a/server/src/services/album.service.ts +++ b/server/src/services/album.service.ts @@ -14,7 +14,7 @@ import { } from 'src/dtos/album.dto'; import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; -import { AlbumUserEntity, AlbumUserRole } from 'src/entities/album-user.entity'; +import { AlbumUserEntity } from 'src/entities/album-user.entity'; import { AlbumEntity } from 'src/entities/album.entity'; import { AssetEntity } from 'src/entities/asset.entity'; import { IAccessRepository } from 'src/interfaces/access.interface'; @@ -115,9 +115,6 @@ export class AlbumService { async create(auth: AuthDto, dto: CreateAlbumDto): Promise { const albumUsers = dto.albumUsers || []; - for (const userId of dto.sharedWithUserIds || []) { - albumUsers.push({ userId, role: AlbumUserRole.EDITOR }); - } for (const { userId } of albumUsers) { const exists = await this.userRepository.get(userId, {}); @@ -216,15 +213,7 @@ export class AlbumService { return results; } - async addUsers(auth: AuthDto, id: string, { albumUsers, sharedUserIds }: AddUsersDto): Promise { - // Remove once deprecated sharedUserIds is removed - if (!albumUsers) { - if (!sharedUserIds) { - throw new BadRequestException('No users provided'); - } - albumUsers = sharedUserIds.map((userId) => ({ userId, role: AlbumUserRole.EDITOR })); - } - + async addUsers(auth: AuthDto, id: string, { albumUsers }: AddUsersDto): Promise { await this.access.requirePermission(auth, Permission.ALBUM_SHARE, id); const album = await this.findOrFail(id, { withAssets: false }); diff --git a/server/src/services/search.service.ts b/server/src/services/search.service.ts index 10a2ccda2a..8c89218138 100644 --- a/server/src/services/search.service.ts +++ b/server/src/services/search.service.ts @@ -78,9 +78,6 @@ export class SearchService { checksum = Buffer.from(dto.checksum, encoding); } - dto.previewPath ??= dto.resizePath; - dto.thumbnailPath ??= dto.webpPath; - const page = dto.page ?? 1; const size = dto.size || 250; const enumToOrder = { [AssetOrder.ASC]: 'ASC', [AssetOrder.DESC]: 'DESC' } as const; diff --git a/server/test/fixtures/shared-link.stub.ts b/server/test/fixtures/shared-link.stub.ts index ebadc63fac..acea121609 100644 --- a/server/test/fixtures/shared-link.stub.ts +++ b/server/test/fixtures/shared-link.stub.ts @@ -57,7 +57,6 @@ const assetResponse: AssetResponseDto = { resized: false, thumbhash: null, fileModifiedAt: today, - isExternal: false, isOffline: false, fileCreatedAt: today, localDateTime: today, @@ -100,7 +99,6 @@ const albumResponse: AlbumResponseDto = { id: 'album-123', ownerId: 'admin_id', owner: mapUser(userStub.admin), - sharedUsers: [], albumUsers: [], shared: false, hasSharedLink: false, diff --git a/web/src/lib/components/album-page/album-options.svelte b/web/src/lib/components/album-page/album-options.svelte index e0d11b3b22..0b91dbb027 100644 --- a/web/src/lib/components/album-page/album-options.svelte +++ b/web/src/lib/components/album-page/album-options.svelte @@ -88,7 +88,7 @@

{user.name}
Owner
- {#each album.sharedUsers as user (user.id)} + {#each album.albumUsers as { user } (user.id)}
diff --git a/web/src/lib/components/album-page/user-selection-modal.svelte b/web/src/lib/components/album-page/user-selection-modal.svelte index ffe8adf480..78947c2032 100644 --- a/web/src/lib/components/album-page/user-selection-modal.svelte +++ b/web/src/lib/components/album-page/user-selection-modal.svelte @@ -42,8 +42,8 @@ users = data.filter((user) => !(user.deletedAt || user.id === album.ownerId)); // Remove the existed shared users from the album - for (const sharedUser of album.sharedUsers) { - users = users.filter((user) => user.id !== sharedUser.id); + for (const sharedUser of album.albumUsers) { + users = users.filter((user) => user.id !== sharedUser.user.id); } }); diff --git a/web/src/lib/components/asset-viewer/detail-panel.svelte b/web/src/lib/components/asset-viewer/detail-panel.svelte index c921faafff..52a80e3baa 100644 --- a/web/src/lib/components/asset-viewer/detail-panel.svelte +++ b/web/src/lib/components/asset-viewer/detail-panel.svelte @@ -571,7 +571,7 @@
{/if} -{#if currentAlbum && currentAlbum.sharedUsers.length > 0 && asset.owner} +{#if currentAlbum && currentAlbum.albumUsers.length > 0 && asset.owner}

SHARED BY

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 d63776342c..32fb7c01ee 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 @@ -136,7 +136,7 @@ assetGridWidth = isShowActivity ? globalWidth - (globalWidth < 768 ? 360 : 460) : globalWidth; } $: showActivityStatus = - album.sharedUsers.length > 0 && !$showAssetViewer && (album.isActivityEnabled || $numberOfComments > 0); + album.albumUsers.length > 0 && !$showAssetViewer && (album.isActivityEnabled || $numberOfComments > 0); $: isEditor = album.albumUsers.find(({ user: { id } }) => id === $user.id)?.role === AlbumUserRole.Editor || @@ -158,7 +158,7 @@ backUrl = url || AppRoute.ALBUMS; - if (backUrl === AppRoute.SHARING && album.sharedUsers.length === 0 && !album.hasSharedLink) { + if (backUrl === AppRoute.SHARING && album.albumUsers.length === 0 && !album.hasSharedLink) { isCreatingSharedAlbum = true; } }); @@ -229,7 +229,7 @@ isShowActivity = !isShowActivity; }; - $: if (album.sharedUsers.length > 0) { + $: if (album.albumUsers.length > 0) { handlePromiseError(getFavorite()); handlePromiseError(getNumberOfComments()); } @@ -342,7 +342,7 @@ try { await refreshAlbum(); - viewMode = album.sharedUsers.length > 0 ? ViewMode.VIEW_USERS : ViewMode.VIEW; + viewMode = album.albumUsers.length > 0 ? ViewMode.VIEW_USERS : ViewMode.VIEW; } catch (error) { handleError(error, 'Error deleting shared user'); } @@ -482,7 +482,7 @@ {/if} {/if} - {#if isCreatingSharedAlbum && album.sharedUsers.length === 0} + {#if isCreatingSharedAlbum && album.albumUsers.length === 0} +{:else if !asset.exifInfo?.city && isOwner} + +{/if} + +{#if isShowChangeLocation} + + handleConfirmChangeLocation(gps)} + on:cancel={() => (isShowChangeLocation = false)} + /> + +{/if} diff --git a/web/src/lib/components/asset-viewer/detail-panel.svelte b/web/src/lib/components/asset-viewer/detail-panel.svelte index 52a80e3baa..0f901d67a3 100644 --- a/web/src/lib/components/asset-viewer/detail-panel.svelte +++ b/web/src/lib/components/asset-viewer/detail-panel.svelte @@ -1,4 +1,5 @@
@@ -464,65 +451,7 @@
{/if} - {#if asset.exifInfo?.city} - - {:else if !asset.exifInfo?.city && isOwner} - - {/if} - {#if isShowChangeLocation} - handleConfirmChangeLocation(gps)} - on:cancel={() => (isShowChangeLocation = false)} - /> - {/if} +
From 99f0aa868a46ec5db3a86fe1705d22cc50debeb5 Mon Sep 17 00:00:00 2001 From: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Date: Sun, 26 May 2024 14:10:01 +0200 Subject: [PATCH 162/163] fix(web): detail panel asset description (#9765) --- .../asset-viewer/detail-panel.e2e-spec.ts | 14 +++++ .../detail-panel-description.svelte | 62 +++++++++++++++++++ .../asset-viewer/detail-panel.svelte | 61 ++---------------- 3 files changed, 80 insertions(+), 57 deletions(-) create mode 100644 web/src/lib/components/asset-viewer/detail-panel-description.svelte diff --git a/e2e/src/web/specs/asset-viewer/detail-panel.e2e-spec.ts b/e2e/src/web/specs/asset-viewer/detail-panel.e2e-spec.ts index ad847dab15..9cfcc4f37b 100644 --- a/e2e/src/web/specs/asset-viewer/detail-panel.e2e-spec.ts +++ b/e2e/src/web/specs/asset-viewer/detail-panel.e2e-spec.ts @@ -43,4 +43,18 @@ test.describe('Detail Panel', () => { await page.keyboard.press('i'); await expect(page.locator('#detail-panel')).toHaveCount(0); }); + + test('description is visible for owner on shared links', async ({ context, page }) => { + const sharedLink = await utils.createSharedLink(admin.accessToken, { + type: SharedLinkType.Individual, + assetIds: [asset.id], + }); + await utils.setAuthCookies(context, admin.accessToken); + await page.goto(`/share/${sharedLink.key}/photos/${asset.id}`); + + const textarea = page.getByRole('textbox', { name: 'Add a description' }); + await page.getByRole('button', { name: 'Info' }).click(); + await expect(textarea).toBeVisible(); + await expect(textarea).not.toBeDisabled(); + }); }); diff --git a/web/src/lib/components/asset-viewer/detail-panel-description.svelte b/web/src/lib/components/asset-viewer/detail-panel-description.svelte new file mode 100644 index 0000000000..c26eab1a84 --- /dev/null +++ b/web/src/lib/components/asset-viewer/detail-panel-description.svelte @@ -0,0 +1,62 @@ + + +{#if isOwner} +
+