diff --git a/cli/package-lock.json b/cli/package-lock.json index abab734dd8..2d2069eb74 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -4144,9 +4144,9 @@ } }, "node_modules/vite": { - "version": "6.2.5", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.2.5.tgz", - "integrity": "sha512-j023J/hCAa4pRIUH6J9HemwYfjB5llR2Ps0CWeikOtdR8+pAURAk0DoJC5/mm9kd+UgdnIy7d6HE4EAvlYhPhA==", + "version": "6.2.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.2.6.tgz", + "integrity": "sha512-9xpjNl3kR4rVDZgPNdTL0/c6ao4km69a/2ihNQbcANz8RuCOK3hQBmLSJf3bRKVQjVMda+YvizNE8AwvogcPbw==", "dev": true, "license": "MIT", "dependencies": { diff --git a/docs/docs/developer/setup.md b/docs/docs/developer/setup.md index eb84b598e2..1d08453790 100644 --- a/docs/docs/developer/setup.md +++ b/docs/docs/developer/setup.md @@ -63,6 +63,13 @@ If you only want to do web development connected to an existing, remote backend, IMMICH_SERVER_URL=https://demo.immich.app/ npm run dev ``` +If you're using PowerShell on Windows you may need to set the env var separately like so: + +```powershell +$env:IMMICH_SERVER_URL = "https://demo.immich.app/" +npm run dev +``` + #### `@immich/ui` To see local changes to `@immich/ui` in Immich, do the following: diff --git a/e2e/src/api/specs/asset.e2e-spec.ts b/e2e/src/api/specs/asset.e2e-spec.ts index 100c2e8cdb..01129b3299 100644 --- a/e2e/src/api/specs/asset.e2e-spec.ts +++ b/e2e/src/api/specs/asset.e2e-spec.ts @@ -1141,7 +1141,7 @@ describe('/asset', () => { fNumber: 8, focalLength: 97, iso: 100, - lensModel: 'E PZ 18-105mm F4 G OSS', + lensModel: 'Sony E PZ 18-105mm F4 G OSS', fileSizeInByte: 25_001_984, dateTimeOriginal: '2016-09-27T10:51:44+00:00', orientation: '1', @@ -1163,7 +1163,7 @@ describe('/asset', () => { fNumber: 22, focalLength: 25, iso: 100, - lensModel: 'E 25mm F2', + lensModel: 'Zeiss Batis 25mm F2', fileSizeInByte: 49_512_448, dateTimeOriginal: '2016-01-08T14:08:01+00:00', orientation: '1', @@ -1234,7 +1234,7 @@ describe('/asset', () => { focalLength: 18.3, iso: 100, latitude: 36.613_24, - lensModel: 'GR LENS 18.3mm F2.8', + lensModel: '18.3mm F2.8', longitude: -121.897_85, make: 'RICOH IMAGING COMPANY, LTD.', model: 'RICOH GR III', diff --git a/e2e/src/web/specs/shared-link.e2e-spec.ts b/e2e/src/web/specs/shared-link.e2e-spec.ts index 9313526dab..562a0b4e8c 100644 --- a/e2e/src/web/specs/shared-link.e2e-spec.ts +++ b/e2e/src/web/specs/shared-link.e2e-spec.ts @@ -48,7 +48,7 @@ test.describe('Shared Links', () => { await page.waitForSelector('[data-group] svg'); await page.getByRole('checkbox').click(); await page.getByRole('button', { name: 'Download' }).click(); - await page.getByText('DOWNLOADING', { exact: true }).waitFor(); + await page.waitForEvent('download'); }); test('download all from shared link', async ({ page }) => { @@ -56,6 +56,7 @@ test.describe('Shared Links', () => { await page.getByRole('heading', { name: 'Test Album' }).waitFor(); await page.getByRole('button', { name: 'Download' }).click(); await page.getByText('DOWNLOADING', { exact: true }).waitFor(); + await page.waitForEvent('download'); }); test('enter password for a shared link', async ({ page }) => { diff --git a/i18n/en.json b/i18n/en.json index 454b776aac..e9d4652d4e 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1371,6 +1371,7 @@ "view_next_asset": "View next asset", "view_previous_asset": "View previous asset", "view_stack": "View Stack", + "view_qr_code": "View QR code", "visibility_changed": "Visibility changed for {count, plural, one {# person} other {# people}}", "waiting": "Waiting", "warning": "Warning", diff --git a/machine-learning/test_main.py b/machine-learning/test_main.py index a19ec65c5f..eeafd01062 100644 --- a/machine-learning/test_main.py +++ b/machine-learning/test_main.py @@ -278,8 +278,8 @@ class TestOrtSession: assert session.provider_options == [] - def test_sets_default_sess_options(self) -> None: - session = OrtSession("ViT-B-32__openai") + def test_sets_default_sess_options_if_cpu(self) -> None: + session = OrtSession("ViT-B-32__openai", providers=["CPUExecutionProvider"]) assert session.sess_options.execution_mode == ort.ExecutionMode.ORT_SEQUENTIAL assert session.sess_options.inter_op_num_threads == 1 diff --git a/mobile/lib/widgets/asset_viewer/top_control_app_bar.dart b/mobile/lib/widgets/asset_viewer/top_control_app_bar.dart index ef8f2e687b..937d1adf32 100644 --- a/mobile/lib/widgets/asset_viewer/top_control_app_bar.dart +++ b/mobile/lib/widgets/asset_viewer/top_control_app_bar.dart @@ -7,6 +7,7 @@ import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/providers/asset.provider.dart'; import 'package:immich_mobile/providers/tab.provider.dart'; import 'package:immich_mobile/widgets/asset_viewer/motion_photo_button.dart'; +import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart'; class TopControlAppBar extends HookConsumerWidget { const TopControlAppBar({ @@ -166,6 +167,9 @@ class TopControlAppBar extends HookConsumerWidget { ); } + bool isInHomePage = ref.read(tabProvider.notifier).state == TabEnum.home; + bool? isInTrash = ref.read(currentAssetProvider)?.isTrashed; + return AppBar( foregroundColor: Colors.grey[100], backgroundColor: Colors.transparent, @@ -174,7 +178,7 @@ class TopControlAppBar extends HookConsumerWidget { shape: const Border(), actions: [ if (asset.isRemote && isOwner) buildFavoriteButton(a), - if (isOwner && ref.read(tabProvider.notifier).state != TabEnum.home) + if (isOwner && !isInHomePage && !(isInTrash ?? false)) buildLocateButton(), if (asset.livePhotoVideoId != null) const MotionPhotoButton(), if (asset.isLocal && !asset.isRemote) buildUploadButton(), diff --git a/server/Dockerfile b/server/Dockerfile index d7126d12c6..8b611fd42d 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -4,6 +4,7 @@ FROM ghcr.io/immich-app/base-server-dev:202503251114@sha256:10e8973e8603c5729436 RUN apt-get install --no-install-recommends -yqq tini WORKDIR /usr/src/app COPY server/package.json server/package-lock.json ./ +COPY server/patches ./patches RUN npm ci && \ # exiftool-vendored.pl, sharp-linux-x64 and sharp-linux-arm64 are the only ones we need # they're marked as optional dependencies, so we need to copy them manually after pruning @@ -56,7 +57,7 @@ COPY server/resources resources COPY server/package.json server/package-lock.json ./ COPY server/start*.sh ./ COPY "docker/scripts/get-cpus.sh" ./ -RUN npm link && npm install -g @immich/cli && npm cache clean --force +RUN npm install -g @immich/cli && npm cache clean --force COPY LICENSE /licenses/LICENSE.txt COPY LICENSE /LICENSE ENV PATH="${PATH}:/usr/src/app/bin" diff --git a/server/package.json b/server/package.json index 257258234c..f96f3a69a0 100644 --- a/server/package.json +++ b/server/package.json @@ -33,7 +33,7 @@ "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", - "postinstall": "[ \"$npm_config_global\" != \"true\" ] && patch-package || true" + "postinstall": "patch-package" }, "dependencies": { "@nestjs/bullmq": "^11.0.1", diff --git a/server/patches/postgres+3.4.5.patch b/server/patches/postgres+3.4.5.patch index d879416978..019ef9df78 100644 --- a/server/patches/postgres+3.4.5.patch +++ b/server/patches/postgres+3.4.5.patch @@ -1,39 +1,48 @@ diff --git a/node_modules/postgres/cf/src/connection.js b/node_modules/postgres/cf/src/connection.js -index ee8b1e6..d03b9dd 100644 +index ee8b1e6..acf4566 100644 --- a/node_modules/postgres/cf/src/connection.js +++ b/node_modules/postgres/cf/src/connection.js -@@ -387,6 +387,8 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -387,8 +387,10 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose } function queryError(query, err) { -+ if (!query || typeof query !== 'object') throw err ++ if (!query || typeof query !== 'object' || !query.reject) throw err + 'query' in err || 'parameters' in err || Object.defineProperties(err, { - stack: { value: err.stack + query.origin.replace(/.*\n/, '\n'), enumerable: options.debug }, +- stack: { value: err.stack + query.origin.replace(/.*\n/, '\n'), enumerable: options.debug }, ++ stack: { value: err.stack + (query.origin || '').replace(/.*\n/, '\n'), enumerable: options.debug }, query: { value: query.string, enumerable: options.debug }, + parameters: { value: query.parameters, enumerable: options.debug }, + args: { value: query.args, enumerable: options.debug }, diff --git a/node_modules/postgres/cjs/src/connection.js b/node_modules/postgres/cjs/src/connection.js -index f7f58d1..8a37571 100644 +index f7f58d1..b7f2d65 100644 --- a/node_modules/postgres/cjs/src/connection.js +++ b/node_modules/postgres/cjs/src/connection.js -@@ -385,6 +385,8 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -385,8 +385,10 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose } function queryError(query, err) { -+ if (!query || typeof query !== 'object') throw err ++ if (!query || typeof query !== 'object' || !query.reject) throw err + 'query' in err || 'parameters' in err || Object.defineProperties(err, { - stack: { value: err.stack + query.origin.replace(/.*\n/, '\n'), enumerable: options.debug }, +- stack: { value: err.stack + query.origin.replace(/.*\n/, '\n'), enumerable: options.debug }, ++ stack: { value: err.stack + (query.origin || '').replace(/.*\n/, '\n'), enumerable: options.debug }, query: { value: query.string, enumerable: options.debug }, + parameters: { value: query.parameters, enumerable: options.debug }, + args: { value: query.args, enumerable: options.debug }, diff --git a/node_modules/postgres/src/connection.js b/node_modules/postgres/src/connection.js -index 97cc97e..58f5298 100644 +index 97cc97e..26f508e 100644 --- a/node_modules/postgres/src/connection.js +++ b/node_modules/postgres/src/connection.js -@@ -385,6 +385,8 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose +@@ -385,8 +385,10 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose } function queryError(query, err) { -+ if (!query || typeof query !== 'object') throw err ++ if (!query || typeof query !== 'object' || !query.reject) throw err + 'query' in err || 'parameters' in err || Object.defineProperties(err, { - stack: { value: err.stack + query.origin.replace(/.*\n/, '\n'), enumerable: options.debug }, +- stack: { value: err.stack + query.origin.replace(/.*\n/, '\n'), enumerable: options.debug }, ++ stack: { value: err.stack + (query.origin || '').replace(/.*\n/, '\n'), enumerable: options.debug }, query: { value: query.string, enumerable: options.debug }, + parameters: { value: query.parameters, enumerable: options.debug }, + args: { value: query.args, enumerable: options.debug }, diff --git a/server/src/cores/storage.core.ts b/server/src/cores/storage.core.ts index 4cbf963158..9985506f24 100644 --- a/server/src/cores/storage.core.ts +++ b/server/src/cores/storage.core.ts @@ -2,7 +2,6 @@ import { randomUUID } from 'node:crypto'; import { dirname, join, resolve } from 'node:path'; import { APP_MEDIA_LOCATION } from 'src/constants'; import { AssetEntity } from 'src/entities/asset.entity'; -import { PersonEntity } from 'src/entities/person.entity'; import { AssetFileType, AssetPathType, ImageFormat, PathType, PersonPathType, StorageFolder } from 'src/enum'; import { AssetRepository } from 'src/repositories/asset.repository'; import { ConfigRepository } from 'src/repositories/config.repository'; @@ -85,7 +84,7 @@ export class StorageCore { return join(APP_MEDIA_LOCATION, folder); } - static getPersonThumbnailPath(person: PersonEntity) { + static getPersonThumbnailPath(person: { id: string; ownerId: string }) { return StorageCore.getNestedPath(StorageFolder.THUMBNAILS, person.ownerId, `${person.id}.jpeg`); } @@ -135,7 +134,7 @@ export class StorageCore { }); } - async movePersonFile(person: PersonEntity, pathType: PersonPathType) { + async movePersonFile(person: { id: string; ownerId: string; thumbnailPath: string }, pathType: PersonPathType) { const { id: entityId, thumbnailPath } = person; switch (pathType) { case PersonPathType.FACE: { diff --git a/server/src/database.ts b/server/src/database.ts index 45e7cad490..38b3ca3a1d 100644 --- a/server/src/database.ts +++ b/server/src/database.ts @@ -1,4 +1,15 @@ -import { AssetStatus, AssetType, MemoryType, Permission, UserStatus } from 'src/enum'; +import { Selectable } from 'kysely'; +import { Exif as DatabaseExif } from 'src/db'; +import { + AlbumUserRole, + AssetFileType, + AssetStatus, + AssetType, + MemoryType, + Permission, + SourceType, + UserStatus, +} from 'src/enum'; import { OnThisDayData, UserMetadataItem } from 'src/types'; export type AuthUser = { @@ -10,6 +21,17 @@ export type AuthUser = { quotaSizeInBytes: number | null; }; +export type AlbumUser = { + user: User; + role: AlbumUserRole; +}; + +export type AssetFile = { + id: string; + type: AssetFileType; + path: string; +}; + export type Library = { id: string; ownerId: string; @@ -184,6 +206,38 @@ export type Session = { deviceType: string; }; +export type Exif = Omit, 'updatedAt' | 'updateId'>; + +export type Person = { + createdAt: Date; + id: string; + ownerId: string; + updatedAt: Date; + updateId: string; + isFavorite: boolean; + name: string; + birthDate: Date | null; + color: string | null; + faceAssetId: string | null; + isHidden: boolean; + thumbnailPath: string; +}; + +export type AssetFace = { + id: string; + deletedAt: Date | null; + assetId: string; + boundingBoxX1: number; + boundingBoxX2: number; + boundingBoxY1: number; + boundingBoxY2: number; + imageHeight: number; + imageWidth: number; + personId: string | null; + sourceType: SourceType; + person?: Person | null; +}; + const userColumns = ['id', 'name', 'email', 'profileImagePath', 'profileChangedAt'] as const; export const columns = { diff --git a/server/src/db.d.ts b/server/src/db.d.ts index 727b0d51e4..7115b701ce 100644 --- a/server/src/db.d.ts +++ b/server/src/db.d.ts @@ -17,7 +17,7 @@ import { SyncEntityType, } from 'src/enum'; import { UserTable } from 'src/schema/tables/user.table'; -import { OnThisDayData } from 'src/types'; +import { OnThisDayData, UserMetadataItem } from 'src/types'; export type ArrayType = ArrayTypeImpl extends (infer U)[] ? U[] : ArrayTypeImpl; @@ -412,10 +412,8 @@ export interface TypeormMetadata { value: string | null; } -export interface UserMetadata { - key: string; +export interface UserMetadata extends UserMetadataItem { userId: string; - value: Json; } export interface UsersAudit { diff --git a/server/src/dtos/album.dto.ts b/server/src/dtos/album.dto.ts index 14db0ab1e8..c9934ec909 100644 --- a/server/src/dtos/album.dto.ts +++ b/server/src/dtos/album.dto.ts @@ -143,13 +143,11 @@ export class AlbumResponseDto { } export const mapAlbum = (entity: AlbumEntity, withAssets: boolean, auth?: AuthDto): AlbumResponseDto => { - const sharedUsers: UserResponseDto[] = []; const albumUsers: AlbumUserResponseDto[] = []; if (entity.albumUsers) { for (const albumUser of entity.albumUsers) { const user = mapUser(albumUser.user); - sharedUsers.push(user); albumUsers.push({ user, role: albumUser.role, @@ -162,7 +160,7 @@ export const mapAlbum = (entity: AlbumEntity, withAssets: boolean, auth?: AuthDt const assets = entity.assets || []; const hasSharedLink = entity.sharedLinks?.length > 0; - const hasSharedUser = sharedUsers.length > 0; + const hasSharedUser = albumUsers.length > 0; let startDate = assets.at(0)?.localDateTime; let endDate = assets.at(-1)?.localDateTime; diff --git a/server/src/dtos/asset-response.dto.ts b/server/src/dtos/asset-response.dto.ts index b12a4378fe..985ad04729 100644 --- a/server/src/dtos/asset-response.dto.ts +++ b/server/src/dtos/asset-response.dto.ts @@ -1,4 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; +import { AssetFace } from 'src/database'; import { PropertyLifecycle } from 'src/decorators'; import { AuthDto } from 'src/dtos/auth.dto'; import { ExifResponseDto, mapExif } from 'src/dtos/exif.dto'; @@ -10,7 +11,6 @@ import { } from 'src/dtos/person.dto'; import { TagResponseDto, mapTag } from 'src/dtos/tag.dto'; import { UserResponseDto, mapUser } from 'src/dtos/user.dto'; -import { AssetFaceEntity } from 'src/entities/asset-face.entity'; import { AssetEntity } from 'src/entities/asset.entity'; import { AssetType } from 'src/enum'; import { mimeTypes } from 'src/utils/mime-types'; @@ -71,7 +71,8 @@ export type AssetMapOptions = { auth?: AuthDto; }; -const peopleWithFaces = (faces: AssetFaceEntity[]): PersonWithFacesResponseDto[] => { +// TODO: this is inefficient +const peopleWithFaces = (faces: AssetFace[]): PersonWithFacesResponseDto[] => { const result: PersonWithFacesResponseDto[] = []; if (faces) { for (const face of faces) { diff --git a/server/src/dtos/auth.dto.ts b/server/src/dtos/auth.dto.ts index 334b7a49b5..7f2ffa5878 100644 --- a/server/src/dtos/auth.dto.ts +++ b/server/src/dtos/auth.dto.ts @@ -1,8 +1,7 @@ import { ApiProperty } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; import { IsEmail, IsNotEmpty, IsString, MinLength } from 'class-validator'; -import { AuthApiKey, AuthSession, AuthSharedLink, AuthUser } from 'src/database'; -import { UserEntity } from 'src/entities/user.entity'; +import { AuthApiKey, AuthSession, AuthSharedLink, AuthUser, UserAdmin } from 'src/database'; import { ImmichCookie } from 'src/enum'; import { toEmail } from 'src/validation'; @@ -42,7 +41,7 @@ export class LoginResponseDto { shouldChangePassword!: boolean; } -export function mapLoginResponse(entity: UserEntity, accessToken: string): LoginResponseDto { +export function mapLoginResponse(entity: UserAdmin, accessToken: string): LoginResponseDto { return { accessToken, userId: entity.id, diff --git a/server/src/dtos/exif.dto.ts b/server/src/dtos/exif.dto.ts index 079891ae56..9fa61d93c8 100644 --- a/server/src/dtos/exif.dto.ts +++ b/server/src/dtos/exif.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; -import { ExifEntity } from 'src/entities/exif.entity'; +import { Exif } from 'src/database'; export class ExifResponseDto { make?: string | null = null; @@ -28,7 +28,7 @@ export class ExifResponseDto { rating?: number | null = null; } -export function mapExif(entity: ExifEntity): ExifResponseDto { +export function mapExif(entity: Exif): ExifResponseDto { return { make: entity.make, model: entity.model, @@ -55,7 +55,7 @@ export function mapExif(entity: ExifEntity): ExifResponseDto { }; } -export function mapSanitizedExif(entity: ExifEntity): ExifResponseDto { +export function mapSanitizedExif(entity: Exif): ExifResponseDto { return { fileSizeInByte: entity.fileSizeInByte ? Number.parseInt(entity.fileSizeInByte.toString()) : null, orientation: entity.orientation, diff --git a/server/src/dtos/person.dto.ts b/server/src/dtos/person.dto.ts index 49f3416b9a..90490715ef 100644 --- a/server/src/dtos/person.dto.ts +++ b/server/src/dtos/person.dto.ts @@ -1,11 +1,12 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { IsArray, IsInt, IsNotEmpty, IsNumber, IsString, Max, Min, ValidateNested } from 'class-validator'; +import { Selectable } from 'kysely'; import { DateTime } from 'luxon'; +import { AssetFace, Person } from 'src/database'; +import { AssetFaces } from 'src/db'; import { PropertyLifecycle } from 'src/decorators'; import { AuthDto } from 'src/dtos/auth.dto'; -import { AssetFaceEntity } from 'src/entities/asset-face.entity'; -import { PersonEntity } from 'src/entities/person.entity'; import { SourceType } from 'src/enum'; import { asDateString } from 'src/utils/date'; import { @@ -219,7 +220,7 @@ export class PeopleResponseDto { hasNextPage?: boolean; } -export function mapPerson(person: PersonEntity): PersonResponseDto { +export function mapPerson(person: Person): PersonResponseDto { return { id: person.id, name: person.name, @@ -232,7 +233,7 @@ export function mapPerson(person: PersonEntity): PersonResponseDto { }; } -export function mapFacesWithoutPerson(face: AssetFaceEntity): AssetFaceWithoutPersonResponseDto { +export function mapFacesWithoutPerson(face: Selectable): AssetFaceWithoutPersonResponseDto { return { id: face.id, imageHeight: face.imageHeight, @@ -245,9 +246,16 @@ export function mapFacesWithoutPerson(face: AssetFaceEntity): AssetFaceWithoutPe }; } -export function mapFaces(face: AssetFaceEntity, auth: AuthDto): AssetFaceResponseDto { +export function mapFaces(face: AssetFace, auth: AuthDto): AssetFaceResponseDto { return { - ...mapFacesWithoutPerson(face), + id: face.id, + imageHeight: face.imageHeight, + imageWidth: face.imageWidth, + boundingBoxX1: face.boundingBoxX1, + boundingBoxX2: face.boundingBoxX2, + boundingBoxY1: face.boundingBoxY1, + boundingBoxY2: face.boundingBoxY2, + sourceType: face.sourceType, person: face.person?.ownerId === auth.user.id ? mapPerson(face.person) : null, }; } diff --git a/server/src/dtos/user.dto.ts b/server/src/dtos/user.dto.ts index 851d4d3921..72e5c83b35 100644 --- a/server/src/dtos/user.dto.ts +++ b/server/src/dtos/user.dto.ts @@ -2,7 +2,6 @@ import { ApiProperty } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; import { IsBoolean, IsEmail, IsNotEmpty, IsNumber, IsString, Min } from 'class-validator'; import { User, UserAdmin } from 'src/database'; -import { UserEntity } from 'src/entities/user.entity'; import { UserAvatarColor, UserMetadataKey, UserStatus } from 'src/enum'; import { UserMetadataItem } from 'src/types'; import { getPreferences } from 'src/utils/preferences'; @@ -42,13 +41,13 @@ export class UserLicense { activatedAt!: Date; } -export const mapUser = (entity: UserEntity | User): UserResponseDto => { +export const mapUser = (entity: User | UserAdmin): UserResponseDto => { return { id: entity.id, email: entity.email, name: entity.name, profileImagePath: entity.profileImagePath, - avatarColor: getPreferences(entity.email, (entity as UserEntity).metadata || []).avatar.color, + avatarColor: getPreferences(entity.email, (entity as UserAdmin).metadata || []).avatar.color, profileChangedAt: entity.profileChangedAt, }; }; @@ -142,7 +141,7 @@ export class UserAdminResponseDto extends UserResponseDto { license!: UserLicense | null; } -export function mapUserAdmin(entity: UserEntity | UserAdmin): UserAdminResponseDto { +export function mapUserAdmin(entity: UserAdmin): UserAdminResponseDto { const metadata = entity.metadata || []; const license = metadata.find( (item): item is UserMetadataItem => item.key === UserMetadataKey.LICENSE, diff --git a/server/src/entities/album-user.entity.ts b/server/src/entities/album-user.entity.ts deleted file mode 100644 index 7950ffab7d..0000000000 --- a/server/src/entities/album-user.entity.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { AlbumEntity } from 'src/entities/album.entity'; -import { UserEntity } from 'src/entities/user.entity'; -import { AlbumUserRole } from 'src/enum'; - -export class AlbumUserEntity { - albumId!: string; - userId!: string; - album!: AlbumEntity; - user!: UserEntity; - role!: AlbumUserRole; -} diff --git a/server/src/entities/album.entity.ts b/server/src/entities/album.entity.ts index 946c807a1a..eb20c1afdd 100644 --- a/server/src/entities/album.entity.ts +++ b/server/src/entities/album.entity.ts @@ -1,12 +1,11 @@ -import { AlbumUserEntity } from 'src/entities/album-user.entity'; +import { AlbumUser, User } from 'src/database'; import { AssetEntity } from 'src/entities/asset.entity'; import { SharedLinkEntity } from 'src/entities/shared-link.entity'; -import { UserEntity } from 'src/entities/user.entity'; import { AssetOrder } from 'src/enum'; export class AlbumEntity { id!: string; - owner!: UserEntity; + owner!: User; ownerId!: string; albumName!: string; description!: string; @@ -16,7 +15,7 @@ export class AlbumEntity { deletedAt!: Date | null; albumThumbnailAsset!: AssetEntity | null; albumThumbnailAssetId!: string | null; - albumUsers!: AlbumUserEntity[]; + albumUsers!: AlbumUser[]; assets!: AssetEntity[]; sharedLinks!: SharedLinkEntity[]; isActivityEnabled!: boolean; diff --git a/server/src/entities/asset-face.entity.ts b/server/src/entities/asset-face.entity.ts deleted file mode 100644 index dddb6b0f3f..0000000000 --- a/server/src/entities/asset-face.entity.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { AssetEntity } from 'src/entities/asset.entity'; -import { FaceSearchEntity } from 'src/entities/face-search.entity'; -import { PersonEntity } from 'src/entities/person.entity'; -import { SourceType } from 'src/enum'; - -export class AssetFaceEntity { - id!: string; - assetId!: string; - personId!: string | null; - faceSearch?: FaceSearchEntity; - imageWidth!: number; - imageHeight!: number; - boundingBoxX1!: number; - boundingBoxY1!: number; - boundingBoxX2!: number; - boundingBoxY2!: number; - sourceType!: SourceType; - asset!: AssetEntity; - person!: PersonEntity | null; - deletedAt!: Date | null; -} diff --git a/server/src/entities/asset-files.entity.ts b/server/src/entities/asset-files.entity.ts deleted file mode 100644 index 3bd80784b6..0000000000 --- a/server/src/entities/asset-files.entity.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { AssetEntity } from 'src/entities/asset.entity'; -import { AssetFileType } from 'src/enum'; - -export class AssetFileEntity { - id!: string; - assetId!: string; - asset?: AssetEntity; - createdAt!: Date; - updatedAt!: Date; - updateId?: string; - type!: AssetFileType; - path!: string; -} diff --git a/server/src/entities/asset.entity.ts b/server/src/entities/asset.entity.ts index ef27e0db5f..9cf04f8d3b 100644 --- a/server/src/entities/asset.entity.ts +++ b/server/src/entities/asset.entity.ts @@ -1,15 +1,11 @@ import { DeduplicateJoinsPlugin, ExpressionBuilder, Kysely, SelectQueryBuilder, sql } from 'kysely'; import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres'; -import { Tag } from 'src/database'; +import { AssetFace, AssetFile, Exif, Tag, User } from 'src/database'; import { DB } from 'src/db'; import { AlbumEntity } from 'src/entities/album.entity'; -import { AssetFaceEntity } from 'src/entities/asset-face.entity'; -import { AssetFileEntity } from 'src/entities/asset-files.entity'; import { AssetJobStatusEntity } from 'src/entities/asset-job-status.entity'; -import { ExifEntity } from 'src/entities/exif.entity'; import { SharedLinkEntity } from 'src/entities/shared-link.entity'; import { StackEntity } from 'src/entities/stack.entity'; -import { UserEntity } from 'src/entities/user.entity'; import { AssetFileType, AssetStatus, AssetType } from 'src/enum'; import { TimeBucketSize } from 'src/repositories/asset.repository'; import { AssetSearchBuilderOptions } from 'src/repositories/search.repository'; @@ -20,14 +16,14 @@ export const ASSET_CHECKSUM_CONSTRAINT = 'UQ_assets_owner_checksum'; export class AssetEntity { id!: string; deviceAssetId!: string; - owner!: UserEntity; + owner!: User; ownerId!: string; libraryId?: string | null; deviceId!: string; type!: AssetType; status!: AssetStatus; originalPath!: string; - files!: AssetFileEntity[]; + files!: AssetFile[]; thumbhash!: Buffer | null; encodedVideoPath!: string | null; createdAt!: Date; @@ -48,11 +44,11 @@ export class AssetEntity { livePhotoVideoId!: string | null; originalFileName!: string; sidecarPath!: string | null; - exifInfo?: ExifEntity; + exifInfo?: Exif; tags?: Tag[]; sharedLinks!: SharedLinkEntity[]; albums?: AlbumEntity[]; - faces!: AssetFaceEntity[]; + faces!: AssetFace[]; stackId?: string | null; stack?: StackEntity | null; jobStatus?: AssetJobStatusEntity; @@ -66,7 +62,9 @@ export type AssetEntityPlaceholder = AssetEntity & { }; export function withExif(qb: SelectQueryBuilder) { - return qb.leftJoin('exif', 'assets.id', 'exif.assetId').select((eb) => eb.fn.toJson(eb.table('exif')).as('exifInfo')); + return qb + .leftJoin('exif', 'assets.id', 'exif.assetId') + .select((eb) => eb.fn.toJson(eb.table('exif')).$castTo().as('exifInfo')); } export function withExifInner(qb: SelectQueryBuilder) { diff --git a/server/src/entities/exif.entity.ts b/server/src/entities/exif.entity.ts deleted file mode 100644 index 75064b7917..0000000000 --- a/server/src/entities/exif.entity.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { AssetEntity } from 'src/entities/asset.entity'; - -export class ExifEntity { - asset?: AssetEntity; - assetId!: string; - updatedAt?: Date; - updateId?: string; - description!: string; // or caption - exifImageWidth!: number | null; - exifImageHeight!: number | null; - fileSizeInByte!: number | null; - orientation!: string | null; - dateTimeOriginal!: Date | null; - modifyDate!: Date | null; - timeZone!: string | null; - latitude!: number | null; - longitude!: number | null; - projectionType!: string | null; - city!: string | null; - livePhotoCID!: string | null; - autoStackId!: string | null; - state!: string | null; - country!: string | null; - make!: string | null; - model!: string | null; - lensModel!: string | null; - fNumber!: number | null; - focalLength!: number | null; - iso!: number | null; - exposureTime!: string | null; - profileDescription!: string | null; - colorspace!: string | null; - bitsPerSample!: number | null; - rating!: number | null; - fps?: number | null; -} diff --git a/server/src/entities/face-search.entity.ts b/server/src/entities/face-search.entity.ts deleted file mode 100644 index 701fd9e580..0000000000 --- a/server/src/entities/face-search.entity.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { AssetFaceEntity } from 'src/entities/asset-face.entity'; - -export class FaceSearchEntity { - face?: AssetFaceEntity; - faceId!: string; - embedding!: string; -} diff --git a/server/src/entities/person.entity.ts b/server/src/entities/person.entity.ts deleted file mode 100644 index 6ea97b21bc..0000000000 --- a/server/src/entities/person.entity.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { AssetFaceEntity } from 'src/entities/asset-face.entity'; -import { UserEntity } from 'src/entities/user.entity'; - -export class PersonEntity { - id!: string; - createdAt!: Date; - updatedAt!: Date; - updateId?: string; - ownerId!: string; - owner!: UserEntity; - name!: string; - birthDate!: Date | string | null; - thumbnailPath!: string; - faceAssetId!: string | null; - faceAsset!: AssetFaceEntity | null; - faces!: AssetFaceEntity[]; - isHidden!: boolean; - isFavorite!: boolean; - color?: string | null; -} diff --git a/server/src/entities/shared-link.entity.ts b/server/src/entities/shared-link.entity.ts index 5ce0247be7..720ba424d1 100644 --- a/server/src/entities/shared-link.entity.ts +++ b/server/src/entities/shared-link.entity.ts @@ -1,6 +1,5 @@ import { AlbumEntity } from 'src/entities/album.entity'; import { AssetEntity } from 'src/entities/asset.entity'; -import { UserEntity } from 'src/entities/user.entity'; import { SharedLinkType } from 'src/enum'; export class SharedLinkEntity { @@ -8,7 +7,6 @@ export class SharedLinkEntity { description!: string | null; password!: string | null; userId!: string; - user!: UserEntity; key!: Buffer; // use to access the inidividual asset type!: SharedLinkType; createdAt!: Date; diff --git a/server/src/entities/stack.entity.ts b/server/src/entities/stack.entity.ts index 8b8fd94f38..b0dc79f7eb 100644 --- a/server/src/entities/stack.entity.ts +++ b/server/src/entities/stack.entity.ts @@ -1,9 +1,7 @@ import { AssetEntity } from 'src/entities/asset.entity'; -import { UserEntity } from 'src/entities/user.entity'; export class StackEntity { id!: string; - owner!: UserEntity; ownerId!: string; assets!: AssetEntity[]; primaryAsset!: AssetEntity; diff --git a/server/src/entities/user.entity.ts b/server/src/entities/user.entity.ts deleted file mode 100644 index 96c574c83d..0000000000 --- a/server/src/entities/user.entity.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { ExpressionBuilder } from 'kysely'; -import { jsonArrayFrom } from 'kysely/helpers/postgres'; -import { DB } from 'src/db'; -import { AssetEntity } from 'src/entities/asset.entity'; -import { UserStatus } from 'src/enum'; -import { UserMetadataItem } from 'src/types'; - -export class UserEntity { - id!: string; - name!: string; - isAdmin!: boolean; - email!: string; - storageLabel!: string | null; - password?: string; - oauthId!: string; - profileImagePath!: string; - shouldChangePassword!: boolean; - createdAt!: Date; - deletedAt!: Date | null; - status!: UserStatus; - updatedAt!: Date; - updateId?: string; - assets!: AssetEntity[]; - quotaSizeInBytes!: number | null; - quotaUsageInBytes!: number; - metadata!: UserMetadataItem[]; - profileChangedAt!: Date; -} - -export const withMetadata = (eb: ExpressionBuilder) => { - return jsonArrayFrom( - eb.selectFrom('user_metadata').selectAll('user_metadata').whereRef('users.id', '=', 'user_metadata.userId'), - ).as('metadata'); -}; diff --git a/server/src/queries/person.repository.sql b/server/src/queries/person.repository.sql index e6868ae302..f9ba32262d 100644 --- a/server/src/queries/person.repository.sql +++ b/server/src/queries/person.repository.sql @@ -23,7 +23,7 @@ REINDEX TABLE person -- PersonRepository.delete delete from "person" where - "person"."id" in ($1) + "person"."id" in $1 -- PersonRepository.deleteFaces delete from "asset_faces" @@ -95,41 +95,72 @@ where "asset_faces"."id" = $1 and "asset_faces"."deletedAt" is null --- PersonRepository.getFaceByIdWithAssets +-- PersonRepository.getFaceForFacialRecognitionJob select - "asset_faces".*, + "asset_faces"."id", + "asset_faces"."personId", + "asset_faces"."sourceType", ( select to_json(obj) from ( select - "person".* - from - "person" - where - "person"."id" = "asset_faces"."personId" - ) as obj - ) as "person", - ( - select - to_json(obj) - from - ( - select - "assets".* + "assets"."ownerId", + "assets"."isArchived", + "assets"."fileCreatedAt" from "assets" where "assets"."id" = "asset_faces"."assetId" ) as obj - ) as "asset" + ) as "asset", + ( + select + to_json(obj) + from + ( + select + "face_search".* + from + "face_search" + where + "face_search"."faceId" = "asset_faces"."id" + ) as obj + ) as "faceSearch" from "asset_faces" where "asset_faces"."id" = $1 and "asset_faces"."deletedAt" is null +-- PersonRepository.getDataForThumbnailGenerationJob +select + "person"."ownerId", + "asset_faces"."boundingBoxX1" as "x1", + "asset_faces"."boundingBoxY1" as "y1", + "asset_faces"."boundingBoxX2" as "x2", + "asset_faces"."boundingBoxY2" as "y2", + "asset_faces"."imageWidth" as "oldWidth", + "asset_faces"."imageHeight" as "oldHeight", + "exif"."exifImageWidth", + "exif"."exifImageHeight", + "assets"."type", + "assets"."originalPath", + "asset_files"."path" as "previewPath" +from + "person" + inner join "asset_faces" on "asset_faces"."id" = "person"."faceAssetId" + inner join "assets" on "asset_faces"."assetId" = "assets"."id" + inner join "exif" on "exif"."assetId" = "assets"."id" + inner join "asset_files" on "asset_files"."assetId" = "assets"."id" +where + "person"."id" = $1 + and "asset_faces"."deletedAt" is null + and "asset_files"."type" = $2 + and "exif"."exifImageWidth" > $3 + and "exif"."exifImageHeight" > $4 + -- PersonRepository.reassignFace update "asset_faces" set diff --git a/server/src/queries/user.repository.sql b/server/src/queries/user.repository.sql index a726ab46e9..1212d0f2bd 100644 --- a/server/src/queries/user.repository.sql +++ b/server/src/queries/user.repository.sql @@ -24,7 +24,8 @@ select from ( select - "user_metadata".* + "user_metadata"."key", + "user_metadata"."value" from "user_metadata" where @@ -54,7 +55,21 @@ select "shouldChangePassword", "storageLabel", "quotaSizeInBytes", - "quotaUsageInBytes" + "quotaUsageInBytes", + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + "user_metadata"."key", + "user_metadata"."value" + from + "user_metadata" + where + "users"."id" = "user_metadata"."userId" + ) as agg + ) as "metadata" from "users" where @@ -87,7 +102,21 @@ select "shouldChangePassword", "storageLabel", "quotaSizeInBytes", - "quotaUsageInBytes" + "quotaUsageInBytes", + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + "user_metadata"."key", + "user_metadata"."value" + from + "user_metadata" + where + "users"."id" = "user_metadata"."userId" + ) as agg + ) as "metadata" from "users" where @@ -135,7 +164,21 @@ select "shouldChangePassword", "storageLabel", "quotaSizeInBytes", - "quotaUsageInBytes" + "quotaUsageInBytes", + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + "user_metadata"."key", + "user_metadata"."value" + from + "user_metadata" + where + "users"."id" = "user_metadata"."userId" + ) as agg + ) as "metadata" from "users" where @@ -174,7 +217,8 @@ select from ( select - "user_metadata".* + "user_metadata"."key", + "user_metadata"."value" from "user_metadata" where @@ -210,7 +254,8 @@ select from ( select - "user_metadata".* + "user_metadata"."key", + "user_metadata"."value" from "user_metadata" where @@ -232,15 +277,15 @@ select count(*) filter ( where ( - "assets"."type" = $1 - and "assets"."isVisible" = $2 + "assets"."type" = 'IMAGE' + and "assets"."isVisible" = true ) ) as "photos", count(*) filter ( where ( - "assets"."type" = $3 - and "assets"."isVisible" = $4 + "assets"."type" = 'VIDEO' + and "assets"."isVisible" = true ) ) as "videos", coalesce( @@ -255,7 +300,7 @@ select where ( "assets"."libraryId" is null - and "assets"."type" = $5 + and "assets"."type" = 'IMAGE' ) ), 0 @@ -265,7 +310,7 @@ select where ( "assets"."libraryId" is null - and "assets"."type" = $6 + and "assets"."type" = 'VIDEO' ) ), 0 diff --git a/server/src/repositories/activity.repository.ts b/server/src/repositories/activity.repository.ts index e266022b05..18128fb087 100644 --- a/server/src/repositories/activity.repository.ts +++ b/server/src/repositories/activity.repository.ts @@ -69,7 +69,7 @@ export class ActivityRepository { async getStatistics({ albumId, assetId }: { albumId: string; assetId?: string }): Promise { const { count } = await this.db .selectFrom('activity') - .select((eb) => eb.fn.countAll().as('count')) + .select((eb) => eb.fn.countAll().as('count')) .innerJoin('users', (join) => join.onRef('users.id', '=', 'activity.userId').on('users.deletedAt', 'is', null)) .leftJoin('assets', 'assets.id', 'activity.assetId') .$if(!!assetId, (qb) => qb.where('activity.assetId', '=', assetId!)) @@ -81,6 +81,6 @@ export class ActivityRepository { .where('assets.localDateTime', 'is not', null) .executeTakeFirstOrThrow(); - return count as number; + return count; } } diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index 3b71cf84fd..e2765d0446 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -470,10 +470,10 @@ export class AssetRepository { async getLivePhotoCount(motionId: string): Promise { const [{ count }] = await this.db .selectFrom('assets') - .select((eb) => eb.fn.countAll().as('count')) + .select((eb) => eb.fn.countAll().as('count')) .where('livePhotoVideoId', '=', asUuid(motionId)) .execute(); - return count as number; + return count; } @GenerateSql({ params: [DummyValue.UUID] }) @@ -773,10 +773,10 @@ export class AssetRepository { getStatistics(ownerId: string, { isArchived, isFavorite, isTrashed }: AssetStatsOptions): Promise { return this.db .selectFrom('assets') - .select((eb) => eb.fn.countAll().filterWhere('type', '=', AssetType.AUDIO).as(AssetType.AUDIO)) - .select((eb) => eb.fn.countAll().filterWhere('type', '=', AssetType.IMAGE).as(AssetType.IMAGE)) - .select((eb) => eb.fn.countAll().filterWhere('type', '=', AssetType.VIDEO).as(AssetType.VIDEO)) - .select((eb) => eb.fn.countAll().filterWhere('type', '=', AssetType.OTHER).as(AssetType.OTHER)) + .select((eb) => eb.fn.countAll().filterWhere('type', '=', AssetType.AUDIO).as(AssetType.AUDIO)) + .select((eb) => eb.fn.countAll().filterWhere('type', '=', AssetType.IMAGE).as(AssetType.IMAGE)) + .select((eb) => eb.fn.countAll().filterWhere('type', '=', AssetType.VIDEO).as(AssetType.VIDEO)) + .select((eb) => eb.fn.countAll().filterWhere('type', '=', AssetType.OTHER).as(AssetType.OTHER)) .where('ownerId', '=', asUuid(ownerId)) .where('assets.fileCreatedAt', 'is not', null) .where('assets.fileModifiedAt', 'is not', null) @@ -786,7 +786,7 @@ export class AssetRepository { .$if(isFavorite !== undefined, (qb) => qb.where('isFavorite', '=', isFavorite!)) .$if(!!isTrashed, (qb) => qb.where('assets.status', '!=', AssetStatus.DELETED)) .where('deletedAt', isTrashed ? 'is not' : 'is', null) - .executeTakeFirst() as Promise; + .executeTakeFirstOrThrow(); } getRandom(userIds: string[], take: number): Promise { @@ -847,7 +847,7 @@ export class AssetRepository { The line below outputs in YYYY-MM-DD format, but needs a change in the web app to work. .select(sql`"timeBucket"::date::text`.as('timeBucket')) */ - .select((eb) => eb.fn.countAll().as('count')) + .select((eb) => eb.fn.countAll().as('count')) .groupBy('timeBucket') .orderBy('timeBucket', options.order ?? 'desc') .execute() as any as Promise @@ -1145,10 +1145,10 @@ export class AssetRepository { async getLibraryAssetCount(libraryId: string): Promise { const { count } = await this.db .selectFrom('assets') - .select((eb) => eb.fn.countAll().as('count')) + .select((eb) => eb.fn.countAll().as('count')) .where('libraryId', '=', asUuid(libraryId)) .executeTakeFirstOrThrow(); - return Number(count); + return count; } } diff --git a/server/src/repositories/config.repository.ts b/server/src/repositories/config.repository.ts index 8cbb87b0c5..5069b07be1 100644 --- a/server/src/repositories/config.repository.ts +++ b/server/src/repositories/config.repository.ts @@ -250,7 +250,7 @@ const getEnv = (): EnvData => { }, bigint: { to: 20, - from: [20], + from: [20, 1700], parse: (value: string) => Number.parseInt(value), serialize: (value: number) => value.toString(), }, diff --git a/server/src/repositories/library.repository.ts b/server/src/repositories/library.repository.ts index efa6e880d1..fd9dd81b7b 100644 --- a/server/src/repositories/library.repository.ts +++ b/server/src/repositories/library.repository.ts @@ -76,13 +76,13 @@ export class LibraryRepository { .leftJoin('exif', 'exif.assetId', 'assets.id') .select((eb) => eb.fn - .countAll() + .countAll() .filterWhere((eb) => eb.and([eb('assets.type', '=', AssetType.IMAGE), eb('assets.isVisible', '=', true)])) .as('photos'), ) .select((eb) => eb.fn - .countAll() + .countAll() .filterWhere((eb) => eb.and([eb('assets.type', '=', AssetType.VIDEO), eb('assets.isVisible', '=', true)])) .as('videos'), ) @@ -105,10 +105,10 @@ export class LibraryRepository { } return { - photos: Number(stats.photos), - videos: Number(stats.videos), - usage: Number(stats.usage), - total: Number(stats.photos) + Number(stats.videos), + photos: stats.photos, + videos: stats.videos, + usage: stats.usage, + total: stats.photos + stats.videos, }; } diff --git a/server/src/repositories/map.repository.ts b/server/src/repositories/map.repository.ts index 3c4cf12ffd..3e2bcf4c2e 100644 --- a/server/src/repositories/map.repository.ts +++ b/server/src/repositories/map.repository.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { getName } from 'i18n-iso-countries'; -import { Expression, Insertable, Kysely, sql, SqlBool } from 'kysely'; +import { Expression, Insertable, Kysely, NotNull, sql, SqlBool } from 'kysely'; import { InjectKysely } from 'nestjs-kysely'; import { createReadStream, existsSync } from 'node:fs'; import { readFile } from 'node:fs/promises'; @@ -87,6 +87,7 @@ export class MapRepository { .on('exif.longitude', 'is not', null), ) .select(['id', 'exif.latitude as lat', 'exif.longitude as lon', 'exif.city', 'exif.state', 'exif.country']) + .$narrowType<{ lat: NotNull; lon: NotNull }>() .where('isVisible', '=', true) .$if(isArchived !== undefined, (q) => q.where('isArchived', '=', isArchived!)) .$if(isFavorite !== undefined, (q) => q.where('isFavorite', '=', isFavorite!)) @@ -114,7 +115,7 @@ export class MapRepository { return eb.or(expression); }) .orderBy('fileCreatedAt', 'desc') - .execute() as Promise; + .execute(); } async reverseGeocode(point: GeoPoint): Promise { diff --git a/server/src/repositories/media.repository.ts b/server/src/repositories/media.repository.ts index d9cac0b018..1e41dd6bb2 100644 --- a/server/src/repositories/media.repository.ts +++ b/server/src/repositories/media.repository.ts @@ -6,7 +6,7 @@ import fs from 'node:fs/promises'; import { Writable } from 'node:stream'; import sharp from 'sharp'; import { ORIENTATION_TO_SHARP_ROTATION } from 'src/constants'; -import { ExifEntity } from 'src/entities/exif.entity'; +import { Exif } from 'src/database'; import { Colorspace, LogLevel } from 'src/enum'; import { LoggingRepository } from 'src/repositories/logging.repository'; import { @@ -66,7 +66,7 @@ export class MediaRepository { return true; } - async writeExif(tags: Partial, output: string): Promise { + async writeExif(tags: Partial, output: string): Promise { try { const tagsToWrite: WriteTags = { ExifImageWidth: tags.exifImageWidth, diff --git a/server/src/repositories/oauth.repository.ts b/server/src/repositories/oauth.repository.ts index 29e6ffbb52..dc19a1fe01 100644 --- a/server/src/repositories/oauth.repository.ts +++ b/server/src/repositories/oauth.repository.ts @@ -63,6 +63,18 @@ export class OAuthRepository { } } + async getProfilePicture(url: string) { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to fetch picture: ${response.statusText}`); + } + + return { + data: await response.arrayBuffer(), + contentType: response.headers.get('content-type'), + }; + } + private async getClient({ issuerUrl, clientId, diff --git a/server/src/repositories/partner.repository.ts b/server/src/repositories/partner.repository.ts index 489793aa77..ea762d0aaf 100644 --- a/server/src/repositories/partner.repository.ts +++ b/server/src/repositories/partner.repository.ts @@ -1,8 +1,8 @@ import { Injectable } from '@nestjs/common'; -import { ExpressionBuilder, Insertable, Kysely, Updateable } from 'kysely'; +import { ExpressionBuilder, Insertable, Kysely, NotNull, Updateable } from 'kysely'; import { jsonObjectFrom } from 'kysely/helpers/postgres'; import { InjectKysely } from 'nestjs-kysely'; -import { columns, Partner } from 'src/database'; +import { columns } from 'src/database'; import { DB, Partners } from 'src/db'; import { DummyValue, GenerateSql } from 'src/decorators'; @@ -44,7 +44,7 @@ export class PartnerRepository { return this.builder() .where('sharedWithId', '=', sharedWithId) .where('sharedById', '=', sharedById) - .executeTakeFirst() as Promise; + .executeTakeFirst(); } @GenerateSql({ params: [{ sharedWithId: DummyValue.UUID, sharedById: DummyValue.UUID }] }) @@ -55,7 +55,8 @@ export class PartnerRepository { .returningAll() .returning(withSharedBy) .returning(withSharedWith) - .executeTakeFirstOrThrow() as Promise; + .$narrowType<{ sharedWith: NotNull; sharedBy: NotNull }>() + .executeTakeFirstOrThrow(); } @GenerateSql({ params: [{ sharedWithId: DummyValue.UUID, sharedById: DummyValue.UUID }, { inTimeline: true }] }) @@ -68,7 +69,8 @@ export class PartnerRepository { .returningAll() .returning(withSharedBy) .returning(withSharedWith) - .executeTakeFirstOrThrow() as Promise; + .$narrowType<{ sharedWith: NotNull; sharedBy: NotNull }>() + .executeTakeFirstOrThrow(); } @GenerateSql({ params: [{ sharedWithId: DummyValue.UUID, sharedById: DummyValue.UUID }] }) diff --git a/server/src/repositories/person.repository.ts b/server/src/repositories/person.repository.ts index 751f97fdeb..d55d863ea7 100644 --- a/server/src/repositories/person.repository.ts +++ b/server/src/repositories/person.repository.ts @@ -1,14 +1,12 @@ import { Injectable } from '@nestjs/common'; -import { ExpressionBuilder, Insertable, Kysely, Selectable, sql } from 'kysely'; +import { ExpressionBuilder, Insertable, Kysely, NotNull, Selectable, sql, Updateable } from 'kysely'; import { jsonObjectFrom } from 'kysely/helpers/postgres'; import { InjectKysely } from 'nestjs-kysely'; import { AssetFaces, DB, FaceSearch, Person } from 'src/db'; import { ChunkedArray, DummyValue, GenerateSql } from 'src/decorators'; -import { AssetFaceEntity } from 'src/entities/asset-face.entity'; -import { PersonEntity } from 'src/entities/person.entity'; -import { SourceType } from 'src/enum'; +import { AssetFileType, SourceType } from 'src/enum'; import { removeUndefinedKeys } from 'src/utils/database'; -import { Paginated, PaginationOptions } from 'src/utils/pagination'; +import { PaginationOptions } from 'src/utils/pagination'; export interface PersonSearchOptions { minimumFaceCount: number; @@ -49,6 +47,19 @@ export interface DeleteFacesOptions { sourceType: SourceType; } +export interface GetAllPeopleOptions { + ownerId?: string; + thumbnailPath?: string; + faceAssetId?: string | null; + isHidden?: boolean; +} + +export interface GetAllFacesOptions { + personId?: string | null; + assetId?: string; + sourceType?: SourceType; +} + export type UnassignFacesOptions = DeleteFacesOptions; export type SelectFaceOptions = (keyof Selectable)[]; @@ -98,20 +109,13 @@ export class PersonRepository { await this.vacuum({ reindexVectors: false }); } - @GenerateSql({ params: [[{ id: DummyValue.UUID }]] }) - async delete(entities: PersonEntity[]): Promise { - if (entities.length === 0) { + @GenerateSql({ params: [DummyValue.UUID] }) + async delete(ids: string[]): Promise { + if (ids.length === 0) { return; } - await this.db - .deleteFrom('person') - .where( - 'person.id', - 'in', - entities.map(({ id }) => id), - ) - .execute(); + await this.db.deleteFrom('person').where('person.id', 'in', ids).execute(); } @GenerateSql({ params: [{ sourceType: SourceType.EXIF }] }) @@ -121,7 +125,7 @@ export class PersonRepository { await this.vacuum({ reindexVectors: sourceType === SourceType.MACHINE_LEARNING }); } - getAllFaces(options: Partial = {}): AsyncIterableIterator { + getAllFaces(options: GetAllFacesOptions = {}) { return this.db .selectFrom('asset_faces') .selectAll('asset_faces') @@ -130,10 +134,10 @@ export class PersonRepository { .$if(!!options.sourceType, (qb) => qb.where('asset_faces.sourceType', '=', options.sourceType!)) .$if(!!options.assetId, (qb) => qb.where('asset_faces.assetId', '=', options.assetId!)) .where('asset_faces.deletedAt', 'is', null) - .stream() as AsyncIterableIterator; + .stream(); } - getAll(options: Partial = {}): AsyncIterableIterator { + getAll(options: GetAllPeopleOptions = {}) { return this.db .selectFrom('person') .selectAll('person') @@ -142,15 +146,11 @@ export class PersonRepository { .$if(options.faceAssetId === null, (qb) => qb.where('person.faceAssetId', 'is', null)) .$if(!!options.faceAssetId, (qb) => qb.where('person.faceAssetId', '=', options.faceAssetId!)) .$if(options.isHidden !== undefined, (qb) => qb.where('person.isHidden', '=', options.isHidden!)) - .stream() as AsyncIterableIterator; + .stream(); } - async getAllForUser( - pagination: PaginationOptions, - userId: string, - options?: PersonSearchOptions, - ): Paginated { - const items = (await this.db + async getAllForUser(pagination: PaginationOptions, userId: string, options?: PersonSearchOptions) { + const items = await this.db .selectFrom('person') .selectAll('person') .innerJoin('asset_faces', 'asset_faces.personId', 'person.id') @@ -198,7 +198,7 @@ export class PersonRepository { .$if(!options?.withHidden, (qb) => qb.where('person.isHidden', '=', false)) .offset(pagination.skip ?? 0) .limit(pagination.take + 1) - .execute()) as PersonEntity[]; + .execute(); if (items.length > pagination.take) { return { items: items.slice(0, -1), hasNextPage: true }; @@ -208,7 +208,7 @@ export class PersonRepository { } @GenerateSql() - getAllWithoutFaces(): Promise { + getAllWithoutFaces() { return this.db .selectFrom('person') .selectAll('person') @@ -216,11 +216,11 @@ export class PersonRepository { .where('asset_faces.deletedAt', 'is', null) .having((eb) => eb.fn.count('asset_faces.assetId'), '=', 0) .groupBy('person.id') - .execute() as Promise; + .execute(); } @GenerateSql({ params: [DummyValue.UUID] }) - getFaces(assetId: string): Promise { + getFaces(assetId: string) { return this.db .selectFrom('asset_faces') .selectAll('asset_faces') @@ -228,11 +228,11 @@ export class PersonRepository { .where('asset_faces.assetId', '=', assetId) .where('asset_faces.deletedAt', 'is', null) .orderBy('asset_faces.boundingBoxX1', 'asc') - .execute() as Promise; + .execute(); } @GenerateSql({ params: [DummyValue.UUID] }) - getFaceById(id: string): Promise { + getFaceById(id: string) { // TODO return null instead of find or fail return this.db .selectFrom('asset_faces') @@ -240,25 +240,57 @@ export class PersonRepository { .select(withPerson) .where('asset_faces.id', '=', id) .where('asset_faces.deletedAt', 'is', null) - .executeTakeFirstOrThrow() as Promise; + .executeTakeFirstOrThrow(); } @GenerateSql({ params: [DummyValue.UUID] }) - getFaceByIdWithAssets( - id: string, - relations?: { faceSearch?: boolean }, - select?: SelectFaceOptions, - ): Promise { + getFaceForFacialRecognitionJob(id: string) { return this.db .selectFrom('asset_faces') - .$if(!!select, (qb) => qb.select(select!)) - .$if(!select, (qb) => qb.selectAll('asset_faces')) - .select(withPerson) - .select(withAsset) - .$if(!!relations?.faceSearch, (qb) => qb.select(withFaceSearch)) + .select(['asset_faces.id', 'asset_faces.personId', 'asset_faces.sourceType']) + .select((eb) => + jsonObjectFrom( + eb + .selectFrom('assets') + .select(['assets.ownerId', 'assets.isArchived', 'assets.fileCreatedAt']) + .whereRef('assets.id', '=', 'asset_faces.assetId'), + ).as('asset'), + ) + .select(withFaceSearch) .where('asset_faces.id', '=', id) .where('asset_faces.deletedAt', 'is', null) - .executeTakeFirst() as Promise; + .executeTakeFirst(); + } + + @GenerateSql({ params: [DummyValue.UUID] }) + getDataForThumbnailGenerationJob(id: string) { + return this.db + .selectFrom('person') + .innerJoin('asset_faces', 'asset_faces.id', 'person.faceAssetId') + .innerJoin('assets', 'asset_faces.assetId', 'assets.id') + .innerJoin('exif', 'exif.assetId', 'assets.id') + .innerJoin('asset_files', 'asset_files.assetId', 'assets.id') + .select([ + 'person.ownerId', + 'asset_faces.boundingBoxX1 as x1', + 'asset_faces.boundingBoxY1 as y1', + 'asset_faces.boundingBoxX2 as x2', + 'asset_faces.boundingBoxY2 as y2', + 'asset_faces.imageWidth as oldWidth', + 'asset_faces.imageHeight as oldHeight', + 'exif.exifImageWidth', + 'exif.exifImageHeight', + 'assets.type', + 'assets.originalPath', + 'asset_files.path as previewPath', + ]) + .where('person.id', '=', id) + .where('asset_faces.deletedAt', 'is', null) + .where('asset_files.type', '=', AssetFileType.PREVIEW) + .where('exif.exifImageWidth', '>', 0) + .where('exif.exifImageHeight', '>', 0) + .$narrowType<{ exifImageWidth: NotNull; exifImageHeight: NotNull }>() + .executeTakeFirst(); } @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] }) @@ -272,16 +304,16 @@ export class PersonRepository { return Number(result.numChangedRows ?? 0); } - getById(personId: string): Promise { - return (this.db // + getById(personId: string) { + return this.db // .selectFrom('person') .selectAll('person') .where('person.id', '=', personId) - .executeTakeFirst() ?? null) as Promise; + .executeTakeFirst(); } @GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING, { withHidden: true }] }) - getByName(userId: string, personName: string, { withHidden }: PersonNameSearchOptions): Promise { + getByName(userId: string, personName: string, { withHidden }: PersonNameSearchOptions) { return this.db .selectFrom('person') .selectAll('person') @@ -296,7 +328,7 @@ export class PersonRepository { ) .limit(1000) .$if(!withHidden, (qb) => qb.where('person.isHidden', '=', false)) - .execute() as Promise; + .execute(); } @GenerateSql({ params: [DummyValue.UUID, { withHidden: true }] }) @@ -362,8 +394,8 @@ export class PersonRepository { }; } - create(person: Insertable): Promise { - return this.db.insertInto('person').values(person).returningAll().executeTakeFirst() as Promise; + create(person: Insertable) { + return this.db.insertInto('person').values(person).returningAll().executeTakeFirstOrThrow(); } async createAll(people: Insertable[]): Promise { @@ -399,13 +431,13 @@ export class PersonRepository { await query.selectFrom(sql`(select 1)`.as('dummy')).execute(); } - async update(person: Partial & { id: string }): Promise { + async update(person: Updateable & { id: string }) { return this.db .updateTable('person') .set(person) .where('person.id', '=', person.id) .returningAll() - .executeTakeFirstOrThrow() as Promise; + .executeTakeFirstOrThrow(); } async updateAll(people: Insertable[]): Promise { @@ -437,7 +469,7 @@ export class PersonRepository { @GenerateSql({ params: [[{ assetId: DummyValue.UUID, personId: DummyValue.UUID }]] }) @ChunkedArray() - getFacesByIds(ids: AssetFaceId[]): Promise { + getFacesByIds(ids: AssetFaceId[]) { if (ids.length === 0) { return Promise.resolve([]); } @@ -457,17 +489,17 @@ export class PersonRepository { .where('asset_faces.assetId', 'in', assetIds) .where('asset_faces.personId', 'in', personIds) .where('asset_faces.deletedAt', 'is', null) - .execute() as Promise; + .execute(); } @GenerateSql({ params: [DummyValue.UUID] }) - getRandomFace(personId: string): Promise { + getRandomFace(personId: string) { return this.db .selectFrom('asset_faces') .selectAll('asset_faces') .where('asset_faces.personId', '=', personId) .where('asset_faces.deletedAt', 'is', null) - .executeTakeFirst() as Promise; + .executeTakeFirst(); } @GenerateSql() diff --git a/server/src/repositories/search.repository.ts b/server/src/repositories/search.repository.ts index 736eb6dcc1..5a6785af2d 100644 --- a/server/src/repositories/search.repository.ts +++ b/server/src/repositories/search.repository.ts @@ -162,7 +162,7 @@ export interface FaceEmbeddingSearch extends SearchEmbeddingOptions { hasPerson?: boolean; numResults: number; maxDistance: number; - minBirthDate?: Date; + minBirthDate?: Date | null; } export interface AssetDuplicateSearch { diff --git a/server/src/repositories/user.repository.ts b/server/src/repositories/user.repository.ts index 5912f60687..e2e396f7b2 100644 --- a/server/src/repositories/user.repository.ts +++ b/server/src/repositories/user.repository.ts @@ -1,11 +1,11 @@ import { Injectable } from '@nestjs/common'; -import { Insertable, Kysely, sql, Updateable } from 'kysely'; +import { ExpressionBuilder, Insertable, Kysely, sql, Updateable } from 'kysely'; +import { jsonArrayFrom } from 'kysely/helpers/postgres'; import { DateTime } from 'luxon'; import { InjectKysely } from 'nestjs-kysely'; -import { columns, UserAdmin } from 'src/database'; +import { columns } from 'src/database'; import { DB, UserMetadata as DbUserMetadata } from 'src/db'; import { DummyValue, GenerateSql } from 'src/decorators'; -import { UserEntity, withMetadata } from 'src/entities/user.entity'; import { AssetType, UserStatus } from 'src/enum'; import { UserTable } from 'src/schema/tables/user.table'; import { UserMetadata, UserMetadataItem } from 'src/types'; @@ -32,12 +32,21 @@ export interface UserFindOptions { withDeleted?: boolean; } +const withMetadata = (eb: ExpressionBuilder) => { + return jsonArrayFrom( + eb + .selectFrom('user_metadata') + .select(['user_metadata.key', 'user_metadata.value']) + .whereRef('users.id', '=', 'user_metadata.userId'), + ).as('metadata'); +}; + @Injectable() export class UserRepository { constructor(@InjectKysely() private db: Kysely) {} @GenerateSql({ params: [DummyValue.UUID, DummyValue.BOOLEAN] }) - get(userId: string, options: UserFindOptions): Promise { + get(userId: string, options: UserFindOptions) { options = options || {}; return this.db @@ -46,7 +55,7 @@ export class UserRepository { .select(withMetadata) .where('users.id', '=', userId) .$if(!options.withDeleted, (eb) => eb.where('users.deletedAt', 'is', null)) - .executeTakeFirst() as Promise; + .executeTakeFirst(); } getMetadata(userId: string) { @@ -58,13 +67,14 @@ export class UserRepository { } @GenerateSql() - getAdmin(): Promise { + getAdmin() { return this.db .selectFrom('users') .select(columns.userAdmin) + .select(withMetadata) .where('users.isAdmin', '=', true) .where('users.deletedAt', 'is', null) - .executeTakeFirst() as Promise; + .executeTakeFirst(); } @GenerateSql() @@ -80,34 +90,36 @@ export class UserRepository { } @GenerateSql({ params: [DummyValue.EMAIL] }) - getByEmail(email: string, withPassword?: boolean): Promise { + getByEmail(email: string, withPassword?: boolean) { return this.db .selectFrom('users') .select(columns.userAdmin) + .select(withMetadata) .$if(!!withPassword, (eb) => eb.select('password')) .where('email', '=', email) .where('users.deletedAt', 'is', null) - .executeTakeFirst() as Promise; + .executeTakeFirst(); } @GenerateSql({ params: [DummyValue.STRING] }) - getByStorageLabel(storageLabel: string): Promise { + getByStorageLabel(storageLabel: string) { return this.db .selectFrom('users') .select(columns.userAdmin) .where('users.storageLabel', '=', storageLabel) .where('users.deletedAt', 'is', null) - .executeTakeFirst() as Promise; + .executeTakeFirst(); } @GenerateSql({ params: [DummyValue.STRING] }) - getByOAuthId(oauthId: string): Promise { + getByOAuthId(oauthId: string) { return this.db .selectFrom('users') .select(columns.userAdmin) + .select(withMetadata) .where('users.oauthId', '=', oauthId) .where('users.deletedAt', 'is', null) - .executeTakeFirst() as Promise; + .executeTakeFirst(); } @GenerateSql({ params: [DateTime.now().minus({ years: 1 })] }) @@ -126,18 +138,19 @@ export class UserRepository { .select(withMetadata) .$if(!withDeleted, (eb) => eb.where('users.deletedAt', 'is', null)) .orderBy('createdAt', 'desc') - .execute() as Promise; + .execute(); } - async create(dto: Insertable): Promise { + async create(dto: Insertable) { return this.db .insertInto('users') .values(dto) .returning(columns.userAdmin) - .executeTakeFirst() as unknown as Promise; + .returning(withMetadata) + .executeTakeFirstOrThrow(); } - update(id: string, dto: Updateable): Promise { + update(id: string, dto: Updateable) { return this.db .updateTable('users') .set(dto) @@ -145,17 +158,17 @@ export class UserRepository { .where('users.deletedAt', 'is', null) .returning(columns.userAdmin) .returning(withMetadata) - .executeTakeFirst() as unknown as Promise; + .executeTakeFirstOrThrow(); } - restore(id: string): Promise { + restore(id: string) { return this.db .updateTable('users') .set({ status: UserStatus.ACTIVE, deletedAt: null }) .where('users.id', '=', asUuid(id)) .returning(columns.userAdmin) .returning(withMetadata) - .executeTakeFirst() as unknown as Promise; + .executeTakeFirstOrThrow(); } async upsertMetadata(id: string, { key, value }: { key: T; value: UserMetadata[T] }) { @@ -175,41 +188,41 @@ export class UserRepository { await this.db.deleteFrom('user_metadata').where('userId', '=', id).where('key', '=', key).execute(); } - delete(user: { id: string }, hard?: boolean): Promise { + delete(user: { id: string }, hard?: boolean) { return hard - ? (this.db.deleteFrom('users').where('id', '=', user.id).execute() as unknown as Promise) - : (this.db - .updateTable('users') - .set({ deletedAt: new Date() }) - .where('id', '=', user.id) - .execute() as unknown as Promise); + ? this.db.deleteFrom('users').where('id', '=', user.id).execute() + : this.db.updateTable('users').set({ deletedAt: new Date() }).where('id', '=', user.id).execute(); } @GenerateSql() - async getUserStats(): Promise { - const stats = (await this.db + getUserStats() { + return this.db .selectFrom('users') .leftJoin('assets', 'assets.ownerId', 'users.id') .leftJoin('exif', 'exif.assetId', 'assets.id') .select(['users.id as userId', 'users.name as userName', 'users.quotaSizeInBytes as quotaSizeInBytes']) .select((eb) => [ eb.fn - .countAll() - .filterWhere((eb) => eb.and([eb('assets.type', '=', AssetType.IMAGE), eb('assets.isVisible', '=', true)])) + .countAll() + .filterWhere((eb) => + eb.and([eb('assets.type', '=', sql.lit(AssetType.IMAGE)), eb('assets.isVisible', '=', sql.lit(true))]), + ) .as('photos'), eb.fn - .countAll() - .filterWhere((eb) => eb.and([eb('assets.type', '=', AssetType.VIDEO), eb('assets.isVisible', '=', true)])) + .countAll() + .filterWhere((eb) => + eb.and([eb('assets.type', '=', sql.lit(AssetType.VIDEO)), eb('assets.isVisible', '=', sql.lit(true))]), + ) .as('videos'), eb.fn - .coalesce(eb.fn.sum('exif.fileSizeInByte').filterWhere('assets.libraryId', 'is', null), eb.lit(0)) + .coalesce(eb.fn.sum('exif.fileSizeInByte').filterWhere('assets.libraryId', 'is', null), eb.lit(0)) .as('usage'), eb.fn .coalesce( eb.fn - .sum('exif.fileSizeInByte') + .sum('exif.fileSizeInByte') .filterWhere((eb) => - eb.and([eb('assets.libraryId', 'is', null), eb('assets.type', '=', AssetType.IMAGE)]), + eb.and([eb('assets.libraryId', 'is', null), eb('assets.type', '=', sql.lit(AssetType.IMAGE))]), ), eb.lit(0), ) @@ -217,9 +230,9 @@ export class UserRepository { eb.fn .coalesce( eb.fn - .sum('exif.fileSizeInByte') + .sum('exif.fileSizeInByte') .filterWhere((eb) => - eb.and([eb('assets.libraryId', 'is', null), eb('assets.type', '=', AssetType.VIDEO)]), + eb.and([eb('assets.libraryId', 'is', null), eb('assets.type', '=', sql.lit(AssetType.VIDEO))]), ), eb.lit(0), ) @@ -228,17 +241,7 @@ export class UserRepository { .where('assets.deletedAt', 'is', null) .groupBy('users.id') .orderBy('users.createdAt', 'asc') - .execute()) as UserStatsQueryResponse[]; - - for (const stat of stats) { - stat.photos = Number(stat.photos); - stat.videos = Number(stat.videos); - stat.usage = Number(stat.usage); - stat.usagePhotos = Number(stat.usagePhotos); - stat.usageVideos = Number(stat.usageVideos); - } - - return stats; + .execute(); } @GenerateSql({ params: [DummyValue.UUID, DummyValue.NUMBER] }) diff --git a/server/src/services/activity.service.spec.ts b/server/src/services/activity.service.spec.ts index 1e5bb3b505..82bd8bba89 100644 --- a/server/src/services/activity.service.spec.ts +++ b/server/src/services/activity.service.spec.ts @@ -23,7 +23,7 @@ describe(ActivityService.name, () => { mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumId])); mocks.activity.search.mockResolvedValue([]); - await expect(sut.getAll(factory.auth({ id: userId }), { assetId, albumId })).resolves.toEqual([]); + await expect(sut.getAll(factory.auth({ user: { id: userId } }), { assetId, albumId })).resolves.toEqual([]); expect(mocks.activity.search).toHaveBeenCalledWith({ assetId, albumId, isLiked: undefined }); }); @@ -35,7 +35,7 @@ describe(ActivityService.name, () => { mocks.activity.search.mockResolvedValue([]); await expect( - sut.getAll(factory.auth({ id: userId }), { assetId, albumId, type: ReactionType.LIKE }), + sut.getAll(factory.auth({ user: { id: userId } }), { assetId, albumId, type: ReactionType.LIKE }), ).resolves.toEqual([]); expect(mocks.activity.search).toHaveBeenCalledWith({ assetId, albumId, isLiked: true }); @@ -80,7 +80,7 @@ describe(ActivityService.name, () => { mocks.access.activity.checkCreateAccess.mockResolvedValue(new Set([albumId])); mocks.activity.create.mockResolvedValue(activity); - await sut.create(factory.auth({ id: userId }), { + await sut.create(factory.auth({ user: { id: userId } }), { albumId, assetId, type: ReactionType.COMMENT, @@ -116,7 +116,7 @@ describe(ActivityService.name, () => { mocks.activity.create.mockResolvedValue(activity); mocks.activity.search.mockResolvedValue([]); - await sut.create(factory.auth({ id: userId }), { albumId, assetId, type: ReactionType.LIKE }); + await sut.create(factory.auth({ user: { id: userId } }), { albumId, assetId, type: ReactionType.LIKE }); expect(mocks.activity.create).toHaveBeenCalledWith({ userId: activity.userId, albumId, assetId, isLiked: true }); }); diff --git a/server/src/services/album.service.ts b/server/src/services/album.service.ts index cbe81f1c0d..eac000005b 100644 --- a/server/src/services/album.service.ts +++ b/server/src/services/album.service.ts @@ -7,13 +7,13 @@ import { CreateAlbumDto, GetAlbumsDto, UpdateAlbumDto, + UpdateAlbumUserDto, mapAlbum, mapAlbumWithAssets, mapAlbumWithoutAssets, } from 'src/dtos/album.dto'; import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; -import { AlbumUserEntity } from 'src/entities/album-user.entity'; import { AlbumEntity } from 'src/entities/album.entity'; import { Permission } from 'src/enum'; import { AlbumAssetCount, AlbumInfoOptions } from 'src/repositories/album.repository'; @@ -247,7 +247,7 @@ export class AlbumService extends BaseService { await this.albumUserRepository.delete({ albumsId: id, usersId: userId }); } - async updateUser(auth: AuthDto, id: string, userId: string, dto: Partial): Promise { + async updateUser(auth: AuthDto, id: string, userId: string, dto: UpdateAlbumUserDto): Promise { await this.requireAccess({ auth, permission: Permission.ALBUM_SHARE, ids: [id] }); await this.albumUserRepository.update({ albumsId: id, usersId: userId }, { role: dto.role }); } diff --git a/server/src/services/api-key.service.spec.ts b/server/src/services/api-key.service.spec.ts index 0a89c04b0d..680cd38f1e 100644 --- a/server/src/services/api-key.service.spec.ts +++ b/server/src/services/api-key.service.spec.ts @@ -54,7 +54,7 @@ describe(ApiKeyService.name, () => { }); it('should throw an error if the api key does not have sufficient permissions', async () => { - const auth = factory.auth({ apiKey: factory.authApiKey({ permissions: [Permission.ASSET_READ] }) }); + const auth = factory.auth({ apiKey: { permissions: [Permission.ASSET_READ] } }); await expect(sut.create(auth, { permissions: [Permission.ASSET_UPDATE] })).rejects.toBeInstanceOf( BadRequestException, diff --git a/server/src/services/asset-media.service.spec.ts b/server/src/services/asset-media.service.spec.ts index 9499e788f4..bcaeb925b8 100644 --- a/server/src/services/asset-media.service.spec.ts +++ b/server/src/services/asset-media.service.spec.ts @@ -5,9 +5,9 @@ import { UnauthorizedException, } from '@nestjs/common'; import { Stats } from 'node:fs'; +import { AssetFile } from 'src/database'; import { AssetMediaStatus, AssetRejectReason, AssetUploadAction } from 'src/dtos/asset-media-response.dto'; import { AssetMediaCreateDto, AssetMediaReplaceDto, AssetMediaSize, UploadFieldName } from 'src/dtos/asset-media.dto'; -import { AssetFileEntity } from 'src/entities/asset-files.entity'; import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity } from 'src/entities/asset.entity'; import { AssetFileType, AssetStatus, AssetType, CacheControl, JobName } from 'src/enum'; import { AuthRequest } from 'src/middleware/auth.guard'; @@ -166,7 +166,7 @@ const assetEntity = Object.freeze({ isArchived: false, encodedVideoPath: '', duration: '0:00:00.000000', - files: [] as AssetFileEntity[], + files: [] as AssetFile[], exifInfo: { latitude: 49.533_547, longitude: 10.703_075, @@ -535,12 +535,9 @@ describe(AssetMediaService.name, () => { ...assetStub.image, files: [ { - assetId: assetStub.image.id, - createdAt: assetStub.image.fileCreatedAt, id: '42', path: '/path/to/preview', type: AssetFileType.THUMBNAIL, - updatedAt: new Date(), }, ], }); @@ -555,12 +552,9 @@ describe(AssetMediaService.name, () => { ...assetStub.image, files: [ { - assetId: assetStub.image.id, - createdAt: assetStub.image.fileCreatedAt, id: '42', path: '/path/to/preview.jpg', type: AssetFileType.PREVIEW, - updatedAt: new Date(), }, ], }); diff --git a/server/src/services/asset.service.spec.ts b/server/src/services/asset.service.spec.ts index 470f29fb3d..4f4b88c320 100755 --- a/server/src/services/asset.service.spec.ts +++ b/server/src/services/asset.service.spec.ts @@ -88,7 +88,7 @@ describe(AssetService.name, () => { it('should get memories with partners with inTimeline enabled', async () => { const partner = factory.partner(); - const auth = factory.auth({ id: partner.sharedWithId }); + const auth = factory.auth({ user: { id: partner.sharedWithId } }); mocks.partner.getAll.mockResolvedValue([partner]); mocks.asset.getByDayOfYear.mockResolvedValue([]); @@ -139,7 +139,7 @@ describe(AssetService.name, () => { it('should not include partner assets if not in timeline', async () => { const partner = factory.partner({ inTimeline: false }); - const auth = factory.auth({ id: partner.sharedWithId }); + const auth = factory.auth({ user: { id: partner.sharedWithId } }); mocks.asset.getRandom.mockResolvedValue([assetStub.image]); mocks.partner.getAll.mockResolvedValue([partner]); @@ -151,7 +151,7 @@ describe(AssetService.name, () => { it('should include partner assets if in timeline', async () => { const partner = factory.partner({ inTimeline: true }); - const auth = factory.auth({ id: partner.sharedWithId }); + const auth = factory.auth({ user: { id: partner.sharedWithId } }); mocks.asset.getRandom.mockResolvedValue([assetStub.image]); mocks.partner.getAll.mockResolvedValue([partner]); diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index d05bb023f2..51e54adf5b 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -43,7 +43,7 @@ export class AssetService extends BaseService { yearsAgo, // TODO move this to clients title: `${yearsAgo} year${yearsAgo > 1 ? 's' : ''} ago`, - assets: assets.map((asset) => mapAsset(asset as AssetEntity, { auth })), + assets: assets.map((asset) => mapAsset(asset as unknown as AssetEntity, { auth })), }; }); } diff --git a/server/src/services/auth.service.spec.ts b/server/src/services/auth.service.spec.ts index 3c8bfa7d95..b1bfe00e85 100644 --- a/server/src/services/auth.service.spec.ts +++ b/server/src/services/auth.service.spec.ts @@ -1,25 +1,34 @@ import { BadRequestException, ForbiddenException, UnauthorizedException } from '@nestjs/common'; import { DateTime } from 'luxon'; +import { UserAdmin } from 'src/database'; import { AuthDto, SignUpDto } from 'src/dtos/auth.dto'; -import { UserEntity } from 'src/entities/user.entity'; import { AuthType, Permission } from 'src/enum'; import { AuthService } from 'src/services/auth.service'; import { UserMetadataItem } from 'src/types'; import { sharedLinkStub } from 'test/fixtures/shared-link.stub'; import { systemConfigStub } from 'test/fixtures/system-config.stub'; -import { userStub } from 'test/fixtures/user.stub'; -import { factory } from 'test/small.factory'; +import { factory, newUuid } from 'test/small.factory'; import { newTestService, ServiceMocks } from 'test/utils'; -const oauthResponse = { +const oauthResponse = ({ + id, + email, + name, + profileImagePath, +}: { + id: string; + email: string; + name: string; + profileImagePath?: string; +}) => ({ accessToken: 'cmFuZG9tLWJ5dGVz', - userId: 'user-id', - userEmail: 'immich@test.com', - name: 'immich_name', - profileImagePath: '', + userId: id, + userEmail: email, + name, + profileImagePath, isAdmin: false, shouldChangePassword: false, -}; +}); // const token = Buffer.from('my-api-key', 'utf8').toString('base64'); @@ -39,15 +48,7 @@ const fixtures = { }, }; -const oauthUserWithDefaultQuota = { - email, - name: ' ', - oauthId: sub, - quotaSizeInBytes: '1073741824', - storageLabel: null, -}; - -describe('AuthService', () => { +describe(AuthService.name, () => { let sut: AuthService; let mocks: ServiceMocks; @@ -89,7 +90,7 @@ describe('AuthService', () => { }); it('should check the user has a password', async () => { - mocks.user.getByEmail.mockResolvedValue({} as UserEntity); + mocks.user.getByEmail.mockResolvedValue({} as UserAdmin); await expect(sut.login(fixtures.login, loginDetails)).rejects.toBeInstanceOf(UnauthorizedException); @@ -97,7 +98,7 @@ describe('AuthService', () => { }); it('should successfully log the user in', async () => { - const user = { ...factory.user(), password: 'immich_password' } as UserEntity; + const user = { ...(factory.user() as UserAdmin), password: 'immich_password' }; const session = factory.session(); mocks.user.getByEmail.mockResolvedValue(user); mocks.session.create.mockResolvedValue(session); @@ -118,14 +119,12 @@ describe('AuthService', () => { describe('changePassword', () => { it('should change the password', async () => { - const auth = { user: { email: 'test@imimch.com' } } as AuthDto; + const user = factory.userAdmin(); + const auth = factory.auth({ user }); const dto = { password: 'old-password', newPassword: 'new-password' }; - mocks.user.getByEmail.mockResolvedValue({ - email: 'test@immich.com', - password: 'hash-password', - } as UserEntity); - mocks.user.update.mockResolvedValue(userStub.user1); + mocks.user.getByEmail.mockResolvedValue({ ...user, password: 'hash-password' }); + mocks.user.update.mockResolvedValue(user); await sut.changePassword(auth, dto); @@ -143,7 +142,7 @@ describe('AuthService', () => { }); it('should throw when password does not match existing password', async () => { - const auth = { user: { email: 'test@imimch.com' } as UserEntity }; + const auth = { user: { email: 'test@imimch.com' } as UserAdmin }; const dto = { password: 'old-password', newPassword: 'new-password' }; mocks.crypto.compareBcrypt.mockReturnValue(false); @@ -151,7 +150,7 @@ describe('AuthService', () => { mocks.user.getByEmail.mockResolvedValue({ email: 'test@immich.com', password: 'hash-password', - } as UserEntity); + } as UserAdmin & { password: string }); await expect(sut.changePassword(auth, dto)).rejects.toBeInstanceOf(BadRequestException); }); @@ -163,7 +162,7 @@ describe('AuthService', () => { mocks.user.getByEmail.mockResolvedValue({ email: 'test@immich.com', password: '', - } as UserEntity); + } as UserAdmin & { password: string }); await expect(sut.changePassword(auth, dto)).rejects.toBeInstanceOf(BadRequestException); }); @@ -217,7 +216,7 @@ describe('AuthService', () => { const dto: SignUpDto = { email: 'test@immich.com', password: 'password', name: 'immich admin' }; it('should only allow one admin', async () => { - mocks.user.getAdmin.mockResolvedValue({} as UserEntity); + mocks.user.getAdmin.mockResolvedValue({} as UserAdmin); await expect(sut.adminSignUp(dto)).rejects.toBeInstanceOf(BadRequestException); @@ -231,7 +230,7 @@ describe('AuthService', () => { id: 'admin', createdAt: new Date('2021-01-01'), metadata: [] as UserMetadataItem[], - } as UserEntity); + } as unknown as UserAdmin); await expect(sut.adminSignUp(dto)).resolves.toMatchObject({ avatarColor: expect.any(String), @@ -294,7 +293,7 @@ describe('AuthService', () => { }); it('should not accept an expired key', async () => { - mocks.sharedLink.getByKey.mockResolvedValue(sharedLinkStub.expired); + mocks.sharedLink.getByKey.mockResolvedValue(sharedLinkStub.expired as any); await expect( sut.authenticate({ @@ -306,7 +305,7 @@ describe('AuthService', () => { }); it('should not accept a key on a non-shared route', async () => { - mocks.sharedLink.getByKey.mockResolvedValue(sharedLinkStub.valid); + mocks.sharedLink.getByKey.mockResolvedValue(sharedLinkStub.valid as any); await expect( sut.authenticate({ @@ -318,7 +317,7 @@ describe('AuthService', () => { }); it('should not accept a key without a user', async () => { - mocks.sharedLink.getByKey.mockResolvedValue(sharedLinkStub.expired); + mocks.sharedLink.getByKey.mockResolvedValue(sharedLinkStub.expired as any); mocks.user.get.mockResolvedValue(void 0); await expect( @@ -331,37 +330,39 @@ describe('AuthService', () => { }); it('should accept a base64url key', async () => { - mocks.sharedLink.getByKey.mockResolvedValue(sharedLinkStub.valid); - mocks.user.get.mockResolvedValue(userStub.admin); + const user = factory.userAdmin(); + const sharedLink = { ...sharedLinkStub.valid, user } as any; + + mocks.sharedLink.getByKey.mockResolvedValue(sharedLink); + mocks.user.get.mockResolvedValue(user); await expect( sut.authenticate({ - headers: { 'x-immich-share-key': sharedLinkStub.valid.key.toString('base64url') }, + headers: { 'x-immich-share-key': sharedLink.key.toString('base64url') }, queryParams: {}, metadata: { adminRoute: false, sharedLinkRoute: true, uri: 'test' }, }), - ).resolves.toEqual({ - user: userStub.admin, - sharedLink: sharedLinkStub.valid, - }); - expect(mocks.sharedLink.getByKey).toHaveBeenCalledWith(sharedLinkStub.valid.key); + ).resolves.toEqual({ user, sharedLink }); + + expect(mocks.sharedLink.getByKey).toHaveBeenCalledWith(sharedLink.key); }); it('should accept a hex key', async () => { - mocks.sharedLink.getByKey.mockResolvedValue(sharedLinkStub.valid); - mocks.user.get.mockResolvedValue(userStub.admin); + const user = factory.userAdmin(); + const sharedLink = { ...sharedLinkStub.valid, user } as any; + + mocks.sharedLink.getByKey.mockResolvedValue(sharedLink); + mocks.user.get.mockResolvedValue(user); await expect( sut.authenticate({ - headers: { 'x-immich-share-key': sharedLinkStub.valid.key.toString('hex') }, + headers: { 'x-immich-share-key': sharedLink.key.toString('hex') }, queryParams: {}, metadata: { adminRoute: false, sharedLinkRoute: true, uri: 'test' }, }), - ).resolves.toEqual({ - user: userStub.admin, - sharedLink: sharedLinkStub.valid, - }); - expect(mocks.sharedLink.getByKey).toHaveBeenCalledWith(sharedLinkStub.valid.key); + ).resolves.toEqual({ user, sharedLink }); + + expect(mocks.sharedLink.getByKey).toHaveBeenCalledWith(sharedLink.key); }); }); @@ -533,24 +534,28 @@ describe('AuthService', () => { }); it('should link an existing user', async () => { + const user = factory.userAdmin(); + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled); - mocks.user.getByEmail.mockResolvedValue(userStub.user1); - mocks.user.update.mockResolvedValue(userStub.user1); + mocks.user.getByEmail.mockResolvedValue(user); + mocks.user.update.mockResolvedValue(user); mocks.session.create.mockResolvedValue(factory.session()); await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual( - oauthResponse, + oauthResponse(user), ); expect(mocks.user.getByEmail).toHaveBeenCalledTimes(1); - expect(mocks.user.update).toHaveBeenCalledWith(userStub.user1.id, { oauthId: sub }); + expect(mocks.user.update).toHaveBeenCalledWith(user.id, { oauthId: sub }); }); it('should not link to a user with a different oauth sub', async () => { + const user = factory.userAdmin({ isAdmin: true, oauthId: 'existing-sub' }); + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister); - mocks.user.getByEmail.mockResolvedValueOnce({ ...userStub.user1, oauthId: 'existing-sub' }); - mocks.user.getAdmin.mockResolvedValue(userStub.user1); - mocks.user.create.mockResolvedValue(userStub.user1); + mocks.user.getByEmail.mockResolvedValueOnce(user); + mocks.user.getAdmin.mockResolvedValue(user); + mocks.user.create.mockResolvedValue(user); await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).rejects.toThrow( BadRequestException, @@ -561,14 +566,16 @@ describe('AuthService', () => { }); it('should allow auto registering by default', async () => { + const user = factory.userAdmin({ oauthId: 'oauth-id' }); + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled); mocks.user.getByEmail.mockResolvedValue(void 0); - mocks.user.getAdmin.mockResolvedValue(userStub.user1); - mocks.user.create.mockResolvedValue(userStub.user1); + mocks.user.getAdmin.mockResolvedValue(factory.userAdmin({ isAdmin: true })); + mocks.user.create.mockResolvedValue(user); mocks.session.create.mockResolvedValue(factory.session()); await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual( - oauthResponse, + oauthResponse(user), ); expect(mocks.user.getByEmail).toHaveBeenCalledTimes(2); // second call is for domain check before create @@ -576,10 +583,12 @@ describe('AuthService', () => { }); it('should throw an error if user should be auto registered but the email claim does not exist', async () => { + const user = factory.userAdmin({ isAdmin: true }); + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled); mocks.user.getByEmail.mockResolvedValue(void 0); - mocks.user.getAdmin.mockResolvedValue(userStub.user1); - mocks.user.create.mockResolvedValue(userStub.user1); + mocks.user.getAdmin.mockResolvedValue(user); + mocks.user.create.mockResolvedValue(user); mocks.session.create.mockResolvedValue(factory.session()); mocks.oauth.getProfile.mockResolvedValue({ sub, email: undefined }); @@ -600,8 +609,10 @@ describe('AuthService', () => { 'app.immich:///oauth-callback?code=abc123', ]) { it(`should use the mobile redirect override for a url of ${url}`, async () => { + const user = factory.userAdmin(); + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithMobileOverride); - mocks.user.getByOAuthId.mockResolvedValue(userStub.user1); + mocks.user.getByOAuthId.mockResolvedValue(user); mocks.session.create.mockResolvedValue(factory.session()); await sut.callback({ url }, loginDetails); @@ -611,100 +622,162 @@ describe('AuthService', () => { } it('should use the default quota', async () => { + const user = factory.userAdmin({ oauthId: 'oauth-id' }); + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota); mocks.user.getByEmail.mockResolvedValue(void 0); - mocks.user.getAdmin.mockResolvedValue(userStub.user1); - mocks.user.create.mockResolvedValue(userStub.user1); + mocks.user.getAdmin.mockResolvedValue(factory.userAdmin({ isAdmin: true })); + mocks.user.create.mockResolvedValue(user); mocks.session.create.mockResolvedValue(factory.session()); await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual( - oauthResponse, + oauthResponse(user), ); - expect(mocks.user.create).toHaveBeenCalledWith({ ...oauthUserWithDefaultQuota, quotaSizeInBytes: 1_073_741_824 }); + expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ quotaSizeInBytes: 1_073_741_824 })); }); it('should ignore an invalid storage quota', async () => { + const user = factory.userAdmin({ oauthId: 'oauth-id' }); + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota); + mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_quota: 'abc' }); + mocks.user.getAdmin.mockResolvedValue(factory.userAdmin({ isAdmin: true })); mocks.user.getByEmail.mockResolvedValue(void 0); - mocks.user.getAdmin.mockResolvedValue(userStub.user1); - mocks.user.create.mockResolvedValue(userStub.user1); - mocks.oauth.getProfile.mockResolvedValue({ sub, email, immich_quota: 'abc' }); + mocks.user.create.mockResolvedValue(user); mocks.session.create.mockResolvedValue(factory.session()); await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual( - oauthResponse, + oauthResponse(user), ); - expect(mocks.user.create).toHaveBeenCalledWith({ ...oauthUserWithDefaultQuota, quotaSizeInBytes: 1_073_741_824 }); + expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ quotaSizeInBytes: 1_073_741_824 })); }); it('should ignore a negative quota', async () => { + const user = factory.userAdmin({ oauthId: 'oauth-id' }); + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota); + mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_quota: -5 }); + mocks.user.getAdmin.mockResolvedValue(user); mocks.user.getByEmail.mockResolvedValue(void 0); - mocks.user.getAdmin.mockResolvedValue(userStub.user1); - mocks.user.create.mockResolvedValue(userStub.user1); - mocks.oauth.getProfile.mockResolvedValue({ sub, email, immich_quota: -5 }); + mocks.user.create.mockResolvedValue(user); mocks.session.create.mockResolvedValue(factory.session()); await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual( - oauthResponse, + oauthResponse(user), ); - expect(mocks.user.create).toHaveBeenCalledWith({ ...oauthUserWithDefaultQuota, quotaSizeInBytes: 1_073_741_824 }); + expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ quotaSizeInBytes: 1_073_741_824 })); }); it('should not set quota for 0 quota', async () => { + const user = factory.userAdmin({ oauthId: 'oauth-id' }); + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota); + mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_quota: 0 }); + mocks.user.getAdmin.mockResolvedValue(factory.userAdmin({ isAdmin: true })); mocks.user.getByEmail.mockResolvedValue(void 0); - mocks.user.getAdmin.mockResolvedValue(userStub.user1); - mocks.user.create.mockResolvedValue(userStub.user1); - mocks.oauth.getProfile.mockResolvedValue({ sub, email, immich_quota: 0 }); + mocks.user.create.mockResolvedValue(user); mocks.session.create.mockResolvedValue(factory.session()); await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual( - oauthResponse, + oauthResponse(user), ); expect(mocks.user.create).toHaveBeenCalledWith({ - email, + email: user.email, name: ' ', - oauthId: sub, + oauthId: user.oauthId, quotaSizeInBytes: null, storageLabel: null, }); }); it('should use a valid storage quota', async () => { + const user = factory.userAdmin({ oauthId: 'oauth-id' }); + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota); + mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_quota: 5 }); mocks.user.getByEmail.mockResolvedValue(void 0); - mocks.user.getAdmin.mockResolvedValue(userStub.user1); - mocks.user.create.mockResolvedValue(userStub.user1); - mocks.oauth.getProfile.mockResolvedValue({ sub, email, immich_quota: 5 }); + mocks.user.getAdmin.mockResolvedValue(factory.userAdmin({ isAdmin: true })); + mocks.user.getByOAuthId.mockResolvedValue(void 0); + mocks.user.create.mockResolvedValue(user); mocks.session.create.mockResolvedValue(factory.session()); await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual( - oauthResponse, + oauthResponse(user), ); expect(mocks.user.create).toHaveBeenCalledWith({ - email, + email: user.email, name: ' ', - oauthId: sub, + oauthId: user.oauthId, quotaSizeInBytes: 5_368_709_120, storageLabel: null, }); }); + + it('should sync the profile picture', async () => { + const fileId = newUuid(); + const user = factory.userAdmin({ oauthId: 'oauth-id' }); + const pictureUrl = 'https://auth.immich.cloud/profiles/1.jpg'; + + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled); + mocks.oauth.getProfile.mockResolvedValue({ + sub: user.oauthId, + email: user.email, + picture: pictureUrl, + }); + mocks.user.getByOAuthId.mockResolvedValue(user); + mocks.crypto.randomUUID.mockReturnValue(fileId); + mocks.oauth.getProfilePicture.mockResolvedValue({ + contentType: 'image/jpeg', + data: new Uint8Array([1, 2, 3, 4, 5]), + }); + mocks.user.update.mockResolvedValue(user); + mocks.session.create.mockResolvedValue(factory.session()); + + await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual( + oauthResponse(user), + ); + + expect(mocks.user.update).toHaveBeenCalledWith(user.id, { + profileImagePath: `upload/profile/${user.id}/${fileId}.jpg`, + profileChangedAt: expect.any(Date), + }); + expect(mocks.oauth.getProfilePicture).toHaveBeenCalledWith(pictureUrl); + }); + + it('should not sync the profile picture if the user already has one', async () => { + const user = factory.userAdmin({ oauthId: 'oauth-id', profileImagePath: 'not-empty' }); + + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled); + mocks.oauth.getProfile.mockResolvedValue({ + sub: user.oauthId, + email: user.email, + picture: 'https://auth.immich.cloud/profiles/1.jpg', + }); + mocks.user.getByOAuthId.mockResolvedValue(user); + mocks.user.update.mockResolvedValue(user); + mocks.session.create.mockResolvedValue(factory.session()); + + await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual( + oauthResponse(user), + ); + + expect(mocks.user.update).not.toHaveBeenCalled(); + expect(mocks.oauth.getProfilePicture).not.toHaveBeenCalled(); + }); }); describe('link', () => { it('should link an account', async () => { - const authUser = factory.authUser(); - const authApiKey = factory.authApiKey({ permissions: [] }); - const auth = { user: authUser, apiKey: authApiKey }; + const user = factory.userAdmin(); + const auth = factory.auth({ apiKey: { permissions: [] }, user }); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled); - mocks.user.update.mockResolvedValue(userStub.user1); + mocks.user.update.mockResolvedValue(user); await sut.link(auth, { url: 'http://immich/user-settings?code=abc123' }); @@ -717,7 +790,7 @@ describe('AuthService', () => { const auth = { user: authUser, apiKey: authApiKey }; mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled); - mocks.user.getByOAuthId.mockResolvedValue({ id: 'other-user' } as UserEntity); + mocks.user.getByOAuthId.mockResolvedValue({ id: 'other-user' } as UserAdmin); await expect(sut.link(auth, { url: 'http://immich/user-settings?code=abc123' })).rejects.toBeInstanceOf( BadRequestException, @@ -729,12 +802,11 @@ describe('AuthService', () => { describe('unlink', () => { it('should unlink an account', async () => { - const authUser = factory.authUser(); - const authApiKey = factory.authApiKey({ permissions: [] }); - const auth = { user: authUser, apiKey: authApiKey }; + const user = factory.userAdmin(); + const auth = factory.auth({ user, apiKey: { permissions: [] } }); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled); - mocks.user.update.mockResolvedValue(userStub.user1); + mocks.user.update.mockResolvedValue(user); await sut.unlink(auth); diff --git a/server/src/services/auth.service.ts b/server/src/services/auth.service.ts index 4110427b0c..ee4ca4dc5d 100644 --- a/server/src/services/auth.service.ts +++ b/server/src/services/auth.service.ts @@ -3,7 +3,10 @@ import { isString } from 'class-validator'; import { parse } from 'cookie'; import { DateTime } from 'luxon'; import { IncomingHttpHeaders } from 'node:http'; +import { join } from 'node:path'; import { LOGIN_URL, MOBILE_REDIRECT, SALT_ROUNDS } from 'src/constants'; +import { StorageCore } from 'src/cores/storage.core'; +import { UserAdmin } from 'src/database'; import { OnEvent } from 'src/decorators'; import { AuthDto, @@ -17,13 +20,12 @@ import { mapLoginResponse, } from 'src/dtos/auth.dto'; import { UserAdminResponseDto, mapUserAdmin } from 'src/dtos/user.dto'; -import { UserEntity } from 'src/entities/user.entity'; -import { AuthType, ImmichCookie, ImmichHeader, ImmichQuery, Permission } from 'src/enum'; +import { AuthType, ImmichCookie, ImmichHeader, ImmichQuery, JobName, Permission, StorageFolder } from 'src/enum'; import { OAuthProfile } from 'src/repositories/oauth.repository'; import { BaseService } from 'src/services/base.service'; import { isGranted } from 'src/utils/access'; import { HumanReadableSize } from 'src/utils/bytes'; - +import { mimeTypes } from 'src/utils/mime-types'; export interface LoginDetails { isSecure: boolean; clientIp: string; @@ -190,7 +192,7 @@ export class AuthService extends BaseService { const profile = await this.oauthRepository.getProfile(oauth, dto.url, this.resolveRedirectUri(oauth, dto.url)); const { autoRegister, defaultStorageQuota, storageLabelClaim, storageQuotaClaim } = oauth; this.logger.debug(`Logging in with OAuth: ${JSON.stringify(profile)}`); - let user = await this.userRepository.getByOAuthId(profile.sub); + let user: UserAdmin | undefined = await this.userRepository.getByOAuthId(profile.sub); // link by email if (!user && profile.email) { @@ -239,9 +241,36 @@ export class AuthService extends BaseService { }); } + if (!user.profileImagePath && profile.picture) { + await this.syncProfilePicture(user, profile.picture); + } + return this.createLoginResponse(user, loginDetails); } + private async syncProfilePicture(user: UserAdmin, url: string) { + try { + const oldPath = user.profileImagePath; + + const { contentType, data } = await this.oauthRepository.getProfilePicture(url); + const extensionWithDot = mimeTypes.toExtension(contentType || 'image/jpeg') ?? 'jpg'; + const profileImagePath = join( + StorageCore.getFolderLocation(StorageFolder.PROFILE, user.id), + `${this.cryptoRepository.randomUUID()}${extensionWithDot}`, + ); + + this.storageCore.ensureFolders(profileImagePath); + await this.storageRepository.createFile(profileImagePath, Buffer.from(data)); + await this.userRepository.update(user.id, { profileImagePath, profileChangedAt: new Date() }); + + if (oldPath) { + await this.jobRepository.queue({ name: JobName.DELETE_FILES, data: { files: [oldPath] } }); + } + } catch (error: Error | any) { + this.logger.warn(`Unable to sync oauth profile picture: ${error}`, error?.stack); + } + } + async link(auth: AuthDto, dto: OAuthCallbackDto): Promise { const { oauth } = await this.getConfig({ withCache: false }); const { sub: oauthId } = await this.oauthRepository.getProfile( @@ -318,7 +347,7 @@ export class AuthService extends BaseService { throw new UnauthorizedException('Invalid API key'); } - private validatePassword(inputPassword: string, user: UserEntity): boolean { + private validatePassword(inputPassword: string, user: { password?: string }): boolean { if (!user || !user.password) { return false; } @@ -347,7 +376,7 @@ export class AuthService extends BaseService { throw new UnauthorizedException('Invalid user token'); } - private async createLoginResponse(user: UserEntity, loginDetails: LoginDetails) { + private async createLoginResponse(user: UserAdmin, loginDetails: LoginDetails) { const key = this.cryptoRepository.newPassword(32); const token = this.cryptoRepository.hashSha256(key); diff --git a/server/src/services/base.service.ts b/server/src/services/base.service.ts index 6739678561..b985ef8352 100644 --- a/server/src/services/base.service.ts +++ b/server/src/services/base.service.ts @@ -4,7 +4,7 @@ import sanitize from 'sanitize-filename'; import { SystemConfig } from 'src/config'; import { SALT_ROUNDS } from 'src/constants'; import { StorageCore } from 'src/cores/storage.core'; -import { UserEntity } from 'src/entities/user.entity'; +import { UserAdmin } from 'src/database'; import { AccessRepository } from 'src/repositories/access.repository'; import { ActivityRepository } from 'src/repositories/activity.repository'; import { AlbumUserRepository } from 'src/repositories/album-user.repository'; @@ -138,7 +138,7 @@ export class BaseService { return checkAccess(this.accessRepository, request); } - async createUser(dto: Insertable & { email: string }): Promise { + async createUser(dto: Insertable & { email: string }): Promise { const user = await this.userRepository.getByEmail(dto.email); if (user) { throw new BadRequestException('User exists'); diff --git a/server/src/services/cli.service.spec.ts b/server/src/services/cli.service.spec.ts index ce591a7e62..1140d44601 100644 --- a/server/src/services/cli.service.spec.ts +++ b/server/src/services/cli.service.spec.ts @@ -1,5 +1,5 @@ import { CliService } from 'src/services/cli.service'; -import { userStub } from 'test/fixtures/user.stub'; +import { factory } from 'test/small.factory'; import { newTestService, ServiceMocks } from 'test/utils'; import { describe, it } from 'vitest'; @@ -13,7 +13,7 @@ describe(CliService.name, () => { describe('listUsers', () => { it('should list users', async () => { - mocks.user.getList.mockResolvedValue([userStub.admin]); + mocks.user.getList.mockResolvedValue([factory.userAdmin({ isAdmin: true })]); await expect(sut.listUsers()).resolves.toEqual([expect.objectContaining({ isAdmin: true })]); expect(mocks.user.getList).toHaveBeenCalledWith({ withDeleted: true }); }); @@ -30,8 +30,10 @@ describe(CliService.name, () => { }); it('should default to a random password', async () => { - mocks.user.getAdmin.mockResolvedValue(userStub.admin); - mocks.user.update.mockResolvedValue(userStub.admin); + const admin = factory.userAdmin({ isAdmin: true }); + + mocks.user.getAdmin.mockResolvedValue(admin); + mocks.user.update.mockResolvedValue(factory.userAdmin({ isAdmin: true })); const ask = vitest.fn().mockImplementation(() => {}); @@ -41,13 +43,15 @@ describe(CliService.name, () => { expect(response.provided).toBe(false); expect(ask).toHaveBeenCalled(); - expect(id).toEqual(userStub.admin.id); + expect(id).toEqual(admin.id); expect(update.password).toBeDefined(); }); it('should use the supplied password', async () => { - mocks.user.getAdmin.mockResolvedValue(userStub.admin); - mocks.user.update.mockResolvedValue(userStub.admin); + const admin = factory.userAdmin({ isAdmin: true }); + + mocks.user.getAdmin.mockResolvedValue(admin); + mocks.user.update.mockResolvedValue(admin); const ask = vitest.fn().mockResolvedValue('new-password'); @@ -57,7 +61,7 @@ describe(CliService.name, () => { expect(response.provided).toBe(true); expect(ask).toHaveBeenCalled(); - expect(id).toEqual(userStub.admin.id); + expect(id).toEqual(admin.id); expect(update.password).toBeDefined(); }); }); diff --git a/server/src/services/map.service.spec.ts b/server/src/services/map.service.spec.ts index 95750f5590..6dc56abf44 100644 --- a/server/src/services/map.service.spec.ts +++ b/server/src/services/map.service.spec.ts @@ -35,7 +35,7 @@ describe(MapService.name, () => { it('should include partner assets', async () => { const partner = factory.partner(); - const auth = factory.auth({ id: partner.sharedWithId }); + const auth = factory.auth({ user: { id: partner.sharedWithId } }); const asset = assetStub.withLocation; const marker = { diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts index a754fc47d0..c55a277fae 100644 --- a/server/src/services/media.service.spec.ts +++ b/server/src/services/media.service.spec.ts @@ -1,8 +1,8 @@ import { OutputInfo } from 'sharp'; import { SystemConfig } from 'src/config'; +import { Exif } from 'src/database'; import { AssetMediaSize } from 'src/dtos/asset-media.dto'; import { AssetEntity } from 'src/entities/asset.entity'; -import { ExifEntity } from 'src/entities/exif.entity'; import { AssetFileType, AssetPathType, @@ -319,7 +319,7 @@ describe(MediaService.name, () => { it('should generate P3 thumbnails for a wide gamut image', async () => { mocks.asset.getById.mockResolvedValue({ ...assetStub.image, - exifInfo: { profileDescription: 'Adobe RGB', bitsPerSample: 14 } as ExifEntity, + exifInfo: { profileDescription: 'Adobe RGB', bitsPerSample: 14 } as Exif, }); const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8'); mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer); @@ -2608,47 +2608,47 @@ describe(MediaService.name, () => { describe('isSRGB', () => { it('should return true for srgb colorspace', () => { - const asset = { ...assetStub.image, exifInfo: { colorspace: 'sRGB' } as ExifEntity }; + const asset = { ...assetStub.image, exifInfo: { colorspace: 'sRGB' } as Exif }; expect(sut.isSRGB(asset)).toEqual(true); }); it('should return true for srgb profile description', () => { - const asset = { ...assetStub.image, exifInfo: { profileDescription: 'sRGB v1.31' } as ExifEntity }; + const asset = { ...assetStub.image, exifInfo: { profileDescription: 'sRGB v1.31' } as Exif }; expect(sut.isSRGB(asset)).toEqual(true); }); it('should return true for 8-bit image with no colorspace metadata', () => { - const asset = { ...assetStub.image, exifInfo: { bitsPerSample: 8 } as ExifEntity }; + const asset = { ...assetStub.image, exifInfo: { bitsPerSample: 8 } as Exif }; expect(sut.isSRGB(asset)).toEqual(true); }); it('should return true for image with no colorspace or bit depth metadata', () => { - const asset = { ...assetStub.image, exifInfo: {} as ExifEntity }; + const asset = { ...assetStub.image, exifInfo: {} as Exif }; expect(sut.isSRGB(asset)).toEqual(true); }); it('should return false for non-srgb colorspace', () => { - const asset = { ...assetStub.image, exifInfo: { colorspace: 'Adobe RGB' } as ExifEntity }; + const asset = { ...assetStub.image, exifInfo: { colorspace: 'Adobe RGB' } as Exif }; expect(sut.isSRGB(asset)).toEqual(false); }); it('should return false for non-srgb profile description', () => { - const asset = { ...assetStub.image, exifInfo: { profileDescription: 'sP3C' } as ExifEntity }; + const asset = { ...assetStub.image, exifInfo: { profileDescription: 'sP3C' } as Exif }; expect(sut.isSRGB(asset)).toEqual(false); }); it('should return false for 16-bit image with no colorspace metadata', () => { - const asset = { ...assetStub.image, exifInfo: { bitsPerSample: 16 } as ExifEntity }; + const asset = { ...assetStub.image, exifInfo: { bitsPerSample: 16 } as Exif }; expect(sut.isSRGB(asset)).toEqual(false); }); it('should return true for 16-bit image with sRGB colorspace', () => { - const asset = { ...assetStub.image, exifInfo: { colorspace: 'sRGB', bitsPerSample: 16 } as ExifEntity }; + const asset = { ...assetStub.image, exifInfo: { colorspace: 'sRGB', bitsPerSample: 16 } as Exif }; expect(sut.isSRGB(asset)).toEqual(true); }); it('should return true for 16-bit image with sRGB profile', () => { - const asset = { ...assetStub.image, exifInfo: { profileDescription: 'sRGB', bitsPerSample: 16 } as ExifEntity }; + const asset = { ...assetStub.image, exifInfo: { profileDescription: 'sRGB', bitsPerSample: 16 } as Exif }; expect(sut.isSRGB(asset)).toEqual(true); }); }); diff --git a/server/src/services/memory.service.spec.ts b/server/src/services/memory.service.spec.ts index 22a4199572..7ce8b1ab46 100644 --- a/server/src/services/memory.service.spec.ts +++ b/server/src/services/memory.service.spec.ts @@ -24,7 +24,7 @@ describe(MemoryService.name, () => { mocks.memory.search.mockResolvedValue([memory1, memory2]); - await expect(sut.search(factory.auth({ id: userId }), {})).resolves.toEqual( + await expect(sut.search(factory.auth({ user: { id: userId } }), {})).resolves.toEqual( expect.arrayContaining([ expect.objectContaining({ id: memory1.id, assets: [expect.objectContaining({ id: asset.id })] }), expect.objectContaining({ id: memory2.id, assets: [] }), @@ -60,7 +60,9 @@ describe(MemoryService.name, () => { mocks.memory.get.mockResolvedValue(memory); mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set([memory.id])); - await expect(sut.get(factory.auth({ id: userId }), memory.id)).resolves.toMatchObject({ id: memory.id }); + await expect(sut.get(factory.auth({ user: { id: userId } }), memory.id)).resolves.toMatchObject({ + id: memory.id, + }); expect(mocks.memory.get).toHaveBeenCalledWith(memory.id); expect(mocks.access.memory.checkOwnerAccess).toHaveBeenCalledWith(memory.ownerId, new Set([memory.id])); @@ -75,7 +77,7 @@ describe(MemoryService.name, () => { mocks.memory.create.mockResolvedValue(memory); await expect( - sut.create(factory.auth({ id: userId }), { + sut.create(factory.auth({ user: { id: userId } }), { type: memory.type, data: memory.data, memoryAt: memory.memoryAt, @@ -105,7 +107,7 @@ describe(MemoryService.name, () => { mocks.memory.create.mockResolvedValue(memory); await expect( - sut.create(factory.auth({ id: userId }), { + sut.create(factory.auth({ user: { id: userId } }), { type: memory.type, data: memory.data, assetIds: memory.assets.map((asset) => asset.id), diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index 9947d803a7..6e95430402 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -3,8 +3,8 @@ import { randomBytes } from 'node:crypto'; import { Stats } from 'node:fs'; import { constants } from 'node:fs/promises'; import { defaults } from 'src/config'; +import { Exif } from 'src/database'; import { AssetEntity } from 'src/entities/asset.entity'; -import { ExifEntity } from 'src/entities/exif.entity'; import { AssetType, ExifOrientation, ImmichWorker, JobName, JobStatus, SourceType } from 'src/enum'; import { WithoutProperty } from 'src/repositories/asset.repository'; import { ImmichTags } from 'src/repositories/metadata.repository'; @@ -12,12 +12,34 @@ import { MetadataService } from 'src/services/metadata.service'; import { assetStub } from 'test/fixtures/asset.stub'; import { fileStub } from 'test/fixtures/file.stub'; import { probeStub } from 'test/fixtures/media.stub'; -import { metadataStub } from 'test/fixtures/metadata.stub'; import { personStub } from 'test/fixtures/person.stub'; import { tagStub } from 'test/fixtures/tag.stub'; import { factory } from 'test/small.factory'; import { newTestService, ServiceMocks } from 'test/utils'; +const makeFaceTags = (face: Partial<{ Name: string }> = {}) => ({ + RegionInfo: { + AppliedToDimensions: { + W: 100, + H: 100, + Unit: 'normalized', + }, + RegionList: [ + { + Type: 'face', + Area: { + X: 0.05, + Y: 0.05, + W: 0.1, + H: 0.1, + Unit: 'normalized', + }, + ...face, + }, + ], + }, +}); + describe(MetadataService.name, () => { let sut: MetadataService; let mocks: ServiceMocks; @@ -969,7 +991,7 @@ describe(MetadataService.name, () => { it('should skip importing metadata when the feature is disabled', async () => { mocks.asset.getByIds.mockResolvedValue([assetStub.primaryImage]); mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: false } } }); - mockReadTags(metadataStub.withFace); + mockReadTags(makeFaceTags({ Name: 'Person 1' })); await sut.handleMetadataExtraction({ id: assetStub.image.id }); expect(mocks.person.getDistinctNames).not.toHaveBeenCalled(); }); @@ -977,7 +999,7 @@ describe(MetadataService.name, () => { it('should skip importing metadata face for assets without tags.RegionInfo', async () => { mocks.asset.getByIds.mockResolvedValue([assetStub.primaryImage]); mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } }); - mockReadTags(metadataStub.empty); + mockReadTags(); await sut.handleMetadataExtraction({ id: assetStub.image.id }); expect(mocks.person.getDistinctNames).not.toHaveBeenCalled(); }); @@ -985,7 +1007,7 @@ describe(MetadataService.name, () => { it('should skip importing faces without name', async () => { mocks.asset.getByIds.mockResolvedValue([assetStub.primaryImage]); mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } }); - mockReadTags(metadataStub.withFaceNoName); + mockReadTags(makeFaceTags()); mocks.person.getDistinctNames.mockResolvedValue([]); mocks.person.createAll.mockResolvedValue([]); await sut.handleMetadataExtraction({ id: assetStub.image.id }); @@ -997,7 +1019,7 @@ describe(MetadataService.name, () => { it('should skip importing faces with empty name', async () => { mocks.asset.getByIds.mockResolvedValue([assetStub.primaryImage]); mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } }); - mockReadTags(metadataStub.withFaceEmptyName); + mockReadTags(makeFaceTags({ Name: '' })); mocks.person.getDistinctNames.mockResolvedValue([]); mocks.person.createAll.mockResolvedValue([]); await sut.handleMetadataExtraction({ id: assetStub.image.id }); @@ -1009,7 +1031,7 @@ describe(MetadataService.name, () => { it('should apply metadata face tags creating new persons', async () => { mocks.asset.getByIds.mockResolvedValue([assetStub.primaryImage]); mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } }); - mockReadTags(metadataStub.withFace); + mockReadTags(makeFaceTags({ Name: personStub.withName.name })); mocks.person.getDistinctNames.mockResolvedValue([]); mocks.person.createAll.mockResolvedValue([personStub.withName.id]); mocks.person.update.mockResolvedValue(personStub.withName); @@ -1050,7 +1072,7 @@ describe(MetadataService.name, () => { it('should assign metadata face tags to existing persons', async () => { mocks.asset.getByIds.mockResolvedValue([assetStub.primaryImage]); mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } }); - mockReadTags(metadataStub.withFace); + mockReadTags(makeFaceTags({ Name: personStub.withName.name })); mocks.person.getDistinctNames.mockResolvedValue([{ id: personStub.withName.id, name: personStub.withName.name }]); mocks.person.createAll.mockResolvedValue([]); mocks.person.update.mockResolvedValue(personStub.withName); @@ -1190,7 +1212,7 @@ describe(MetadataService.name, () => { mocks.asset.getByIds.mockResolvedValue([ { ...assetStub.livePhotoStillAsset, - exifInfo: { livePhotoCID: assetStub.livePhotoMotionAsset.id } as ExifEntity, + exifInfo: { livePhotoCID: assetStub.livePhotoMotionAsset.id } as Exif, }, ]); mocks.asset.findLivePhotoMatch.mockResolvedValue(assetStub.livePhotoMotionAsset); @@ -1229,18 +1251,51 @@ describe(MetadataService.name, () => { }); it.each([ - { Make: '1', Model: '2', Device: { Manufacturer: '3', ModelName: '4' }, AndroidMake: '4', AndroidModel: '5' }, - { Device: { Manufacturer: '1', ModelName: '2' }, AndroidMake: '3', AndroidModel: '4' }, - { AndroidMake: '1', AndroidModel: '2' }, - ])('should read camera make and model correct place %s', async (metaData) => { + { + exif: { + Make: '1', + Model: '2', + Device: { Manufacturer: '3', ModelName: '4' }, + AndroidMake: '4', + AndroidModel: '5', + }, + expected: { make: '1', model: '2' }, + }, + { + exif: { Device: { Manufacturer: '1', ModelName: '2' }, AndroidMake: '3', AndroidModel: '4' }, + expected: { make: '1', model: '2' }, + }, + { exif: { AndroidMake: '1', AndroidModel: '2' }, expected: { make: '1', model: '2' } }, + ])('should read camera make and model $exif -> $expected', async ({ exif, expected }) => { mocks.asset.getByIds.mockResolvedValue([assetStub.image]); - mockReadTags(metaData); + mockReadTags(exif); + + await sut.handleMetadataExtraction({ id: assetStub.image.id }); + expect(mocks.asset.upsertExif).toHaveBeenCalledWith(expect.objectContaining(expected)); + }); + + it.each([ + { exif: {}, expected: null }, + { exif: { LensID: '1', LensSpec: '2', LensType: '3', LensModel: '4' }, expected: '1' }, + { exif: { LensSpec: '2', LensType: '3', LensModel: '4' }, expected: '3' }, + { exif: { LensSpec: '2', LensModel: '4' }, expected: '2' }, + { exif: { LensModel: '4' }, expected: '4' }, + { exif: { LensID: '----' }, expected: null }, + { exif: { LensID: 'Unknown (0 ff ff)' }, expected: null }, + { + exif: { LensID: 'Unknown (E1 40 19 36 2C 35 DF 0E) Tamron 10-24mm f/3.5-4.5 Di II VC HLD (B023) ?' }, + expected: null, + }, + { exif: { LensID: ' Unknown 6-30mm' }, expected: null }, + { exif: { LensID: '' }, expected: null }, + ])('should read camera lens information $exif -> $expected', async ({ exif, expected }) => { + mocks.asset.getByIds.mockResolvedValue([assetStub.image]); + mockReadTags(exif); await sut.handleMetadataExtraction({ id: assetStub.image.id }); expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect.objectContaining({ - make: '1', - model: '2', + lensModel: expected, }), ); }); diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index 72f7270844..3bf0c6d5c7 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -9,11 +9,9 @@ import { constants } from 'node:fs/promises'; import path from 'node:path'; import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants'; import { StorageCore } from 'src/cores/storage.core'; -import { Exif } from 'src/db'; +import { AssetFaces, Exif, Person } from 'src/db'; import { OnEvent, OnJob } from 'src/decorators'; -import { AssetFaceEntity } from 'src/entities/asset-face.entity'; import { AssetEntity } from 'src/entities/asset.entity'; -import { PersonEntity } from 'src/entities/person.entity'; import { AssetType, DatabaseLock, @@ -76,6 +74,19 @@ const validateRange = (value: number | undefined, min: number, max: number): Non return val; }; +const getLensModel = (exifTags: ImmichTags): string | null => { + const lensModel = String( + exifTags.LensID ?? exifTags.LensType ?? exifTags.LensSpec ?? exifTags.LensModel ?? '', + ).trim(); + if (lensModel === '----') { + return null; + } + if (lensModel.startsWith('Unknown')) { + return null; + } + return lensModel || null; +}; + type ImmichTagsWithFaces = ImmichTags & { RegionInfo: NonNullable }; type Dates = { @@ -228,7 +239,7 @@ export class MetadataService extends BaseService { fps: validate(Number.parseFloat(exifTags.VideoFrameRate!)), iso: validate(exifTags.ISO) as number, exposureTime: exifTags.ExposureTime ?? null, - lensModel: exifTags.LensModel ?? null, + lensModel: getLensModel(exifTags), fNumber: validate(exifTags.FNumber), focalLength: validate(exifTags.FocalLength), @@ -574,10 +585,10 @@ export class MetadataService extends BaseService { return; } - const facesToAdd: (Partial & { assetId: string })[] = []; + const facesToAdd: (Insertable & { assetId: string })[] = []; const existingNames = await this.personRepository.getDistinctNames(asset.ownerId, { withHidden: true }); const existingNameMap = new Map(existingNames.map(({ id, name }) => [name.toLowerCase(), id])); - const missing: (Partial & { ownerId: string })[] = []; + const missing: (Insertable & { ownerId: string })[] = []; const missingWithFaceAsset: { id: string; ownerId: string; faceAssetId: string }[] = []; for (const region of tags.RegionInfo.RegionList) { if (!region.Name) { diff --git a/server/src/services/notification.service.spec.ts b/server/src/services/notification.service.spec.ts index 823f1614ea..c9a6f593ba 100644 --- a/server/src/services/notification.service.spec.ts +++ b/server/src/services/notification.service.spec.ts @@ -1,8 +1,7 @@ import { plainToInstance } from 'class-transformer'; import { defaults, SystemConfig } from 'src/config'; +import { AlbumUser } from 'src/database'; import { SystemConfigDto } from 'src/dtos/system-config.dto'; -import { AlbumUserEntity } from 'src/entities/album-user.entity'; -import { AssetFileEntity } from 'src/entities/asset-files.entity'; import { AssetFileType, JobName, JobStatus, UserMetadataKey } from 'src/enum'; import { EmailTemplate } from 'src/repositories/notification.repository'; import { NotificationService } from 'src/services/notification.service'; @@ -442,7 +441,7 @@ describe(NotificationService.name, () => { mocks.notification.renderEmail.mockResolvedValue({ html: '', text: '' }); mocks.asset.getById.mockResolvedValue({ ...assetStub.image, - files: [{ assetId: 'asset-id', type: AssetFileType.THUMBNAIL, path: 'path-to-thumb.jpg' } as AssetFileEntity], + files: [{ id: '1', type: AssetFileType.THUMBNAIL, path: 'path-to-thumb.jpg' }], }); await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.SUCCESS); @@ -503,7 +502,7 @@ describe(NotificationService.name, () => { it('should skip recipient that could not be looked up', async () => { mocks.album.getById.mockResolvedValue({ ...albumStub.emptyWithValidThumbnail, - albumUsers: [{ user: { id: userStub.user1.id } } as AlbumUserEntity], + albumUsers: [{ user: { id: userStub.user1.id } } as AlbumUser], }); mocks.user.get.mockResolvedValueOnce(userStub.user1); mocks.notification.renderEmail.mockResolvedValue({ html: '', text: '' }); @@ -516,7 +515,7 @@ describe(NotificationService.name, () => { it('should skip recipient with disabled email notifications', async () => { mocks.album.getById.mockResolvedValue({ ...albumStub.emptyWithValidThumbnail, - albumUsers: [{ user: { id: userStub.user1.id } } as AlbumUserEntity], + albumUsers: [{ user: { id: userStub.user1.id } } as AlbumUser], }); mocks.user.get.mockResolvedValue({ ...userStub.user1, @@ -537,7 +536,7 @@ describe(NotificationService.name, () => { it('should skip recipient with disabled email notifications for the album update event', async () => { mocks.album.getById.mockResolvedValue({ ...albumStub.emptyWithValidThumbnail, - albumUsers: [{ user: { id: userStub.user1.id } } as AlbumUserEntity], + albumUsers: [{ user: { id: userStub.user1.id } } as AlbumUser], }); mocks.user.get.mockResolvedValue({ ...userStub.user1, @@ -558,7 +557,7 @@ describe(NotificationService.name, () => { it('should send email', async () => { mocks.album.getById.mockResolvedValue({ ...albumStub.emptyWithValidThumbnail, - albumUsers: [{ user: { id: userStub.user1.id } } as AlbumUserEntity], + albumUsers: [{ user: { id: userStub.user1.id } } as AlbumUser], }); mocks.user.get.mockResolvedValue(userStub.user1); mocks.notification.renderEmail.mockResolvedValue({ html: '', text: '' }); diff --git a/server/src/services/partner.service.spec.ts b/server/src/services/partner.service.spec.ts index 6c3460666e..c6d5762c2c 100644 --- a/server/src/services/partner.service.spec.ts +++ b/server/src/services/partner.service.spec.ts @@ -22,7 +22,7 @@ describe(PartnerService.name, () => { const user2 = factory.user(); const sharedWithUser2 = factory.partner({ sharedBy: user1, sharedWith: user2 }); const sharedWithUser1 = factory.partner({ sharedBy: user2, sharedWith: user1 }); - const auth = factory.auth({ id: user1.id }); + const auth = factory.auth({ user: { id: user1.id } }); mocks.partner.getAll.mockResolvedValue([sharedWithUser1, sharedWithUser2]); @@ -35,7 +35,7 @@ describe(PartnerService.name, () => { const user2 = factory.user(); const sharedWithUser2 = factory.partner({ sharedBy: user1, sharedWith: user2 }); const sharedWithUser1 = factory.partner({ sharedBy: user2, sharedWith: user1 }); - const auth = factory.auth({ id: user1.id }); + const auth = factory.auth({ user: { id: user1.id } }); mocks.partner.getAll.mockResolvedValue([sharedWithUser1, sharedWithUser2]); await expect(sut.search(auth, { direction: PartnerDirection.SharedWith })).resolves.toBeDefined(); @@ -48,7 +48,7 @@ describe(PartnerService.name, () => { const user1 = factory.user(); const user2 = factory.user(); const partner = factory.partner({ sharedBy: user1, sharedWith: user2 }); - const auth = factory.auth({ id: user1.id }); + const auth = factory.auth({ user: { id: user1.id } }); mocks.partner.get.mockResolvedValue(void 0); mocks.partner.create.mockResolvedValue(partner); @@ -65,7 +65,7 @@ describe(PartnerService.name, () => { const user1 = factory.user(); const user2 = factory.user(); const partner = factory.partner({ sharedBy: user1, sharedWith: user2 }); - const auth = factory.auth({ id: user1.id }); + const auth = factory.auth({ user: { id: user1.id } }); mocks.partner.get.mockResolvedValue(partner); @@ -80,7 +80,7 @@ describe(PartnerService.name, () => { const user1 = factory.user(); const user2 = factory.user(); const partner = factory.partner({ sharedBy: user1, sharedWith: user2 }); - const auth = factory.auth({ id: user1.id }); + const auth = factory.auth({ user: { id: user1.id } }); mocks.partner.get.mockResolvedValue(partner); @@ -113,7 +113,7 @@ describe(PartnerService.name, () => { const user1 = factory.user(); const user2 = factory.user(); const partner = factory.partner({ sharedBy: user1, sharedWith: user2 }); - const auth = factory.auth({ id: user1.id }); + const auth = factory.auth({ user: { id: user1.id } }); mocks.access.partner.checkUpdateAccess.mockResolvedValue(new Set([user2.id])); mocks.partner.update.mockResolvedValue(partner); diff --git a/server/src/services/person.service.spec.ts b/server/src/services/person.service.spec.ts index 1d8cdfd3b9..d907ae1714 100644 --- a/server/src/services/person.service.spec.ts +++ b/server/src/services/person.service.spec.ts @@ -1,7 +1,7 @@ import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { AssetFace } from 'src/database'; import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto'; import { mapFaces, mapPerson, PersonResponseDto } from 'src/dtos/person.dto'; -import { AssetFaceEntity } from 'src/entities/asset-face.entity'; import { CacheControl, Colorspace, ImageFormat, JobName, JobStatus, SourceType, SystemMetadataKey } from 'src/enum'; import { WithoutProperty } from 'src/repositories/asset.repository'; import { DetectedFaces } from 'src/repositories/machine-learning.repository'; @@ -11,8 +11,9 @@ import { 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 { personStub, personThumbnailStub } from 'test/fixtures/person.stub'; import { systemConfigStub } from 'test/fixtures/system-config.stub'; +import { factory } from 'test/small.factory'; import { makeStream, newTestService, ServiceMocks } from 'test/utils'; const responseDto: PersonResponseDto = { @@ -23,6 +24,7 @@ const responseDto: PersonResponseDto = { isHidden: false, updatedAt: expect.any(Date), isFavorite: false, + color: expect.any(String), }; const statistics = { assets: 3 }; @@ -89,6 +91,7 @@ describe(PersonService.name, () => { isHidden: true, isFavorite: false, updatedAt: expect.any(Date), + color: expect.any(String), }, ], }); @@ -117,6 +120,7 @@ describe(PersonService.name, () => { isHidden: false, isFavorite: true, updatedAt: expect.any(Date), + color: personStub.isFavorite.color, }, responseDto, ], @@ -136,7 +140,6 @@ describe(PersonService.name, () => { }); it('should throw a bad request when person is not found', async () => { - mocks.person.getById.mockResolvedValue(null); mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); await expect(sut.getById(authStub.admin, 'person-1')).rejects.toBeInstanceOf(BadRequestException); expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); @@ -160,7 +163,6 @@ describe(PersonService.name, () => { }); it('should throw an error when personId is invalid', async () => { - mocks.person.getById.mockResolvedValue(null); mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); await expect(sut.getThumbnail(authStub.admin, 'person-1')).rejects.toBeInstanceOf(NotFoundException); expect(mocks.storage.createReadStream).not.toHaveBeenCalled(); @@ -230,6 +232,7 @@ describe(PersonService.name, () => { isHidden: false, isFavorite: false, updatedAt: expect.any(Date), + color: expect.any(String), }); expect(mocks.person.update).toHaveBeenCalledWith({ id: 'person-1', birthDate: new Date('1976-06-30') }); expect(mocks.job.queue).not.toHaveBeenCalled(); @@ -345,7 +348,6 @@ describe(PersonService.name, () => { describe('handlePersonMigration', () => { it('should not move person files', async () => { - mocks.person.getById.mockResolvedValue(null); await expect(sut.handlePersonMigration(personStub.noName)).resolves.toBe(JobStatus.FAILED); }); }); @@ -399,6 +401,7 @@ describe(PersonService.name, () => { name: personStub.noName.name, thumbnailPath: personStub.noName.thumbnailPath, updatedAt: expect.any(Date), + color: personStub.noName.color, }); expect(mocks.job.queue).not.toHaveBeenCalledWith(); @@ -437,7 +440,7 @@ describe(PersonService.name, () => { await sut.handlePersonCleanup(); - expect(mocks.person.delete).toHaveBeenCalledWith([personStub.noName]); + expect(mocks.person.delete).toHaveBeenCalledWith([personStub.noName.id]); expect(mocks.storage.unlink).toHaveBeenCalledWith(personStub.noName.thumbnailPath); }); }); @@ -479,7 +482,7 @@ describe(PersonService.name, () => { await sut.handleQueueDetectFaces({ force: true }); expect(mocks.person.deleteFaces).toHaveBeenCalledWith({ sourceType: SourceType.MACHINE_LEARNING }); - expect(mocks.person.delete).toHaveBeenCalledWith([personStub.withName]); + expect(mocks.person.delete).toHaveBeenCalledWith([personStub.withName.id]); expect(mocks.storage.unlink).toHaveBeenCalledWith(personStub.withName.thumbnailPath); expect(mocks.asset.getAll).toHaveBeenCalled(); expect(mocks.job.queueAll).toHaveBeenCalledWith([ @@ -530,7 +533,7 @@ describe(PersonService.name, () => { data: { id: assetStub.image.id }, }, ]); - expect(mocks.person.delete).toHaveBeenCalledWith([personStub.randomPerson]); + expect(mocks.person.delete).toHaveBeenCalledWith([personStub.randomPerson.id]); expect(mocks.storage.unlink).toHaveBeenCalledWith(personStub.randomPerson.thumbnailPath); }); }); @@ -697,7 +700,7 @@ describe(PersonService.name, () => { data: { id: faceStub.face1.id, deferred: false }, }, ]); - expect(mocks.person.delete).toHaveBeenCalledWith([personStub.randomPerson]); + expect(mocks.person.delete).toHaveBeenCalledWith([personStub.randomPerson.id]); expect(mocks.storage.unlink).toHaveBeenCalledWith(personStub.randomPerson.thumbnailPath); }); }); @@ -730,7 +733,7 @@ describe(PersonService.name, () => { id: 'asset-face-1', assetId: assetStub.noResizePath.id, personId: faceStub.face1.personId, - } as AssetFaceEntity, + } as AssetFace, ], }, ]); @@ -847,8 +850,8 @@ describe(PersonService.name, () => { }); it('should fail if face does not have asset', async () => { - const face = { ...faceStub.face1, asset: null } as AssetFaceEntity & { asset: null }; - mocks.person.getFaceByIdWithAssets.mockResolvedValue(face); + const face = { ...faceStub.face1, asset: null }; + mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(face); expect(await sut.handleRecognizeFaces({ id: faceStub.face1.id })).toBe(JobStatus.FAILED); @@ -857,7 +860,7 @@ describe(PersonService.name, () => { }); it('should skip if face already has an assigned person', async () => { - mocks.person.getFaceByIdWithAssets.mockResolvedValue(faceStub.face1); + mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(faceStub.face1); expect(await sut.handleRecognizeFaces({ id: faceStub.face1.id })).toBe(JobStatus.SKIPPED); @@ -879,7 +882,7 @@ describe(PersonService.name, () => { mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } }); mocks.search.searchFaces.mockResolvedValue(faces); - mocks.person.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1); + mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(faceStub.noPerson1); mocks.person.create.mockResolvedValue(faceStub.primaryFace1.person); await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id }); @@ -909,7 +912,7 @@ describe(PersonService.name, () => { mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } }); mocks.search.searchFaces.mockResolvedValue(faces); - mocks.person.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1); + mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(faceStub.noPerson1); mocks.person.create.mockResolvedValue(faceStub.primaryFace1.person); await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id }); @@ -939,7 +942,7 @@ describe(PersonService.name, () => { mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } }); mocks.search.searchFaces.mockResolvedValue(faces); - mocks.person.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1); + mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(faceStub.noPerson1); mocks.person.create.mockResolvedValue(faceStub.primaryFace1.person); await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id }); @@ -964,7 +967,7 @@ describe(PersonService.name, () => { mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } }); mocks.search.searchFaces.mockResolvedValue(faces); - mocks.person.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1); + mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(faceStub.noPerson1); mocks.person.create.mockResolvedValue(personStub.withName); await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id }); @@ -983,7 +986,7 @@ describe(PersonService.name, () => { const faces = [{ ...faceStub.noPerson1, distance: 0 }] as FaceSearchResult[]; mocks.search.searchFaces.mockResolvedValue(faces); - mocks.person.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1); + mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(faceStub.noPerson1); mocks.person.create.mockResolvedValue(personStub.withName); await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id }); @@ -1002,7 +1005,7 @@ describe(PersonService.name, () => { mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 3 } } }); mocks.search.searchFaces.mockResolvedValue(faces); - mocks.person.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1); + mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(faceStub.noPerson1); mocks.person.create.mockResolvedValue(personStub.withName); await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id }); @@ -1024,7 +1027,7 @@ describe(PersonService.name, () => { mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 3 } } }); mocks.search.searchFaces.mockResolvedValueOnce(faces).mockResolvedValueOnce([]); - mocks.person.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1); + mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(faceStub.noPerson1); mocks.person.create.mockResolvedValue(personStub.withName); await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id, deferred: true }); @@ -1046,7 +1049,6 @@ describe(PersonService.name, () => { }); it('should skip a person not found', async () => { - mocks.person.getById.mockResolvedValue(null); await sut.handleGeneratePersonThumbnail({ id: 'person-1' }); expect(mocks.media.generateThumbnail).not.toHaveBeenCalled(); }); @@ -1057,30 +1059,18 @@ describe(PersonService.name, () => { expect(mocks.media.generateThumbnail).not.toHaveBeenCalled(); }); - it('should skip a person with a face asset id not found', async () => { - mocks.person.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.middle.id }); - mocks.person.getFaceByIdWithAssets.mockResolvedValue(faceStub.face1); - await sut.handleGeneratePersonThumbnail({ id: 'person-1' }); - expect(mocks.media.generateThumbnail).not.toHaveBeenCalled(); - }); - - it('should skip a person with a face asset id without a thumbnail', async () => { - mocks.person.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.middle.assetId }); - mocks.person.getFaceByIdWithAssets.mockResolvedValue(faceStub.face1); - mocks.asset.getByIds.mockResolvedValue([assetStub.noResizePath]); + it('should skip a person with face not found', async () => { await sut.handleGeneratePersonThumbnail({ id: 'person-1' }); expect(mocks.media.generateThumbnail).not.toHaveBeenCalled(); }); it('should generate a thumbnail', async () => { - mocks.person.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.middle.assetId }); - mocks.person.getFaceByIdWithAssets.mockResolvedValue(faceStub.middle); - mocks.asset.getById.mockResolvedValue(assetStub.primaryImage); + mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.newThumbnailMiddle); mocks.media.generateThumbnail.mockResolvedValue(); await sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id }); - expect(mocks.asset.getById).toHaveBeenCalledWith(faceStub.middle.assetId, { exifInfo: true, files: true }); + expect(mocks.person.getDataForThumbnailGenerationJob).toHaveBeenCalledWith(personStub.primaryPerson.id); expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/thumbs/admin_id/pe/rs'); expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( assetStub.primaryImage.originalPath, @@ -1106,9 +1096,7 @@ describe(PersonService.name, () => { }); it('should generate a thumbnail without going negative', async () => { - mocks.person.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.start.assetId }); - mocks.person.getFaceByIdWithAssets.mockResolvedValue(faceStub.start); - mocks.asset.getById.mockResolvedValue(assetStub.image); + mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.newThumbnailStart); mocks.media.generateThumbnail.mockResolvedValue(); await sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id }); @@ -1133,10 +1121,8 @@ describe(PersonService.name, () => { }); it('should generate a thumbnail without overflowing', async () => { - mocks.person.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.end.assetId }); - mocks.person.getFaceByIdWithAssets.mockResolvedValue(faceStub.end); + mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.newThumbnailEnd); mocks.person.update.mockResolvedValue(personStub.primaryPerson); - mocks.asset.getById.mockResolvedValue(assetStub.primaryImage); mocks.media.generateThumbnail.mockResolvedValue(); await sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id }); @@ -1219,7 +1205,6 @@ describe(PersonService.name, () => { }); it('should throw an error when the primary person is not found', async () => { - mocks.person.getById.mockResolvedValue(null); mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).rejects.toBeInstanceOf( @@ -1232,7 +1217,6 @@ describe(PersonService.name, () => { it('should handle invalid merge ids', async () => { mocks.person.getById.mockResolvedValueOnce(personStub.primaryPerson); - mocks.person.getById.mockResolvedValueOnce(null); mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-1'])); mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-2'])); @@ -1279,7 +1263,8 @@ describe(PersonService.name, () => { describe('mapFace', () => { it('should map a face', () => { - expect(mapFaces(faceStub.face1, { user: personStub.withName.owner })).toEqual({ + const authDto = factory.auth({ user: { id: faceStub.face1.person.ownerId } }); + expect(mapFaces(faceStub.face1, authDto)).toEqual({ boundingBoxX1: 0, boundingBoxX2: 1, boundingBoxY1: 0, diff --git a/server/src/services/person.service.ts b/server/src/services/person.service.ts index ec412ad307..ae59e2d82c 100644 --- a/server/src/services/person.service.ts +++ b/server/src/services/person.service.ts @@ -1,6 +1,8 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { Insertable, Updateable } from 'kysely'; import { FACE_THUMBNAIL_SIZE, JOBS_ASSET_PAGINATION_SIZE } from 'src/constants'; import { StorageCore } from 'src/cores/storage.core'; +import { AssetFaces, FaceSearch, Person } from 'src/db'; import { Chunked, OnJob } from 'src/decorators'; import { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; @@ -21,10 +23,6 @@ import { PersonStatisticsResponseDto, PersonUpdateDto, } from 'src/dtos/person.dto'; -import { AssetFaceEntity } from 'src/entities/asset-face.entity'; -import { AssetEntity } from 'src/entities/asset.entity'; -import { FaceSearchEntity } from 'src/entities/face-search.entity'; -import { PersonEntity } from 'src/entities/person.entity'; import { AssetFileType, AssetType, @@ -243,9 +241,9 @@ export class PersonService extends BaseService { } @Chunked() - private async delete(people: PersonEntity[]) { + private async delete(people: { id: string; thumbnailPath: string }[]) { await Promise.all(people.map((person) => this.storageRepository.unlink(person.thumbnailPath))); - await this.personRepository.delete(people); + await this.personRepository.delete(people.map((person) => person.id)); this.logger.debug(`Deleted ${people.length} people`); } @@ -317,8 +315,8 @@ export class PersonService extends BaseService { ); this.logger.debug(`${faces.length} faces detected in ${previewFile.path}`); - const facesToAdd: (Partial & { id: string; assetId: string })[] = []; - const embeddings: FaceSearchEntity[] = []; + const facesToAdd: (Insertable & { id: string })[] = []; + const embeddings: FaceSearch[] = []; const mlFaceIds = new Set(); for (const face of asset.faces) { if (face.sourceType === SourceType.MACHINE_LEARNING) { @@ -377,7 +375,10 @@ export class PersonService extends BaseService { return JobStatus.SUCCESS; } - private iou(face: AssetFaceEntity, newBox: BoundingBox): number { + private iou( + face: { boundingBoxX1: number; boundingBoxY1: number; boundingBoxX2: number; boundingBoxY2: number }, + newBox: BoundingBox, + ): number { const x1 = Math.max(face.boundingBoxX1, newBox.x1); const y1 = Math.max(face.boundingBoxY1, newBox.y1); const x2 = Math.min(face.boundingBoxX2, newBox.x2); @@ -453,11 +454,7 @@ export class PersonService extends BaseService { return JobStatus.SKIPPED; } - const face = await this.personRepository.getFaceByIdWithAssets(id, { faceSearch: true }, [ - 'id', - 'personId', - 'sourceType', - ]); + const face = await this.personRepository.getFaceForFacialRecognitionJob(id); if (!face || !face.asset) { this.logger.warn(`Face ${id} not found`); return JobStatus.FAILED; @@ -545,46 +542,23 @@ export class PersonService extends BaseService { } @OnJob({ name: JobName.GENERATE_PERSON_THUMBNAIL, queue: QueueName.THUMBNAIL_GENERATION }) - async handleGeneratePersonThumbnail(data: JobOf): Promise { + async handleGeneratePersonThumbnail({ id }: JobOf): Promise { const { machineLearning, metadata, image } = await this.getConfig({ withCache: true }); if (!isFacialRecognitionEnabled(machineLearning) && !isFaceImportEnabled(metadata)) { return JobStatus.SKIPPED; } - const person = await this.personRepository.getById(data.id); - if (!person?.faceAssetId) { - this.logger.error(`Could not generate person thumbnail: person ${person?.id} has no face asset`); + const data = await this.personRepository.getDataForThumbnailGenerationJob(id); + if (!data) { + this.logger.error(`Could not generate person thumbnail for ${id}: missing data`); return JobStatus.FAILED; } - const face = await this.personRepository.getFaceByIdWithAssets(person.faceAssetId); - if (!face) { - this.logger.error(`Could not generate person thumbnail: face ${person.faceAssetId} not found`); - return JobStatus.FAILED; - } + const { ownerId, x1, y1, x2, y2, oldWidth, oldHeight } = data; - const { - assetId, - boundingBoxX1: x1, - boundingBoxX2: x2, - boundingBoxY1: y1, - boundingBoxY2: y2, - imageWidth: oldWidth, - imageHeight: oldHeight, - } = face; + const { width, height, inputPath } = await this.getInputDimensions(data); - const asset = await this.assetRepository.getById(assetId, { - exifInfo: true, - files: true, - }); - if (!asset) { - this.logger.error(`Could not generate person thumbnail: asset ${assetId} does not exist`); - return JobStatus.FAILED; - } - - const { width, height, inputPath } = await this.getInputDimensions(asset, { width: oldWidth, height: oldHeight }); - - const thumbnailPath = StorageCore.getPersonThumbnailPath(person); + const thumbnailPath = StorageCore.getPersonThumbnailPath({ id, ownerId }); this.storageCore.ensureFolders(thumbnailPath); const thumbnailOptions = { @@ -597,7 +571,7 @@ export class PersonService extends BaseService { }; await this.mediaRepository.generateThumbnail(inputPath, thumbnailOptions, thumbnailPath); - await this.personRepository.update({ id: person.id, thumbnailPath }); + await this.personRepository.update({ id, thumbnailPath }); return JobStatus.SUCCESS; } @@ -634,7 +608,7 @@ export class PersonService extends BaseService { continue; } - const update: Partial = {}; + const update: Updateable & { id: string } = { id: primaryPerson.id }; if (!primaryPerson.name && mergePerson.name) { update.name = mergePerson.name; } @@ -644,7 +618,7 @@ export class PersonService extends BaseService { } if (Object.keys(update).length > 0) { - primaryPerson = await this.personRepository.update({ id: primaryPerson.id, ...update }); + primaryPerson = await this.personRepository.update(update); } const mergeName = mergePerson.name || mergePerson.id; @@ -672,27 +646,26 @@ export class PersonService extends BaseService { return person; } - private async getInputDimensions(asset: AssetEntity, oldDims: ImageDimensions): Promise { - if (!asset.exifInfo?.exifImageHeight || !asset.exifInfo.exifImageWidth) { - throw new Error(`Asset ${asset.id} dimensions are unknown`); - } - - const previewFile = getAssetFile(asset.files, AssetFileType.PREVIEW); - if (!previewFile) { - throw new Error(`Asset ${asset.id} has no preview path`); - } - + private async getInputDimensions(asset: { + type: AssetType; + exifImageWidth: number; + exifImageHeight: number; + previewPath: string; + originalPath: string; + oldWidth: number; + oldHeight: number; + }): Promise { if (asset.type === AssetType.IMAGE) { - let { exifImageWidth: width, exifImageHeight: height } = asset.exifInfo; - if (oldDims.height > oldDims.width !== height > width) { + let { exifImageWidth: width, exifImageHeight: height } = asset; + if (asset.oldHeight > asset.oldWidth !== height > width) { [width, height] = [height, width]; } return { width, height, inputPath: asset.originalPath }; } - const { width, height } = await this.mediaRepository.getImageDimensions(previewFile.path); - return { width, height, inputPath: previewFile.path }; + const { width, height } = await this.mediaRepository.getImageDimensions(asset.previewPath); + return { width, height, inputPath: asset.previewPath }; } private getCrop(dims: { old: ImageDimensions; new: ImageDimensions }, { x1, y1, x2, y2 }: BoundingBox): CropOptions { diff --git a/server/src/services/shared-link.service.spec.ts b/server/src/services/shared-link.service.spec.ts index 8c79f752b7..4d084d6e67 100644 --- a/server/src/services/shared-link.service.spec.ts +++ b/server/src/services/shared-link.service.spec.ts @@ -7,6 +7,7 @@ import { albumStub } from 'test/fixtures/album.stub'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; import { sharedLinkResponseStub, sharedLinkStub } from 'test/fixtures/shared-link.stub'; +import { factory } from 'test/small.factory'; import { newTestService, ServiceMocks } from 'test/utils'; describe(SharedLinkService.name, () => { @@ -46,7 +47,13 @@ describe(SharedLinkService.name, () => { }); it('should not return metadata', async () => { - const authDto = authStub.adminSharedLinkNoExif; + const authDto = factory.auth({ + sharedLink: { + showExif: false, + allowDownload: true, + allowUpload: true, + }, + }); mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.readonlyNoExif); await expect(sut.getMine(authDto, {})).resolves.toEqual(sharedLinkResponseStub.readonlyNoMetadata); expect(mocks.sharedLink.get).toHaveBeenCalledWith(authDto.user.id, authDto.sharedLink?.id); @@ -208,7 +215,9 @@ describe(SharedLinkService.name, () => { it('should update a shared link', async () => { mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.valid); mocks.sharedLink.update.mockResolvedValue(sharedLinkStub.valid); + await sut.update(authStub.user1, sharedLinkStub.valid.id, { allowDownload: false }); + expect(mocks.sharedLink.get).toHaveBeenCalledWith(authStub.user1.user.id, sharedLinkStub.valid.id); expect(mocks.sharedLink.update).toHaveBeenCalledWith({ id: sharedLinkStub.valid.id, @@ -242,6 +251,7 @@ describe(SharedLinkService.name, () => { describe('addAssets', () => { it('should not work on album shared links', async () => { mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.valid); + await expect(sut.addAssets(authStub.admin, 'link-1', { assetIds: ['asset-1'] })).rejects.toBeInstanceOf( BadRequestException, ); @@ -273,6 +283,7 @@ describe(SharedLinkService.name, () => { describe('removeAssets', () => { it('should not work on album shared links', async () => { mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.valid); + await expect(sut.removeAssets(authStub.admin, 'link-1', { assetIds: ['asset-1'] })).rejects.toBeInstanceOf( BadRequestException, ); @@ -297,31 +308,39 @@ describe(SharedLinkService.name, () => { describe('getMetadataTags', () => { it('should return null when auth is not a shared link', async () => { await expect(sut.getMetadataTags(authStub.admin)).resolves.toBe(null); + expect(mocks.sharedLink.get).not.toHaveBeenCalled(); }); it('should return null when shared link has a password', async () => { - await expect(sut.getMetadataTags(authStub.passwordSharedLink)).resolves.toBe(null); + const auth = factory.auth({ user: {}, sharedLink: { password: 'password' } }); + + await expect(sut.getMetadataTags(auth)).resolves.toBe(null); + expect(mocks.sharedLink.get).not.toHaveBeenCalled(); }); it('should return metadata tags', async () => { mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.individual); + await expect(sut.getMetadataTags(authStub.adminSharedLink)).resolves.toEqual({ description: '1 shared photos & videos', imageUrl: `https://my.immich.app/api/assets/asset-id/thumbnail?key=LCtkaJX4R1O_9D-2lq0STzsPryoL1UdAbyb6Sna1xxmQCSuqU2J1ZUsqt6GR-yGm1s0`, title: 'Public Share', }); + expect(mocks.sharedLink.get).toHaveBeenCalled(); }); it('should return metadata tags with a default image path if the asset id is not set', async () => { mocks.sharedLink.get.mockResolvedValue({ ...sharedLinkStub.individual, album: undefined, assets: [] }); + await expect(sut.getMetadataTags(authStub.adminSharedLink)).resolves.toEqual({ description: '0 shared photos & videos', imageUrl: `https://my.immich.app/feature-panel.png`, title: 'Public Share', }); + expect(mocks.sharedLink.get).toHaveBeenCalled(); }); }); diff --git a/server/src/services/storage-template.service.spec.ts b/server/src/services/storage-template.service.spec.ts index edff406b48..46603cdbce 100644 --- a/server/src/services/storage-template.service.spec.ts +++ b/server/src/services/storage-template.service.spec.ts @@ -5,6 +5,7 @@ import { StorageTemplateService } from 'src/services/storage-template.service'; import { albumStub } from 'test/fixtures/album.stub'; import { assetStub } from 'test/fixtures/asset.stub'; import { userStub } from 'test/fixtures/user.stub'; +import { factory } from 'test/small.factory'; import { makeStream, newTestService, ServiceMocks } from 'test/utils'; const motionAsset = assetStub.storageAsset({}); @@ -426,15 +427,16 @@ describe(StorageTemplateService.name, () => { }); it('should use the user storage label', async () => { - const asset = assetStub.storageAsset(); + const user = factory.userAdmin({ storageLabel: 'label-1' }); + const asset = assetStub.storageAsset({ ownerId: user.id }); mocks.asset.streamStorageTemplateAssets.mockReturnValue(makeStream([asset])); - mocks.user.getList.mockResolvedValue([userStub.storageLabel]); + mocks.user.getList.mockResolvedValue([user]); mocks.move.create.mockResolvedValue({ id: '123', entityId: asset.id, pathType: AssetPathType.ORIGINAL, oldPath: asset.originalPath, - newPath: `upload/library/user-id/2023/2023-02-23/${asset.originalFileName}`, + newPath: `upload/library/${user.storageLabel}/2023/2023-02-23/${asset.originalFileName}`, }); await sut.handleMigration(); @@ -442,11 +444,11 @@ describe(StorageTemplateService.name, () => { expect(mocks.asset.streamStorageTemplateAssets).toHaveBeenCalled(); expect(mocks.storage.rename).toHaveBeenCalledWith( '/original/path.jpg', - `upload/library/label-1/2022/2022-06-19/${asset.originalFileName}`, + `upload/library/${user.storageLabel}/2022/2022-06-19/${asset.originalFileName}`, ); expect(mocks.asset.update).toHaveBeenCalledWith({ id: asset.id, - originalPath: `upload/library/label-1/2022/2022-06-19/${asset.originalFileName}`, + originalPath: `upload/library/${user.storageLabel}/2022/2022-06-19/${asset.originalFileName}`, }); }); @@ -551,98 +553,106 @@ describe(StorageTemplateService.name, () => { describe('file rename correctness', () => { it('should not create double extensions when filename has lower extension', async () => { + const user = factory.userAdmin({ storageLabel: 'label-1' }); const asset = assetStub.storageAsset({ - originalPath: 'upload/library/user-id/2022/2022-06-19/IMG_7065.heic', + ownerId: user.id, + originalPath: `upload/library/${user.id}/2022/2022-06-19/IMG_7065.heic`, originalFileName: 'IMG_7065.HEIC', }); mocks.asset.streamStorageTemplateAssets.mockReturnValue(makeStream([asset])); - mocks.user.getList.mockResolvedValue([userStub.storageLabel]); + mocks.user.getList.mockResolvedValue([user]); mocks.move.create.mockResolvedValue({ id: '123', entityId: asset.id, pathType: AssetPathType.ORIGINAL, - oldPath: 'upload/library/user-id/2022/2022-06-19/IMG_7065.heic', - newPath: 'upload/library/user-id/2023/2023-02-23/IMG_7065.heic', + oldPath: `upload/library/${user.id}/2022/2022-06-19/IMG_7065.heic`, + newPath: `upload/library/${user.id}/2023/2023-02-23/IMG_7065.heic`, }); await sut.handleMigration(); expect(mocks.asset.streamStorageTemplateAssets).toHaveBeenCalled(); expect(mocks.storage.rename).toHaveBeenCalledWith( - 'upload/library/user-id/2022/2022-06-19/IMG_7065.heic', - 'upload/library/label-1/2022/2022-06-19/IMG_7065.heic', + `upload/library/${user.id}/2022/2022-06-19/IMG_7065.heic`, + `upload/library/${user.storageLabel}/2022/2022-06-19/IMG_7065.heic`, ); }); it('should not create double extensions when filename has uppercase extension', async () => { + const user = factory.userAdmin(); const asset = assetStub.storageAsset({ - originalPath: 'upload/library/user-id/2022/2022-06-19/IMG_7065.HEIC', + ownerId: user.id, + originalPath: `upload/library/${user.id}/2022/2022-06-19/IMG_7065.HEIC`, originalFileName: 'IMG_7065.HEIC', }); mocks.asset.streamStorageTemplateAssets.mockReturnValue(makeStream([asset])); - mocks.user.getList.mockResolvedValue([userStub.storageLabel]); + mocks.user.getList.mockResolvedValue([user]); mocks.move.create.mockResolvedValue({ id: '123', entityId: asset.id, pathType: AssetPathType.ORIGINAL, - oldPath: 'upload/library/user-id/2022/2022-06-19/IMG_7065.HEIC', - newPath: 'upload/library/user-id/2023/2023-02-23/IMG_7065.heic', + oldPath: `upload/library/${user.id}/2022/2022-06-19/IMG_7065.HEIC`, + newPath: `upload/library/${user.id}/2023/2023-02-23/IMG_7065.heic`, }); await sut.handleMigration(); expect(mocks.asset.streamStorageTemplateAssets).toHaveBeenCalled(); expect(mocks.storage.rename).toHaveBeenCalledWith( - 'upload/library/user-id/2022/2022-06-19/IMG_7065.HEIC', - 'upload/library/label-1/2022/2022-06-19/IMG_7065.heic', + `upload/library/${user.id}/2022/2022-06-19/IMG_7065.HEIC`, + `upload/library/${user.id}/2022/2022-06-19/IMG_7065.heic`, ); }); it('should normalize the filename to lowercase (JPEG > jpg)', async () => { + const user = factory.userAdmin(); const asset = assetStub.storageAsset({ - originalPath: 'upload/library/user-id/2022/2022-06-19/IMG_7065.JPEG', + ownerId: user.id, + originalPath: `upload/library/${user.id}/2022/2022-06-19/IMG_7065.JPEG`, originalFileName: 'IMG_7065.JPEG', }); mocks.asset.streamStorageTemplateAssets.mockReturnValue(makeStream([asset])); - mocks.user.getList.mockResolvedValue([userStub.storageLabel]); + mocks.user.getList.mockResolvedValue([user]); mocks.move.create.mockResolvedValue({ id: '123', entityId: asset.id, pathType: AssetPathType.ORIGINAL, - oldPath: 'upload/library/user-id/2022/2022-06-19/IMG_7065.JPEG', - newPath: 'upload/library/user-id/2023/2023-02-23/IMG_7065.jpg', + oldPath: `upload/library/${user.id}/2022/2022-06-19/IMG_7065.JPEG`, + newPath: `upload/library/${user.id}/2023/2023-02-23/IMG_7065.jpg`, }); await sut.handleMigration(); expect(mocks.asset.streamStorageTemplateAssets).toHaveBeenCalled(); expect(mocks.storage.rename).toHaveBeenCalledWith( - 'upload/library/user-id/2022/2022-06-19/IMG_7065.JPEG', - 'upload/library/label-1/2022/2022-06-19/IMG_7065.jpg', + `upload/library/${user.id}/2022/2022-06-19/IMG_7065.JPEG`, + `upload/library/${user.id}/2022/2022-06-19/IMG_7065.jpg`, ); }); it('should normalize the filename to lowercase (JPG > jpg)', async () => { + const user = factory.userAdmin(); const asset = assetStub.storageAsset({ + ownerId: user.id, originalPath: 'upload/library/user-id/2022/2022-06-19/IMG_7065.JPG', originalFileName: 'IMG_7065.JPG', }); mocks.asset.streamStorageTemplateAssets.mockReturnValue(makeStream([asset])); - mocks.user.getList.mockResolvedValue([userStub.storageLabel]); + mocks.user.getList.mockResolvedValue([user]); mocks.move.create.mockResolvedValue({ id: '123', entityId: asset.id, pathType: AssetPathType.ORIGINAL, - oldPath: 'upload/library/user-id/2022/2022-06-19/IMG_7065.JPG', - newPath: 'upload/library/user-id/2023/2023-02-23/IMG_7065.jpg', + oldPath: `upload/library/${user.id}/2022/2022-06-19/IMG_7065.JPG`, + newPath: `upload/library/${user.id}/2023/2023-02-23/IMG_7065.jpg`, }); await sut.handleMigration(); expect(mocks.asset.streamStorageTemplateAssets).toHaveBeenCalled(); expect(mocks.storage.rename).toHaveBeenCalledWith( - 'upload/library/user-id/2022/2022-06-19/IMG_7065.JPG', - 'upload/library/label-1/2022/2022-06-19/IMG_7065.jpg', + `upload/library/${user.id}/2022/2022-06-19/IMG_7065.JPG`, + `upload/library/${user.id}/2022/2022-06-19/IMG_7065.jpg`, ); }); }); diff --git a/server/src/services/sync.service.spec.ts b/server/src/services/sync.service.spec.ts index 27a54b2b58..5f7357c64d 100644 --- a/server/src/services/sync.service.spec.ts +++ b/server/src/services/sync.service.spec.ts @@ -39,7 +39,7 @@ describe(SyncService.name, () => { describe('getChangesForDeltaSync', () => { it('should return a response requiring a full sync when partners are out of sync', async () => { const partner = factory.partner(); - const auth = factory.auth({ id: partner.sharedWithId }); + const auth = factory.auth({ user: { id: partner.sharedWithId } }); mocks.partner.getAll.mockResolvedValue([partner]); diff --git a/server/src/services/timeline.service.spec.ts b/server/src/services/timeline.service.spec.ts index 1c2c422433..c6a09d2fdf 100644 --- a/server/src/services/timeline.service.spec.ts +++ b/server/src/services/timeline.service.spec.ts @@ -3,6 +3,7 @@ import { TimeBucketSize } from 'src/repositories/asset.repository'; import { TimelineService } from 'src/services/timeline.service'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; +import { factory } from 'test/small.factory'; import { newTestService, ServiceMocks } from 'test/utils'; describe(TimelineService.name, () => { @@ -114,15 +115,15 @@ describe(TimelineService.name, () => { mocks.access.album.checkSharedLinkAccess.mockResolvedValue(new Set(['album-id'])); mocks.asset.getTimeBucket.mockResolvedValue([assetStub.image]); - const buckets = await sut.getTimeBucket( - { ...authStub.admin, sharedLink: { ...authStub.adminSharedLink.sharedLink!, showExif: false } }, - { - size: TimeBucketSize.DAY, - timeBucket: 'bucket', - isArchived: true, - albumId: 'album-id', - }, - ); + const auth = factory.auth({ sharedLink: { showExif: false } }); + + const buckets = await sut.getTimeBucket(auth, { + size: TimeBucketSize.DAY, + timeBucket: 'bucket', + isArchived: true, + albumId: 'album-id', + }); + expect(buckets).toEqual([expect.objectContaining({ id: 'asset-id' })]); expect(buckets[0]).not.toHaveProperty('exif'); expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith('bucket', { diff --git a/server/src/services/user.service.spec.ts b/server/src/services/user.service.spec.ts index c2433f13cb..4ee92fc3d3 100644 --- a/server/src/services/user.service.spec.ts +++ b/server/src/services/user.service.spec.ts @@ -1,5 +1,5 @@ import { BadRequestException, InternalServerErrorException, NotFoundException } from '@nestjs/common'; -import { UserEntity } from 'src/entities/user.entity'; +import { UserAdmin } from 'src/database'; import { CacheControl, JobName, UserMetadataKey } from 'src/enum'; import { UserService } from 'src/services/user.service'; import { ImmichFileResponse } from 'src/utils/file'; @@ -29,7 +29,7 @@ describe(UserService.name, () => { describe('getAll', () => { it('admin should get all users', async () => { const user = factory.userAdmin(); - const auth = factory.auth(user); + const auth = factory.auth({ user }); mocks.user.getList.mockResolvedValue([user]); @@ -39,14 +39,12 @@ describe(UserService.name, () => { }); it('non-admin should get all users when publicUsers enabled', async () => { - mocks.user.getList.mockResolvedValue([userStub.user1]); + const user = factory.userAdmin(); + const auth = factory.auth({ user }); - await expect(sut.search(authStub.user1)).resolves.toEqual([ - expect.objectContaining({ - id: authStub.user1.user.id, - email: authStub.user1.user.email, - }), - ]); + mocks.user.getList.mockResolvedValue([user]); + + await expect(sut.search(auth)).resolves.toEqual([expect.objectContaining({ id: user.id, email: user.email })]); expect(mocks.user.getList).toHaveBeenCalledWith({ withDeleted: false }); }); @@ -107,17 +105,19 @@ describe(UserService.name, () => { it('should throw an error if the user profile could not be updated with the new image', async () => { const file = { path: '/profile/path' } as Express.Multer.File; - mocks.user.get.mockResolvedValue(userStub.profilePath); + const user = factory.userAdmin({ profileImagePath: '/path/to/profile.jpg' }); + mocks.user.get.mockResolvedValue(user); mocks.user.update.mockRejectedValue(new InternalServerErrorException('mocked error')); await expect(sut.createProfileImage(authStub.admin, file)).rejects.toThrowError(InternalServerErrorException); }); it('should delete the previous profile image', async () => { + const user = factory.userAdmin({ profileImagePath: '/path/to/profile.jpg' }); const file = { path: '/profile/path' } as Express.Multer.File; - const files = [userStub.profilePath.profileImagePath]; + const files = [user.profileImagePath]; - mocks.user.get.mockResolvedValue(userStub.profilePath); + mocks.user.get.mockResolvedValue(user); mocks.user.update.mockResolvedValue({ ...userStub.admin, profileImagePath: file.path }); await sut.createProfileImage(authStub.admin, file); @@ -149,8 +149,10 @@ describe(UserService.name, () => { }); it('should delete the profile image if user has one', async () => { - mocks.user.get.mockResolvedValue(userStub.profilePath); - const files = [userStub.profilePath.profileImagePath]; + const user = factory.userAdmin({ profileImagePath: '/path/to/profile.jpg' }); + const files = [user.profileImagePath]; + + mocks.user.get.mockResolvedValue(user); await sut.deleteProfileImage(authStub.admin); @@ -176,9 +178,10 @@ describe(UserService.name, () => { }); it('should return the profile picture', async () => { - mocks.user.get.mockResolvedValue(userStub.profilePath); + const user = factory.userAdmin({ profileImagePath: '/path/to/profile.jpg' }); + mocks.user.get.mockResolvedValue(user); - await expect(sut.getProfileImage(userStub.profilePath.id)).resolves.toEqual( + await expect(sut.getProfileImage(user.id)).resolves.toEqual( new ImmichFileResponse({ path: '/path/to/profile.jpg', contentType: 'image/jpeg', @@ -186,7 +189,7 @@ describe(UserService.name, () => { }), ); - expect(mocks.user.get).toHaveBeenCalledWith(userStub.profilePath.id, {}); + expect(mocks.user.get).toHaveBeenCalledWith(user.id, {}); }); }); @@ -214,7 +217,7 @@ describe(UserService.name, () => { describe('handleUserDelete', () => { it('should skip users not ready for deletion', async () => { - const user = { id: 'user-1', deletedAt: makeDeletedAt(5) } as UserEntity; + const user = { id: 'user-1', deletedAt: makeDeletedAt(5) } as UserAdmin; mocks.user.get.mockResolvedValue(user); @@ -225,7 +228,7 @@ describe(UserService.name, () => { }); it('should delete the user and associated assets', async () => { - const user = { id: 'deleted-user', deletedAt: makeDeletedAt(10) } as UserEntity; + const user = { id: 'deleted-user', deletedAt: makeDeletedAt(10) } as UserAdmin; const options = { force: true, recursive: true }; mocks.user.get.mockResolvedValue(user); @@ -242,7 +245,7 @@ describe(UserService.name, () => { }); it('should delete the library path for a storage label', async () => { - const user = { id: 'deleted-user', deletedAt: makeDeletedAt(10), storageLabel: 'admin' } as UserEntity; + const user = { id: 'deleted-user', deletedAt: makeDeletedAt(10), storageLabel: 'admin' } as UserAdmin; mocks.user.get.mockResolvedValue(user); diff --git a/server/src/services/user.service.ts b/server/src/services/user.service.ts index d1859ed419..327328eb1c 100644 --- a/server/src/services/user.service.ts +++ b/server/src/services/user.service.ts @@ -1,4 +1,5 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { Updateable } from 'kysely'; import { DateTime } from 'luxon'; import { SALT_ROUNDS } from 'src/constants'; import { StorageCore } from 'src/cores/storage.core'; @@ -8,9 +9,9 @@ import { LicenseKeyDto, LicenseResponseDto } from 'src/dtos/license.dto'; import { UserPreferencesResponseDto, UserPreferencesUpdateDto, mapPreferences } from 'src/dtos/user-preferences.dto'; import { CreateProfileImageResponseDto } from 'src/dtos/user-profile.dto'; import { UserAdminResponseDto, UserResponseDto, UserUpdateMeDto, mapUser, mapUserAdmin } from 'src/dtos/user.dto'; -import { UserEntity } from 'src/entities/user.entity'; import { CacheControl, JobName, JobStatus, QueueName, StorageFolder, UserMetadataKey } from 'src/enum'; import { UserFindOptions } from 'src/repositories/user.repository'; +import { UserTable } from 'src/schema/tables/user.table'; import { BaseService } from 'src/services/base.service'; import { JobOf, UserMetadataItem } from 'src/types'; import { ImmichFileResponse } from 'src/utils/file'; @@ -49,7 +50,7 @@ export class UserService extends BaseService { } } - const update: Partial = { + const update: Updateable = { email: dto.email, name: dto.name, }; @@ -229,7 +230,7 @@ export class UserService extends BaseService { return JobStatus.SUCCESS; } - private isReadyForDeletion(user: UserEntity, deleteDelay: number): boolean { + private isReadyForDeletion(user: { id: string; deletedAt?: Date | null }, deleteDelay: number): boolean { if (!user.deletedAt) { return false; } diff --git a/server/src/utils/asset.util.ts b/server/src/utils/asset.util.ts index 575cbb4a21..a15f006cda 100644 --- a/server/src/utils/asset.util.ts +++ b/server/src/utils/asset.util.ts @@ -1,9 +1,9 @@ import { BadRequestException } from '@nestjs/common'; import { GeneratedImageType, StorageCore } from 'src/cores/storage.core'; +import { AssetFile } from 'src/database'; import { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto'; import { UploadFieldName } from 'src/dtos/asset-media.dto'; import { AuthDto } from 'src/dtos/auth.dto'; -import { AssetFileEntity } from 'src/entities/asset-files.entity'; import { AssetFileType, AssetType, Permission } from 'src/enum'; import { AuthRequest } from 'src/middleware/auth.guard'; import { AccessRepository } from 'src/repositories/access.repository'; @@ -20,7 +20,7 @@ export const getAssetFile = ( return (files || []).find((file) => file.type === type); }; -export const getAssetFiles = (files: AssetFileEntity[]) => ({ +export const getAssetFiles = (files: AssetFile[]) => ({ fullsizeFile: getAssetFile(files, AssetFileType.FULLSIZE), previewFile: getAssetFile(files, AssetFileType.PREVIEW), thumbnailFile: getAssetFile(files, AssetFileType.THUMBNAIL), diff --git a/server/src/utils/database.ts b/server/src/utils/database.ts index 8e07f388a0..69e4acaf02 100644 --- a/server/src/utils/database.ts +++ b/server/src/utils/database.ts @@ -1,4 +1,4 @@ -import { Expression, sql } from 'kysely'; +import { Expression, ExpressionBuilder, ExpressionWrapper, Nullable, Selectable, Simplify, sql } from 'kysely'; export const asUuid = (id: string | Expression) => sql`${id}::uuid`; @@ -17,3 +17,25 @@ export const removeUndefinedKeys = (update: T, template: unkno return update; }; + +/** Modifies toJson return type to not set all properties as nullable */ +export function toJson>( + eb: ExpressionBuilder, + table: T, +) { + return eb.fn.toJson(table) as ExpressionWrapper< + DB, + TB, + Simplify< + T extends TB + ? Selectable extends Nullable + ? N | null + : Selectable + : T extends Expression + ? O extends Nullable + ? N | null + : O + : never + > + >; +} diff --git a/server/src/utils/mime-types.spec.ts b/server/src/utils/mime-types.spec.ts index 6c2f92c2ee..12587eff37 100644 --- a/server/src/utils/mime-types.spec.ts +++ b/server/src/utils/mime-types.spec.ts @@ -101,6 +101,20 @@ describe('mimeTypes', () => { }); } + describe('toExtension', () => { + it('should get an extension for a png file', () => { + expect(mimeTypes.toExtension('image/png')).toEqual('.png'); + }); + + it('should get an extension for a jpeg file', () => { + expect(mimeTypes.toExtension('image/jpeg')).toEqual('.jpg'); + }); + + it('should get an extension from a webp file', () => { + expect(mimeTypes.toExtension('image/webp')).toEqual('.webp'); + }); + }); + describe('profile', () => { it('should contain only lowercase mime types', () => { const keys = Object.keys(mimeTypes.profile); diff --git a/server/src/utils/mime-types.ts b/server/src/utils/mime-types.ts index 7beeb91b67..b1a9c77588 100644 --- a/server/src/utils/mime-types.ts +++ b/server/src/utils/mime-types.ts @@ -55,6 +55,10 @@ const image: Record = { '.webp': ['image/webp'], }; +const extensionOverrides: Record = { + 'image/jpeg': '.jpg', +}; + /** * list of supported image extensions from https://developer.mozilla.org/en-US/docs/Web/Media/Formats/Image_types excluding svg * @TODO share with the client @@ -104,6 +108,11 @@ const types = { ...image, ...video, ...sidecar }; const isType = (filename: string, r: Record) => extname(filename).toLowerCase() in r; const lookup = (filename: string) => types[extname(filename).toLowerCase()]?.[0] ?? 'application/octet-stream'; +const toExtension = (mimeType: string) => { + return ( + extensionOverrides[mimeType] || Object.entries(types).find(([, mimeTypes]) => mimeTypes.includes(mimeType))?.[0] + ); +}; export const mimeTypes = { image, @@ -120,6 +129,8 @@ export const mimeTypes = { isVideo: (filename: string) => isType(filename, video), isRaw: (filename: string) => isType(filename, raw), lookup, + /** return an extension (including a leading `.`) for a mime-type */ + toExtension, assetType: (filename: string) => { const contentType = lookup(filename); if (contentType.startsWith('image/')) { diff --git a/server/test/fixtures/album.stub.ts b/server/test/fixtures/album.stub.ts index 3d2899d3c6..5a1c141512 100644 --- a/server/test/fixtures/album.stub.ts +++ b/server/test/fixtures/album.stub.ts @@ -38,10 +38,7 @@ export const albumStub = { albumUsers: [ { user: userStub.user1, - album: undefined as unknown as AlbumEntity, role: AlbumUserRole.EDITOR, - userId: userStub.user1.id, - albumId: 'album-2', }, ], isActivityEnabled: true, @@ -63,17 +60,11 @@ export const albumStub = { albumUsers: [ { user: userStub.user1, - album: undefined as unknown as AlbumEntity, role: AlbumUserRole.EDITOR, - userId: userStub.user1.id, - albumId: 'album-3', }, { user: userStub.user2, - album: undefined as unknown as AlbumEntity, role: AlbumUserRole.EDITOR, - userId: userStub.user2.id, - albumId: 'album-3', }, ], isActivityEnabled: true, @@ -95,10 +86,7 @@ export const albumStub = { albumUsers: [ { user: userStub.admin, - album: undefined as unknown as AlbumEntity, role: AlbumUserRole.EDITOR, - userId: userStub.admin.id, - albumId: 'album-3', }, ], isActivityEnabled: true, diff --git a/server/test/fixtures/asset.stub.ts b/server/test/fixtures/asset.stub.ts index 72016e9862..b388c31e73 100644 --- a/server/test/fixtures/asset.stub.ts +++ b/server/test/fixtures/asset.stub.ts @@ -1,6 +1,5 @@ -import { AssetFileEntity } from 'src/entities/asset-files.entity'; +import { AssetFile, Exif } from 'src/database'; import { AssetEntity } from 'src/entities/asset.entity'; -import { ExifEntity } from 'src/entities/exif.entity'; import { StackEntity } from 'src/entities/stack.entity'; import { AssetFileType, AssetStatus, AssetType } from 'src/enum'; import { StorageAsset } from 'src/types'; @@ -8,40 +7,30 @@ import { authStub } from 'test/fixtures/auth.stub'; import { fileStub } from 'test/fixtures/file.stub'; import { userStub } from 'test/fixtures/user.stub'; -const previewFile: AssetFileEntity = { +export const previewFile: AssetFile = { id: 'file-1', - assetId: 'asset-id', type: AssetFileType.PREVIEW, path: '/uploads/user-id/thumbs/path.jpg', - createdAt: new Date('2023-02-23T05:06:29.716Z'), - updatedAt: new Date('2023-02-23T05:06:29.716Z'), }; -const thumbnailFile: AssetFileEntity = { +const thumbnailFile: AssetFile = { id: 'file-2', - assetId: 'asset-id', type: AssetFileType.THUMBNAIL, path: '/uploads/user-id/webp/path.ext', - createdAt: new Date('2023-02-23T05:06:29.716Z'), - updatedAt: new Date('2023-02-23T05:06:29.716Z'), }; -const fullsizeFile: AssetFileEntity = { +const fullsizeFile: AssetFile = { id: 'file-3', - assetId: 'asset-id', type: AssetFileType.FULLSIZE, path: '/uploads/user-id/fullsize/path.webp', - createdAt: new Date('2023-02-23T05:06:29.716Z'), - updatedAt: new Date('2023-02-23T05:06:29.716Z'), }; -const files: AssetFileEntity[] = [fullsizeFile, previewFile, thumbnailFile]; +const files: AssetFile[] = [fullsizeFile, previewFile, thumbnailFile]; export const stackStub = (stackId: string, assets: AssetEntity[]): StackEntity => { return { id: stackId, assets, - owner: assets[0].owner, ownerId: assets[0].ownerId, primaryAsset: assets[0], primaryAssetId: assets[0].id, @@ -129,7 +118,7 @@ export const assetStub = { isExternal: false, exifInfo: { fileSizeInByte: 123_000, - } as ExifEntity, + } as Exif, deletedAt: null, duplicateId: null, isOffline: false, @@ -203,7 +192,7 @@ export const assetStub = { fileSizeInByte: 5000, exifImageHeight: 1000, exifImageWidth: 1000, - } as ExifEntity, + } as Exif, stackId: 'stack-1', stack: stackStub('stack-1', [ { id: 'primary-asset-id' } as AssetEntity, @@ -248,7 +237,7 @@ export const assetStub = { fileSizeInByte: 5000, exifImageHeight: 3840, exifImageWidth: 2160, - } as ExifEntity, + } as Exif, duplicateId: null, isOffline: false, }), @@ -286,7 +275,7 @@ export const assetStub = { fileSizeInByte: 5000, exifImageHeight: 3840, exifImageWidth: 2160, - } as ExifEntity, + } as Exif, duplicateId: null, isOffline: false, status: AssetStatus.TRASHED, @@ -327,7 +316,7 @@ export const assetStub = { fileSizeInByte: 5000, exifImageHeight: 3840, exifImageWidth: 2160, - } as ExifEntity, + } as Exif, duplicateId: null, isOffline: true, }), @@ -365,7 +354,7 @@ export const assetStub = { fileSizeInByte: 5000, exifImageHeight: 3840, exifImageWidth: 2160, - } as ExifEntity, + } as Exif, duplicateId: null, isOffline: false, }), @@ -403,7 +392,7 @@ export const assetStub = { sidecarPath: null, exifInfo: { fileSizeInByte: 5000, - } as ExifEntity, + } as Exif, duplicateId: null, isOffline: false, }), @@ -440,7 +429,7 @@ export const assetStub = { sidecarPath: null, exifInfo: { fileSizeInByte: 5000, - } as ExifEntity, + } as Exif, duplicateId: null, isOffline: false, }), @@ -476,7 +465,7 @@ export const assetStub = { sidecarPath: null, exifInfo: { fileSizeInByte: 5000, - } as ExifEntity, + } as Exif, deletedAt: null, duplicateId: null, isOffline: false, @@ -515,7 +504,7 @@ export const assetStub = { fileSizeInByte: 100_000, exifImageHeight: 2160, exifImageWidth: 3840, - } as ExifEntity, + } as Exif, deletedAt: null, duplicateId: null, isOffline: false, @@ -606,7 +595,7 @@ export const assetStub = { city: 'test-city', state: 'test-state', country: 'test-country', - } as ExifEntity, + } as Exif, deletedAt: null, duplicateId: null, isOffline: false, @@ -711,7 +700,7 @@ export const assetStub = { sidecarPath: null, exifInfo: { fileSizeInByte: 100_000, - } as ExifEntity, + } as Exif, deletedAt: null, duplicateId: null, isOffline: false, @@ -750,7 +739,7 @@ export const assetStub = { sidecarPath: null, exifInfo: { fileSizeInByte: 5000, - } as ExifEntity, + } as Exif, duplicateId: null, isOffline: false, }), @@ -789,7 +778,7 @@ export const assetStub = { fileSizeInByte: 5000, profileDescription: 'Adobe RGB', bitsPerSample: 14, - } as ExifEntity, + } as Exif, duplicateId: null, isOffline: false, }), @@ -828,7 +817,7 @@ export const assetStub = { fileSizeInByte: 5000, profileDescription: 'Adobe RGB', bitsPerSample: 14, - } as ExifEntity, + } as Exif, duplicateId: null, isOffline: false, }), diff --git a/server/test/fixtures/auth.stub.ts b/server/test/fixtures/auth.stub.ts index f5fbe07b53..dfa21fc707 100644 --- a/server/test/fixtures/auth.stub.ts +++ b/server/test/fixtures/auth.stub.ts @@ -52,24 +52,4 @@ export const authStub = { key: Buffer.from('shared-link-key'), } as SharedLinkEntity, }), - adminSharedLinkNoExif: Object.freeze({ - user: authUser.admin, - sharedLink: { - id: '123', - showExif: false, - allowDownload: true, - allowUpload: true, - key: Buffer.from('shared-link-key'), - } as SharedLinkEntity, - }), - passwordSharedLink: Object.freeze({ - user: authUser.admin, - sharedLink: { - id: '123', - allowUpload: false, - allowDownload: false, - password: 'password-123', - showExif: true, - } as SharedLinkEntity, - }), }; diff --git a/server/test/fixtures/face.stub.ts b/server/test/fixtures/face.stub.ts index 37fab86962..fe5cbb9a56 100644 --- a/server/test/fixtures/face.stub.ts +++ b/server/test/fixtures/face.stub.ts @@ -1,15 +1,17 @@ -import { AssetFaceEntity } from 'src/entities/asset-face.entity'; import { SourceType } from 'src/enum'; import { assetStub } from 'test/fixtures/asset.stub'; import { personStub } from 'test/fixtures/person.stub'; -type NonNullableProperty = { [P in keyof T]: NonNullable }; - export const faceStub = { - face1: Object.freeze>({ + face1: Object.freeze({ id: 'assetFaceId1', assetId: assetStub.image.id, - asset: assetStub.image, + asset: { + ...assetStub.image, + libraryId: null, + updateId: '0d1173e3-4d80-4d76-b41e-57d56de21125', + stackId: null, + }, personId: personStub.withName.id, person: personStub.withName, boundingBoxX1: 0, @@ -22,7 +24,7 @@ export const faceStub = { faceSearch: { faceId: 'assetFaceId1', embedding: '[1, 2, 3, 4]' }, deletedAt: new Date(), }), - primaryFace1: Object.freeze({ + primaryFace1: Object.freeze({ id: 'assetFaceId2', assetId: assetStub.image.id, asset: assetStub.image, @@ -38,7 +40,7 @@ export const faceStub = { faceSearch: { faceId: 'assetFaceId2', embedding: '[1, 2, 3, 4]' }, deletedAt: null, }), - mergeFace1: Object.freeze({ + mergeFace1: Object.freeze({ id: 'assetFaceId3', assetId: assetStub.image.id, asset: assetStub.image, @@ -54,55 +56,7 @@ export const faceStub = { faceSearch: { faceId: 'assetFaceId3', embedding: '[1, 2, 3, 4]' }, deletedAt: null, }), - start: Object.freeze({ - id: 'assetFaceId5', - assetId: assetStub.image.id, - asset: assetStub.image, - personId: personStub.newThumbnail.id, - person: personStub.newThumbnail, - boundingBoxX1: 5, - boundingBoxY1: 5, - boundingBoxX2: 505, - boundingBoxY2: 505, - imageHeight: 2880, - imageWidth: 2160, - sourceType: SourceType.MACHINE_LEARNING, - faceSearch: { faceId: 'assetFaceId5', embedding: '[1, 2, 3, 4]' }, - deletedAt: null, - }), - middle: Object.freeze({ - id: 'assetFaceId6', - assetId: assetStub.image.id, - asset: assetStub.image, - personId: personStub.newThumbnail.id, - person: personStub.newThumbnail, - boundingBoxX1: 100, - boundingBoxY1: 100, - boundingBoxX2: 200, - boundingBoxY2: 200, - imageHeight: 500, - imageWidth: 400, - sourceType: SourceType.MACHINE_LEARNING, - faceSearch: { faceId: 'assetFaceId6', embedding: '[1, 2, 3, 4]' }, - deletedAt: null, - }), - end: Object.freeze({ - id: 'assetFaceId7', - assetId: assetStub.image.id, - asset: assetStub.image, - personId: personStub.newThumbnail.id, - person: personStub.newThumbnail, - boundingBoxX1: 300, - boundingBoxY1: 300, - boundingBoxX2: 495, - boundingBoxY2: 495, - imageHeight: 500, - imageWidth: 500, - sourceType: SourceType.MACHINE_LEARNING, - faceSearch: { faceId: 'assetFaceId7', embedding: '[1, 2, 3, 4]' }, - deletedAt: null, - }), - noPerson1: Object.freeze({ + noPerson1: Object.freeze({ id: 'assetFaceId8', assetId: assetStub.image.id, asset: assetStub.image, @@ -118,7 +72,7 @@ export const faceStub = { faceSearch: { faceId: 'assetFaceId8', embedding: '[1, 2, 3, 4]' }, deletedAt: null, }), - noPerson2: Object.freeze({ + noPerson2: Object.freeze({ id: 'assetFaceId9', assetId: assetStub.image.id, asset: assetStub.image, @@ -134,7 +88,7 @@ export const faceStub = { faceSearch: { faceId: 'assetFaceId9', embedding: '[1, 2, 3, 4]' }, deletedAt: null, }), - fromExif1: Object.freeze({ + fromExif1: Object.freeze({ id: 'assetFaceId9', assetId: assetStub.image.id, asset: assetStub.image, @@ -149,7 +103,7 @@ export const faceStub = { sourceType: SourceType.EXIF, deletedAt: null, }), - fromExif2: Object.freeze({ + fromExif2: Object.freeze({ id: 'assetFaceId9', assetId: assetStub.image.id, asset: assetStub.image, @@ -164,7 +118,7 @@ export const faceStub = { sourceType: SourceType.EXIF, deletedAt: null, }), - withBirthDate: Object.freeze({ + withBirthDate: Object.freeze({ id: 'assetFaceId10', assetId: assetStub.image.id, asset: assetStub.image, diff --git a/server/test/fixtures/metadata.stub.ts b/server/test/fixtures/metadata.stub.ts deleted file mode 100644 index e60d8d0eac..0000000000 --- a/server/test/fixtures/metadata.stub.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { ImmichTags } from 'src/repositories/metadata.repository'; -import { personStub } from 'test/fixtures/person.stub'; - -export const metadataStub = { - empty: Object.freeze({}), - withFace: Object.freeze({ - RegionInfo: { - AppliedToDimensions: { - W: 100, - H: 100, - Unit: 'normalized', - }, - RegionList: [ - { - Type: 'face', - Name: personStub.withName.name, - Area: { - X: 0.05, - Y: 0.05, - W: 0.1, - H: 0.1, - Unit: 'normalized', - }, - }, - ], - }, - }), - withFaceEmptyName: Object.freeze({ - RegionInfo: { - AppliedToDimensions: { - W: 100, - H: 100, - Unit: 'normalized', - }, - RegionList: [ - { - Type: 'face', - Name: '', - Area: { - X: 0.05, - Y: 0.05, - W: 0.1, - H: 0.1, - Unit: 'normalized', - }, - }, - ], - }, - }), - withFaceNoName: Object.freeze({ - RegionInfo: { - AppliedToDimensions: { - W: 100, - H: 100, - Unit: 'normalized', - }, - RegionList: [ - { - Type: 'face', - Area: { - X: 0.05, - Y: 0.05, - W: 0.1, - H: 0.1, - Unit: 'normalized', - }, - }, - ], - }, - }), -}; diff --git a/server/test/fixtures/person.stub.ts b/server/test/fixtures/person.stub.ts index ecd5b0dbea..8457f9ddcd 100644 --- a/server/test/fixtures/person.stub.ts +++ b/server/test/fixtures/person.stub.ts @@ -1,13 +1,16 @@ -import { PersonEntity } from 'src/entities/person.entity'; +import { AssetType } from 'src/enum'; +import { previewFile } from 'test/fixtures/asset.stub'; import { userStub } from 'test/fixtures/user.stub'; +const updateId = '0d1173e3-4d80-4d76-b41e-57d56de21125'; + export const personStub = { - noName: Object.freeze({ + noName: Object.freeze({ id: 'person-1', createdAt: new Date('2021-01-01'), updatedAt: new Date('2021-01-01'), + updateId, ownerId: userStub.admin.id, - owner: userStub.admin, name: '', birthDate: null, thumbnailPath: '/path/to/thumbnail.jpg', @@ -16,13 +19,14 @@ export const personStub = { faceAsset: null, isHidden: false, isFavorite: false, + color: 'red', }), - hidden: Object.freeze({ + hidden: Object.freeze({ id: 'person-1', createdAt: new Date('2021-01-01'), updatedAt: new Date('2021-01-01'), + updateId, ownerId: userStub.admin.id, - owner: userStub.admin, name: '', birthDate: null, thumbnailPath: '/path/to/thumbnail.jpg', @@ -31,13 +35,14 @@ export const personStub = { faceAsset: null, isHidden: true, isFavorite: false, + color: 'red', }), - withName: Object.freeze({ + withName: Object.freeze({ id: 'person-1', createdAt: new Date('2021-01-01'), updatedAt: new Date('2021-01-01'), + updateId, ownerId: userStub.admin.id, - owner: userStub.admin, name: 'Person 1', birthDate: null, thumbnailPath: '/path/to/thumbnail.jpg', @@ -46,28 +51,30 @@ export const personStub = { faceAsset: null, isHidden: false, isFavorite: false, + color: 'red', }), - withBirthDate: Object.freeze({ + withBirthDate: Object.freeze({ id: 'person-1', createdAt: new Date('2021-01-01'), updatedAt: new Date('2021-01-01'), + updateId, ownerId: userStub.admin.id, - owner: userStub.admin, name: 'Person 1', - birthDate: '1976-06-30', + birthDate: new Date('1976-06-30'), thumbnailPath: '/path/to/thumbnail.jpg', faces: [], faceAssetId: null, faceAsset: null, isHidden: false, isFavorite: false, + color: 'red', }), - noThumbnail: Object.freeze({ + noThumbnail: Object.freeze({ id: 'person-1', createdAt: new Date('2021-01-01'), updatedAt: new Date('2021-01-01'), + updateId, ownerId: userStub.admin.id, - owner: userStub.admin, name: '', birthDate: null, thumbnailPath: '', @@ -76,13 +83,14 @@ export const personStub = { faceAsset: null, isHidden: false, isFavorite: false, + color: 'red', }), - newThumbnail: Object.freeze({ + newThumbnail: Object.freeze({ id: 'person-1', createdAt: new Date('2021-01-01'), updatedAt: new Date('2021-01-01'), + updateId, ownerId: userStub.admin.id, - owner: userStub.admin, name: '', birthDate: null, thumbnailPath: '/new/path/to/thumbnail.jpg', @@ -91,13 +99,14 @@ export const personStub = { faceAsset: null, isHidden: false, isFavorite: false, + color: 'red', }), - primaryPerson: Object.freeze({ + primaryPerson: Object.freeze({ id: 'person-1', createdAt: new Date('2021-01-01'), updatedAt: new Date('2021-01-01'), + updateId, ownerId: userStub.admin.id, - owner: userStub.admin, name: 'Person 1', birthDate: null, thumbnailPath: '/path/to/thumbnail', @@ -106,13 +115,14 @@ export const personStub = { faceAsset: null, isHidden: false, isFavorite: false, + color: 'red', }), - mergePerson: Object.freeze({ + mergePerson: Object.freeze({ id: 'person-2', createdAt: new Date('2021-01-01'), updatedAt: new Date('2021-01-01'), + updateId, ownerId: userStub.admin.id, - owner: userStub.admin, name: 'Person 2', birthDate: null, thumbnailPath: '/path/to/thumbnail', @@ -121,13 +131,14 @@ export const personStub = { faceAsset: null, isHidden: false, isFavorite: false, + color: 'red', }), - randomPerson: Object.freeze({ + randomPerson: Object.freeze({ id: 'person-3', createdAt: new Date('2021-01-01'), updatedAt: new Date('2021-01-01'), + updateId, ownerId: userStub.admin.id, - owner: userStub.admin, name: '', birthDate: null, thumbnailPath: '/path/to/thumbnail', @@ -136,13 +147,14 @@ export const personStub = { faceAsset: null, isHidden: false, isFavorite: false, + color: 'red', }), - isFavorite: Object.freeze({ + isFavorite: Object.freeze({ id: 'person-4', createdAt: new Date('2021-01-01'), updatedAt: new Date('2021-01-01'), + updateId, ownerId: userStub.admin.id, - owner: userStub.admin, name: 'Person 1', birthDate: null, thumbnailPath: '/path/to/thumbnail.jpg', @@ -151,5 +163,51 @@ export const personStub = { faceAsset: null, isHidden: false, isFavorite: true, + color: 'red', + }), +}; + +export const personThumbnailStub = { + newThumbnailStart: Object.freeze({ + ownerId: userStub.admin.id, + x1: 5, + y1: 5, + x2: 505, + y2: 505, + oldHeight: 2880, + oldWidth: 2160, + type: AssetType.IMAGE, + originalPath: '/original/path.jpg', + exifImageHeight: 3840, + exifImageWidth: 2160, + previewPath: previewFile.path, + }), + newThumbnailMiddle: Object.freeze({ + ownerId: userStub.admin.id, + x1: 100, + y1: 100, + x2: 200, + y2: 200, + oldHeight: 500, + oldWidth: 400, + type: AssetType.IMAGE, + originalPath: '/original/path.jpg', + exifImageHeight: 1000, + exifImageWidth: 1000, + previewPath: previewFile.path, + }), + newThumbnailEnd: Object.freeze({ + ownerId: userStub.admin.id, + x1: 300, + y1: 300, + x2: 495, + y2: 495, + oldHeight: 500, + oldWidth: 500, + type: AssetType.IMAGE, + originalPath: '/original/path.jpg', + exifImageHeight: 1000, + exifImageWidth: 1000, + previewPath: previewFile.path, }), }; diff --git a/server/test/fixtures/shared-link.stub.ts b/server/test/fixtures/shared-link.stub.ts index 739d6c5b93..be69147e7a 100644 --- a/server/test/fixtures/shared-link.stub.ts +++ b/server/test/fixtures/shared-link.stub.ts @@ -1,10 +1,10 @@ +import { UserAdmin } from 'src/database'; import { AlbumResponseDto } from 'src/dtos/album.dto'; import { AssetResponseDto } from 'src/dtos/asset-response.dto'; import { ExifResponseDto } from 'src/dtos/exif.dto'; import { SharedLinkResponseDto } from 'src/dtos/shared-link.dto'; import { mapUser } from 'src/dtos/user.dto'; import { SharedLinkEntity } from 'src/entities/shared-link.entity'; -import { UserEntity } from 'src/entities/user.entity'; import { AssetOrder, AssetStatus, AssetType, SharedLinkType } from 'src/enum'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; @@ -106,7 +106,6 @@ export const sharedLinkStub = { individual: Object.freeze({ id: '123', userId: authStub.admin.user.id, - user: userStub.admin, key: sharedLinkBytes, type: SharedLinkType.INDIVIDUAL, createdAt: today, @@ -154,7 +153,6 @@ export const sharedLinkStub = { readonlyNoExif: Object.freeze({ id: '123', userId: authStub.admin.user.id, - user: userStub.admin, key: sharedLinkBytes, type: SharedLinkType.ALBUM, createdAt: today, @@ -185,7 +183,7 @@ export const sharedLinkStub = { { id: 'id_1', status: AssetStatus.ACTIVE, - owner: undefined as unknown as UserEntity, + owner: undefined as unknown as UserAdmin, ownerId: 'user_id_1', deviceAssetId: 'device_asset_id_1', deviceId: 'device_id_1', @@ -234,7 +232,6 @@ export const sharedLinkStub = { iso: 100, exposureTime: '1/16', fps: 100, - asset: null as any, profileDescription: 'sRGB', bitsPerSample: 8, colorspace: 'sRGB', @@ -253,7 +250,6 @@ export const sharedLinkStub = { passwordRequired: Object.freeze({ id: '123', userId: authStub.admin.user.id, - user: userStub.admin, key: sharedLinkBytes, type: SharedLinkType.ALBUM, createdAt: today, diff --git a/server/test/fixtures/user.stub.ts b/server/test/fixtures/user.stub.ts index 844b8c61b9..f0043d174a 100644 --- a/server/test/fixtures/user.stub.ts +++ b/server/test/fixtures/user.stub.ts @@ -1,13 +1,12 @@ -import { UserEntity } from 'src/entities/user.entity'; +import { UserAdmin } from 'src/database'; import { UserAvatarColor, UserMetadataKey, UserStatus } from 'src/enum'; import { authStub } from 'test/fixtures/auth.stub'; export const userStub = { - admin: Object.freeze({ + admin: { ...authStub.admin.user, status: UserStatus.ACTIVE, profileChangedAt: new Date('2021-01-01'), - password: 'admin_password', name: 'admin_name', id: 'admin_id', storageLabel: 'admin', @@ -17,16 +16,14 @@ export const userStub = { createdAt: new Date('2021-01-01'), deletedAt: null, updatedAt: new Date('2021-01-01'), - assets: [], metadata: [], quotaSizeInBytes: null, quotaUsageInBytes: 0, - }), - user1: Object.freeze({ + }, + user1: { ...authStub.user1.user, status: UserStatus.ACTIVE, profileChangedAt: new Date('2021-01-01'), - password: 'immich_password', name: 'immich_name', storageLabel: null, oauthId: '', @@ -35,7 +32,6 @@ export const userStub = { createdAt: new Date('2021-01-01'), deletedAt: null, updatedAt: new Date('2021-01-01'), - assets: [], metadata: [ { key: UserMetadataKey.PREFERENCES, @@ -44,13 +40,12 @@ export const userStub = { ], quotaSizeInBytes: null, quotaUsageInBytes: 0, - }), - user2: Object.freeze({ + }, + user2: { ...authStub.user2.user, status: UserStatus.ACTIVE, profileChangedAt: new Date('2021-01-01'), metadata: [], - password: 'immich_password', name: 'immich_name', storageLabel: null, oauthId: '', @@ -59,44 +54,7 @@ export const userStub = { createdAt: new Date('2021-01-01'), deletedAt: null, updatedAt: new Date('2021-01-01'), - assets: [], quotaSizeInBytes: null, quotaUsageInBytes: 0, - }), - storageLabel: Object.freeze({ - ...authStub.user1.user, - status: UserStatus.ACTIVE, - profileChangedAt: new Date('2021-01-01'), - metadata: [], - password: 'immich_password', - name: 'immich_name', - storageLabel: 'label-1', - oauthId: '', - shouldChangePassword: false, - profileImagePath: '', - createdAt: new Date('2021-01-01'), - deletedAt: null, - updatedAt: new Date('2021-01-01'), - assets: [], - quotaSizeInBytes: null, - quotaUsageInBytes: 0, - }), - profilePath: Object.freeze({ - ...authStub.user1.user, - status: UserStatus.ACTIVE, - profileChangedAt: new Date('2021-01-01'), - metadata: [], - password: 'immich_password', - name: 'immich_name', - storageLabel: 'label-1', - oauthId: '', - shouldChangePassword: false, - profileImagePath: '/path/to/profile.jpg', - createdAt: new Date('2021-01-01'), - deletedAt: null, - updatedAt: new Date('2021-01-01'), - assets: [], - quotaSizeInBytes: null, - quotaUsageInBytes: 0, - }), + }, }; diff --git a/server/test/repositories/person.repository.mock.ts b/server/test/repositories/person.repository.mock.ts index 80a6a25c74..59377576b1 100644 --- a/server/test/repositories/person.repository.mock.ts +++ b/server/test/repositories/person.repository.mock.ts @@ -14,7 +14,8 @@ export const newPersonRepositoryMock = (): Mocked { const authFactory = ({ apiKey, session, - ...user -}: Partial & { apiKey?: Partial; session?: { id: string } } = {}) => { + sharedLink, + user, +}: { + apiKey?: Partial; + session?: { id: string }; + user?: Partial; + sharedLink?: Partial; +} = {}) => { const auth: AuthDto = { - user: authUserFactory(user), + user: authUserFactory(userAdminFactory(user ?? {})), }; + const userId = auth.user.id; + if (apiKey) { auth.apiKey = authApiKeyFactory(apiKey); } @@ -49,24 +58,45 @@ const authFactory = ({ auth.session = { id: session.id }; } + if (sharedLink) { + auth.sharedLink = authSharedLinkFactory({ ...sharedLink, userId }); + } + return auth; }; +const authSharedLinkFactory = (sharedLink: Partial = {}) => { + const { + id = newUuid(), + expiresAt = null, + userId = newUuid(), + showExif = true, + allowUpload = false, + allowDownload = true, + password = null, + } = sharedLink; + + return { id, expiresAt, userId, showExif, allowUpload, allowDownload, password }; +}; + const authApiKeyFactory = (apiKey: Partial = {}) => ({ id: newUuid(), permissions: [Permission.ALL], ...apiKey, }); -const authUserFactory = (authUser: Partial = {}) => ({ - id: newUuid(), - isAdmin: false, - name: 'Test User', - email: 'test@immich.cloud', - quotaUsageInBytes: 0, - quotaSizeInBytes: null, - ...authUser, -}); +const authUserFactory = (authUser: Partial = {}) => { + const { + id = newUuid(), + isAdmin = false, + name = 'Test User', + email = 'test@immich.cloud', + quotaUsageInBytes = 0, + quotaSizeInBytes = null, + } = authUser; + + return { id, isAdmin, name, email, quotaUsageInBytes, quotaSizeInBytes }; +}; const partnerFactory = (partner: Partial = {}) => { const sharedBy = userFactory(partner.sharedBy || {}); @@ -112,25 +142,44 @@ const userFactory = (user: Partial = {}) => ({ ...user, }); -const userAdminFactory = (user: Partial = {}) => ({ - id: newUuid(), - name: 'Test User', - email: 'test@immich.cloud', - profileImagePath: '', - profileChangedAt: newDate(), - storageLabel: null, - shouldChangePassword: false, - isAdmin: false, - createdAt: newDate(), - updatedAt: newDate(), - deletedAt: null, - oauthId: '', - quotaSizeInBytes: null, - quotaUsageInBytes: 0, - status: UserStatus.ACTIVE, - metadata: [], - ...user, -}); +const userAdminFactory = (user: Partial = {}) => { + const { + id = newUuid(), + name = 'Test User', + email = 'test@immich.cloud', + profileImagePath = '', + profileChangedAt = newDate(), + storageLabel = null, + shouldChangePassword = false, + isAdmin = false, + createdAt = newDate(), + updatedAt = newDate(), + deletedAt = null, + oauthId = '', + quotaSizeInBytes = null, + quotaUsageInBytes = 0, + status = UserStatus.ACTIVE, + metadata = [], + } = user; + return { + id, + name, + email, + profileImagePath, + profileChangedAt, + storageLabel, + shouldChangePassword, + isAdmin, + createdAt, + updatedAt, + deletedAt, + oauthId, + quotaSizeInBytes, + quotaUsageInBytes, + status, + metadata, + }; +}; const assetFactory = (asset: Partial = {}) => ({ id: newUuid(), diff --git a/web/package-lock.json b/web/package-lock.json index 029d6aede5..8cb99c114c 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -9492,9 +9492,9 @@ } }, "node_modules/vite": { - "version": "6.2.5", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.2.5.tgz", - "integrity": "sha512-j023J/hCAa4pRIUH6J9HemwYfjB5llR2Ps0CWeikOtdR8+pAURAk0DoJC5/mm9kd+UgdnIy7d6HE4EAvlYhPhA==", + "version": "6.2.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.2.6.tgz", + "integrity": "sha512-9xpjNl3kR4rVDZgPNdTL0/c6ao4km69a/2ihNQbcANz8RuCOK3hQBmLSJf3bRKVQjVMda+YvizNE8AwvogcPbw==", "dev": true, "license": "MIT", "dependencies": { diff --git a/web/src/lib/components/album-page/album-shared-link.svelte b/web/src/lib/components/album-page/album-shared-link.svelte index 2b02eb8e07..b56aa11b6d 100644 --- a/web/src/lib/components/album-page/album-shared-link.svelte +++ b/web/src/lib/components/album-page/album-shared-link.svelte @@ -1,17 +1,20 @@ - - {#if Object.keys(selectedUsers).length > 0} -
-

{$t('selected')}

-
- {#each Object.values(selectedUsers) as { user } (user.id)} - {#key user.id} -
-
- -
+{#if sharedLinkUrl} + (sharedLinkUrl = '')} value={sharedLinkUrl} /> +{:else} + + {#if Object.keys(selectedUsers).length > 0} +
+

{$t('selected')}

+
+ {#each Object.values(selectedUsers) as { user } (user.id)} + {#key user.id} +
+
+ +
- -
-

- {user.name} -

-

- {user.email} -

-
- - ({ title, icon })} - onSelect={({ value }) => handleChangeRole(user, value)} - /> -
- {/key} - {/each} -
-
- {/if} - - {#if users.length + Object.keys(selectedUsers).length === 0} -

- {$t('album_share_no_users')} -

- {/if} - -
- {#if users.length > 0 && users.length !== Object.keys(selectedUsers).length} - {$t('users')} - -
- {#each users as user (user.id)} - {#if !Object.keys(selectedUsers).includes(user.id)} -
- -
- {/if} - {/each} + + ({ title, icon })} + onSelect={({ value }) => handleChangeRole(user, value)} + /> +
+ {/key} + {/each} +
{/if} -
- {#if users.length > 0} -
- + {#if users.length + Object.keys(selectedUsers).length === 0} +

+ {$t('album_share_no_users')} +

+ {/if} + +
+ {#if users.length > 0 && users.length !== Object.keys(selectedUsers).length} + {$t('users')} + +
+ {#each users as user (user.id)} + {#if !Object.keys(selectedUsers).includes(user.id)} +
+ +
+ {/if} + {/each} +
+ {/if}
- {/if} -
- - - {#if sharedLinks.length > 0} -
- {$t('shared_links')} - {$t('view_all')} + {#if users.length > 0} +
+
- - - {#each sharedLinks as sharedLink (sharedLink.id)} - - {/each} - {/if} - - - +
+ + + {#if sharedLinks.length > 0} +
+ {$t('shared_links')} + {$t('view_all')} +
+ + + {#each sharedLinks as sharedLink (sharedLink.id)} + handleViewQrCode(sharedLink)} /> + {/each} + + {/if} + + +
+ +{/if} diff --git a/web/src/lib/components/asset-viewer/download-panel.svelte b/web/src/lib/components/asset-viewer/download-panel.svelte index 17f5e7e6a8..80a14a5ac3 100644 --- a/web/src/lib/components/asset-viewer/download-panel.svelte +++ b/web/src/lib/components/asset-viewer/download-panel.svelte @@ -1,5 +1,5 @@ -{#if $isDownloading} +{#if downloadStore.isDownloading}

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

- {#each Object.keys($downloadAssets) as downloadKey (downloadKey)} - {@const download = $downloadAssets[downloadKey]} + {#each Object.keys(downloadStore.assets) as downloadKey (downloadKey)} + {@const download = downloadStore.assets[downloadKey]}
@@ -31,7 +31,7 @@ {/if}
-
+

diff --git a/web/src/lib/components/layouts/user-page-layout.svelte b/web/src/lib/components/layouts/user-page-layout.svelte index 556c468e95..d5f4d96ef9 100644 --- a/web/src/lib/components/layouts/user-page-layout.svelte +++ b/web/src/lib/components/layouts/user-page-layout.svelte @@ -51,7 +51,7 @@

{#if sidebar}{@render sidebar()}{:else if admin} diff --git a/web/src/lib/components/photos-page/asset-grid.svelte b/web/src/lib/components/photos-page/asset-grid.svelte index 3457d87fdf..3f9df8f0a7 100644 --- a/web/src/lib/components/photos-page/asset-grid.svelte +++ b/web/src/lib/components/photos-page/asset-grid.svelte @@ -6,7 +6,7 @@ import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { AssetBucket, assetsSnapshot, AssetStore, isSelectingAllAssets } from '$lib/stores/assets-store.svelte'; import { showDeleteModal } from '$lib/stores/preferences.store'; - import { isSearchEnabled } from '$lib/stores/search.store'; + import { searchStore } from '$lib/stores/search.svelte'; import { featureFlags } from '$lib/stores/server-config.store'; import { handlePromiseError } from '$lib/utils'; import { deleteAssets, updateStackedAssetInTimeline, updateUnstackedAssetInTimeline } from '$lib/utils/actions'; @@ -448,7 +448,7 @@ }; const onKeyDown = (event: KeyboardEvent) => { - if ($isSearchEnabled) { + if (searchStore.isSearchEnabled) { return; } @@ -459,7 +459,7 @@ }; const onKeyUp = (event: KeyboardEvent) => { - if ($isSearchEnabled) { + if (searchStore.isSearchEnabled) { return; } @@ -648,7 +648,7 @@ let shortcutList = $derived( (() => { - if ($isSearchEnabled || $showAssetViewer) { + if (searchStore.isSearchEnabled || $showAssetViewer) { return []; } diff --git a/web/src/lib/components/shared-components/create-share-link-modal/create-shared-link-modal.svelte b/web/src/lib/components/shared-components/create-share-link-modal/create-shared-link-modal.svelte index 19bef9c7db..a87ca3da4a 100644 --- a/web/src/lib/components/shared-components/create-share-link-modal/create-shared-link-modal.svelte +++ b/web/src/lib/components/shared-components/create-share-link-modal/create-shared-link-modal.svelte @@ -1,20 +1,20 @@ + + +
+
+
+ +
+
+ + +
+ (value ? copyToClipboard(value) : '')} + aria-label={$t('copy_link_to_clipboard')} + /> +
+
+
+
diff --git a/web/src/lib/components/shared-components/search-bar/search-bar.svelte b/web/src/lib/components/shared-components/search-bar/search-bar.svelte index 767b0e608d..2dc358bad3 100644 --- a/web/src/lib/components/shared-components/search-bar/search-bar.svelte +++ b/web/src/lib/components/shared-components/search-bar/search-bar.svelte @@ -1,7 +1,7 @@ -