Merge branch 'main' into feat/mobile-ocr

This commit is contained in:
Yaros
2026-04-13 18:07:43 +02:00
committed by GitHub
417 changed files with 13493 additions and 14010 deletions
+1 -1
View File
@@ -1 +1 @@
24.13.1
24.14.1
+2 -2
View File
@@ -1,4 +1,4 @@
FROM ghcr.io/immich-app/base-server-dev:202603031112@sha256:837536db5fd9e432f0f474ef9b61712fe3b3815821c3e4edf5e5b0b1f1ed30ad AS builder
FROM ghcr.io/immich-app/base-server-dev:202603251709@sha256:2bf3053c732fcb87ec90c3c614632ac44847423468ccc57fd935bff771828d9d AS builder
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
CI=1 \
COREPACK_HOME=/tmp \
@@ -71,7 +71,7 @@ RUN --mount=type=cache,id=pnpm-plugins,target=/buildcache/pnpm-store \
--mount=type=cache,id=mise-tools-${TARGETPLATFORM},target=/buildcache/mise \
cd plugins && mise run build
FROM ghcr.io/immich-app/base-server-prod:202603031112@sha256:bb8c8645ee61977140121e56ba09db7ae656a7506f9a6af1be8461b4d81fdf03
FROM ghcr.io/immich-app/base-server-prod:202603251709@sha256:17de30977ff87aa06758a56ad7f10d6b5c97bf9dab76e4ec4177a2a8d1b2b5f3
WORKDIR /usr/src/app
ENV NODE_ENV=production \
+1 -1
View File
@@ -1,5 +1,5 @@
# dev build
FROM ghcr.io/immich-app/base-server-dev:202603031112@sha256:837536db5fd9e432f0f474ef9b61712fe3b3815821c3e4edf5e5b0b1f1ed30ad AS dev
FROM ghcr.io/immich-app/base-server-dev:202603251709@sha256:2bf3053c732fcb87ec90c3c614632ac44847423468ccc57fd935bff771828d9d AS dev
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
CI=1 \
+6 -7
View File
@@ -15,13 +15,12 @@ log_message() {
log_message "Initializing Immich $IMMICH_SOURCE_REF"
# TODO: Update to mimalloc v3 when verified memory isn't released issue is fixed
# lib_path="/usr/lib/$(arch)-linux-gnu/libmimalloc.so.3"
# if [ -f "$lib_path" ]; then
# export LD_PRELOAD="$lib_path"
# else
# echo "skipping libmimalloc - path not found $lib_path"
# fi
lib_path="/usr/lib/$(arch)-linux-gnu/libmimalloc.so.3"
if [ -f "$lib_path" ]; then
export LD_PRELOAD="$lib_path"
else
echo "skipping libmimalloc - path not found $lib_path"
fi
export LD_LIBRARY_PATH="$LD_LIBRARY_PATH:/usr/lib/jellyfin-ffmpeg/lib"
SERVER_HOME="$(readlink -f "$(dirname "$0")/..")"
+21
View File
@@ -0,0 +1,21 @@
{
"contentSecurityPolicy": {
"directives": {
"default-src": ["'self'"],
"script-src": ["'self'", "'wasm-unsafe-eval'", "'unsafe-inline'", "https://www.gstatic.com"],
"style-src": ["'self'", "'unsafe-inline'"],
"img-src": ["'self'", "data:", "blob:"],
"connect-src": [
"'self'",
"blob:",
"https://pay.futo.org",
"https://static.immich.cloud",
"https://tiles.immich.cloud"
],
"worker-src": ["'self'", "blob:"],
"frame-src": ["'none'"],
"object-src": ["'none'"],
"base-uri": ["'self'"]
}
}
}
+14 -10
View File
@@ -1,10 +1,15 @@
{
"name": "immich",
"version": "2.6.2",
"version": "2.7.5",
"description": "",
"author": "",
"private": true,
"license": "GNU Affero General Public License version 3",
"files": [
"bin",
"dist",
"helmet.json"
],
"scripts": {
"build": "nest build",
"format": "prettier --cache --check .",
@@ -77,12 +82,13 @@
"fluent-ffmpeg": "^2.1.2",
"geo-tz": "^8.0.0",
"handlebars": "^4.7.8",
"helmet": "^8.1.0",
"i18n-iso-countries": "^7.6.0",
"ioredis": "^5.8.2",
"jose": "^5.10.0",
"js-yaml": "^4.1.0",
"jsonwebtoken": "^9.0.2",
"kysely": "0.28.11",
"kysely": "0.28.14",
"kysely-postgres-js": "^3.0.0",
"lodash": "^4.17.21",
"luxon": "^3.4.2",
@@ -92,7 +98,7 @@
"nestjs-cls": "^5.0.0",
"nestjs-kysely": "3.1.2",
"nestjs-otel": "^7.0.0",
"nodemailer": "^7.0.0",
"nodemailer": "^8.0.0",
"openid-client": "^6.3.3",
"pg": "^8.11.3",
"pg-connection-string": "^2.9.1",
@@ -104,7 +110,6 @@
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.1",
"sanitize-filename": "^1.6.3",
"sanitize-html": "^2.14.0",
"semver": "^7.6.2",
"sharp": "^0.34.5",
"sirv": "^3.0.0",
@@ -136,21 +141,20 @@
"@types/luxon": "^3.6.2",
"@types/mock-fs": "^4.13.1",
"@types/multer": "^2.0.0",
"@types/node": "^24.11.0",
"@types/node": "^24.12.0",
"@types/nodemailer": "^7.0.0",
"@types/picomatch": "^4.0.0",
"@types/pngjs": "^6.0.5",
"@types/react": "^19.0.0",
"@types/sanitize-html": "^2.13.0",
"@types/semver": "^7.5.8",
"@types/supertest": "^6.0.0",
"@types/supertest": "^7.0.0",
"@types/ua-parser-js": "^0.7.36",
"@types/validator": "^13.15.2",
"@vitest/coverage-v8": "^3.0.0",
"eslint": "^10.0.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-unicorn": "^63.0.0",
"eslint-plugin-unicorn": "^64.0.0",
"globals": "^17.0.0",
"mock-fs": "^5.2.0",
"node-gyp": "^12.0.0",
@@ -161,14 +165,14 @@
"supertest": "^7.1.0",
"tailwindcss": "^3.4.0",
"testcontainers": "^11.0.0",
"typescript": "^5.9.2",
"typescript": "^6.0.0",
"typescript-eslint": "^8.28.0",
"unplugin-swc": "^1.4.5",
"vite-tsconfig-paths": "^6.0.0",
"vitest": "^3.0.0"
},
"volta": {
"node": "24.13.1"
"node": "24.14.1"
},
"overrides": {
"sharp": "^0.34.5"
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+10 -3
View File
@@ -2,9 +2,10 @@ import { NestExpressApplication } from '@nestjs/platform-express';
import { json } from 'body-parser';
import compression from 'compression';
import cookieParser from 'cookie-parser';
import helmetMiddleware from 'helmet';
import { existsSync } from 'node:fs';
import sirv from 'sirv';
import { excludePaths, serverVersion } from 'src/constants';
import { IMMICH_SERVER_START, excludePaths, serverVersion } from 'src/constants';
import { MaintenanceWorkerService } from 'src/maintenance/maintenance-worker.service';
import { WebSocketAdapter } from 'src/middleware/websocket.adapter';
import { ConfigRepository } from 'src/repositories/config.repository';
@@ -39,7 +40,7 @@ export async function configureExpress(
},
) {
const configRepository = app.get(ConfigRepository);
const { environment, host, port, resourcePaths, network } = configRepository.getEnv();
const { environment, host, port, helmet, resourcePaths, network } = configRepository.getEnv();
const logger = await app.resolve(LoggingRepository);
logger.setContext('Bootstrap');
@@ -47,6 +48,12 @@ export async function configureExpress(
app.set('trust proxy', ['loopback', ...network.trustedProxies]);
app.set('etag', 'strong');
if (helmet.config) {
app.use(helmetMiddleware(helmet.config));
logger.log('Initialized helmet middleware');
}
app.use(cookieParser());
app.use(json({ limit: '10mb' }));
@@ -83,5 +90,5 @@ export async function configureExpress(
const server = await (host ? app.listen(port, host) : app.listen(port));
server.requestTimeout = 24 * 60 * 60 * 1000;
logger.log(`Immich Server is listening on ${await app.getUrl()} [v${serverVersion}] [${environment}] `);
logger.log(`${IMMICH_SERVER_START} on ${await app.getUrl()} [v${serverVersion}] [${environment}] `);
}
+5
View File
@@ -29,6 +29,7 @@ import { ProcessRepository } from 'src/repositories/process.repository';
import { StorageRepository } from 'src/repositories/storage.repository';
import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository';
import { teardownTelemetry, TelemetryRepository } from 'src/repositories/telemetry.repository';
import { UserRepository } from 'src/repositories/user.repository';
import { WebsocketRepository } from 'src/repositories/websocket.repository';
import { services } from 'src/services';
import { AuthService } from 'src/services/auth.service';
@@ -36,6 +37,7 @@ import { CliService } from 'src/services/cli.service';
import { DatabaseBackupService } from 'src/services/database-backup.service';
import { QueueService } from 'src/services/queue.service';
import { getKyselyConfig } from 'src/utils/database';
import { configureUserAgent } from 'src/utils/fetch';
const common = [...repositories, ...services, GlobalExceptionFilter];
@@ -59,6 +61,8 @@ const commonImports = [
const bullImports = [BullModule.forRoot(bull.config), BullModule.registerQueue(...bull.queues)];
configureUserAgent();
export class BaseModule implements OnModuleInit, OnModuleDestroy {
constructor(
@Inject(IWorker) private worker: ImmichWorker,
@@ -111,6 +115,7 @@ export class ApiModule extends BaseModule {}
StorageRepository,
ProcessRepository,
DatabaseRepository,
UserRepository,
SystemMetadataRepository,
AppRepository,
MaintenanceHealthRepository,
+2
View File
@@ -4,6 +4,8 @@ import { dirname, join } from 'node:path';
import { SemVer } from 'semver';
import { ApiTag, AudioCodec, DatabaseExtension, ExifOrientation, VectorIndex } from 'src/enum';
export const IMMICH_SERVER_START = 'Immich Server is listening';
export const ErrorMessages = {
InconsistentMediaLocation:
'Detected an inconsistent media location. For more information, see https://docs.immich.app/errors#inconsistent-media-location',
@@ -0,0 +1,47 @@
import { DuplicateController } from 'src/controllers/duplicate.controller';
import { DuplicateService } from 'src/services/duplicate.service';
import request from 'supertest';
import { factory } from 'test/small.factory';
import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils';
describe(DuplicateController.name, () => {
let ctx: ControllerContext;
const service = mockBaseService(DuplicateService);
beforeAll(async () => {
ctx = await controllerSetup(DuplicateController, [{ provide: DuplicateService, useValue: service }]);
return () => ctx.close();
});
beforeEach(() => {
service.resetAllMocks();
ctx.reset();
});
describe('GET /duplicates', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).get('/duplicates');
expect(ctx.authenticate).toHaveBeenCalled();
});
});
describe('DELETE /duplicates', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).delete('/duplicates');
expect(ctx.authenticate).toHaveBeenCalled();
});
});
describe('DELETE /duplicates/:id', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).delete(`/duplicates/${factory.uuid()}`);
expect(ctx.authenticate).toHaveBeenCalled();
});
it('should require a valid uuid', async () => {
const { status, body } = await request(ctx.getHttpServer()).delete(`/duplicates/123`);
expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest(['id must be a UUID']));
});
});
});
+15 -3
View File
@@ -1,9 +1,9 @@
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param } from '@nestjs/common';
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { DuplicateResponseDto } from 'src/dtos/duplicate.dto';
import { DuplicateResolveDto, DuplicateResponseDto } from 'src/dtos/duplicate.dto';
import { ApiTag, Permission } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { DuplicateService } from 'src/services/duplicate.service';
@@ -48,4 +48,16 @@ export class DuplicateController {
deleteDuplicate(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.delete(auth, id);
}
@Post('resolve')
@HttpCode(HttpStatus.OK)
@Authenticated({ permission: Permission.DuplicateDelete })
@Endpoint({
summary: 'Resolve duplicate groups',
description: 'Resolve duplicate groups by synchronizing metadata across assets and deleting/trashing duplicates.',
history: new HistoryBuilder().added('v3.0.0').alpha('v3.0.0'),
})
resolveDuplicates(@Auth() auth: AuthDto, @Body() dto: DuplicateResolveDto): Promise<BulkIdResponseDto[]> {
return this.service.resolve(auth, dto);
}
}
+4
View File
@@ -5,6 +5,7 @@ import {
AssetFileType,
AssetType,
AssetVisibility,
ChecksumAlgorithm,
MemoryType,
Permission,
PluginContext,
@@ -112,6 +113,7 @@ export type Memory = {
export type Asset = {
id: string;
checksum: Buffer<ArrayBufferLike>;
checksumAlgorithm: ChecksumAlgorithm;
deviceAssetId: string;
deviceId: string;
fileCreatedAt: Date;
@@ -330,6 +332,7 @@ export const columns = {
asset: [
'asset.id',
'asset.checksum',
'asset.checksumAlgorithm',
'asset.deviceAssetId',
'asset.deviceId',
'asset.fileCreatedAt',
@@ -345,6 +348,7 @@ export const columns = {
'asset.type',
'asset.width',
'asset.height',
'asset.isEdited',
],
assetFiles: ['asset_file.id', 'asset_file.path', 'asset_file.type', 'asset_file.isEdited'],
assetFilesForThumbnail: [
+8 -7
View File
@@ -73,7 +73,8 @@ export function Chunked(
const originalMethod = descriptor.value;
const parameterIndex = options.paramIndex ?? 0;
const chunkSize = options.chunkSize || DATABASE_PARAMETER_CHUNK_SIZE;
descriptor.value = async function (...arguments_: any[]) {
const mergeFn = options.mergeFn;
descriptor.value = function (...arguments_: any[]) {
const argument = arguments_[parameterIndex];
// Early return if argument length is less than or equal to the chunk size.
@@ -81,27 +82,27 @@ export function Chunked(
(Array.isArray(argument) && argument.length <= chunkSize) ||
(argument instanceof Set && argument.size <= chunkSize)
) {
return await originalMethod.apply(this, arguments_);
return originalMethod.apply(this, arguments_);
}
return Promise.all(
chunks(argument, chunkSize).map(async (chunk) => {
return await Reflect.apply(originalMethod, this, [
chunks(argument, chunkSize).map((chunk) => {
return Reflect.apply(originalMethod, this, [
...arguments_.slice(0, parameterIndex),
chunk,
...arguments_.slice(parameterIndex + 1),
]);
}),
).then((results) => (options.mergeFn ? options.mergeFn(results) : results));
).then((results) => (mergeFn ? mergeFn(results) : results));
};
};
}
export function ChunkedArray(options?: { paramIndex?: number }): MethodDecorator {
export function ChunkedArray(options?: { paramIndex?: number; chunkSize?: number }): MethodDecorator {
return Chunked({ ...options, mergeFn: _.flatten });
}
export function ChunkedSet(options?: { paramIndex?: number }): MethodDecorator {
export function ChunkedSet(options?: { paramIndex?: number; chunkSize?: number }): MethodDecorator {
return Chunked({ ...options, mergeFn: (args: Set<any>[]) => setUnion(...args) });
}
@@ -23,6 +23,7 @@ export enum BulkIdErrorReason {
NO_PERMISSION = 'no_permission',
NOT_FOUND = 'not_found',
UNKNOWN = 'unknown',
VALIDATION = 'validation',
}
export class BulkIdsDto {
@@ -37,4 +38,5 @@ export class BulkIdResponseDto {
success!: boolean;
@ApiPropertyOptional({ description: 'Error reason if failed', enum: BulkIdErrorReason })
error?: BulkIdErrorReason;
errorMessage?: string;
}
+2 -1
View File
@@ -13,7 +13,7 @@ import {
} from 'src/dtos/person.dto';
import { TagResponseDto, mapTag } from 'src/dtos/tag.dto';
import { UserResponseDto, mapUser } from 'src/dtos/user.dto';
import { AssetStatus, AssetType, AssetVisibility } from 'src/enum';
import { AssetStatus, AssetType, AssetVisibility, ChecksumAlgorithm } from 'src/enum';
import { ImageDimensions, MaybeDehydrated } from 'src/types';
import { getDimensions } from 'src/utils/asset.util';
import { hexOrBufferToBase64 } from 'src/utils/bytes';
@@ -148,6 +148,7 @@ export type MapAsset = {
updateId: string;
status: AssetStatus;
checksum: Buffer<ArrayBufferLike>;
checksumAlgorithm: ChecksumAlgorithm;
deviceAssetId: string;
deviceId: string;
duplicateId: string | null;
+1
View File
@@ -4,6 +4,7 @@ import { IsString } from 'class-validator';
export class DatabaseBackupDto {
filename!: string;
filesize!: number;
timezone!: string;
}
export class DatabaseBackupListResponseDto {
+26
View File
@@ -1,9 +1,35 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { ArrayMinSize, IsArray, ValidateNested } from 'class-validator';
import { AssetResponseDto } from 'src/dtos/asset-response.dto';
import { ValidateUUID } from 'src/validation';
export class DuplicateResponseDto {
@ApiProperty({ description: 'Duplicate group ID' })
duplicateId!: string;
@ApiProperty({ description: 'Duplicate assets' })
assets!: AssetResponseDto[];
@ValidateUUID({ each: true, description: 'Suggested asset IDs to keep based on file size and EXIF data' })
suggestedKeepAssetIds!: string[];
}
export class DuplicateResolveGroupDto {
@ValidateUUID()
duplicateId!: string;
@ValidateUUID({ each: true, description: 'Asset IDs to keep' })
keepAssetIds!: string[];
@ValidateUUID({ each: true, description: 'Asset IDs to trash or delete' })
trashAssetIds!: string[];
}
export class DuplicateResolveDto {
@ApiProperty({ description: 'List of duplicate groups to resolve' })
@ValidateNested({ each: true })
@IsArray()
@Type(() => DuplicateResolveGroupDto)
@ArrayMinSize(1)
groups!: DuplicateResolveGroupDto[];
}
+4
View File
@@ -42,6 +42,10 @@ export class EnvDto {
@Optional()
IMMICH_CONFIG_FILE?: string;
@IsString()
@Optional()
IMMICH_HELMET_FILE?: string;
@IsEnum(ImmichEnvironment)
@Optional()
IMMICH_ENV?: ImmichEnvironment;
+1 -1
View File
@@ -146,7 +146,7 @@ export class RandomSearchDto extends BaseSearchWithResultsDto {
@ValidateBoolean({ optional: true, description: 'Include stacked assets' })
withStacked?: boolean;
@ValidateBoolean({ optional: true, description: 'Include assets with people' })
@ValidateBoolean({ optional: true, description: 'Include people data in response' })
withPeople?: boolean;
}
+7
View File
@@ -37,6 +37,11 @@ export enum AssetType {
Other = 'OTHER',
}
export enum ChecksumAlgorithm {
sha1File = 'sha1', // sha1 checksum of the whole file contents
sha1Path = 'sha1-path', // sha1 checksum of "path:" plus the file path, currently used in external libraries, deprecated
}
export enum AssetFileType {
/**
* An full/large-size image extracted/converted from RAW photos
@@ -702,6 +707,7 @@ export enum DatabaseLock {
BackupDatabase = 42,
MaintenanceOperation = 621,
MemoryCreation = 777,
VersionCheck = 800,
}
export enum MaintenanceAction {
@@ -846,6 +852,7 @@ export enum AssetVisibility {
export enum CronJob {
LibraryScan = 'LibraryScan',
NightlyJobs = 'NightlyJobs',
VersionCheck = 'VersionCheck',
}
export enum ApiTag {
@@ -1,6 +1,7 @@
import { Injectable } from '@nestjs/common';
import { fork } from 'node:child_process';
import { dirname, join } from 'node:path';
import { IMMICH_SERVER_START } from 'src/constants';
@Injectable()
export class MaintenanceHealthRepository {
@@ -20,48 +21,32 @@ export class MaintenanceHealthRepository {
stdio: ['ignore', 'pipe', 'ignore', 'ipc'],
});
async function checkHealth() {
try {
const response = await fetch('http://127.0.0.1:33001/api/server/config');
const { isOnboarded } = await response.json();
if (isOnboarded) {
resolve();
} else {
reject(new Error('Server health check failed, no admin exists.'));
}
} catch (error) {
reject(error);
} finally {
if (worker.exitCode === null) {
worker.kill('SIGTERM');
}
}
}
let output = '',
alive = false;
let output = '';
worker.stdout?.on('data', (data) => {
if (alive) {
if (worker.exitCode !== null) {
return;
}
output += data;
if (output.includes('Immich Server is listening')) {
alive = true;
void checkHealth();
if (output.includes(IMMICH_SERVER_START)) {
resolve();
worker.kill('SIGTERM');
}
});
worker.on('exit', reject);
worker.on('error', reject);
worker.on('exit', (code, signal) =>
reject(new Error(`Server health check failed, server exited with ${signal ?? code}`)),
);
worker.on('error', (error) => reject(new Error(`Server health check failed, process threw: ${error}`)));
setTimeout(() => {
if (worker.exitCode === null) {
reject(new Error('Server health check failed, took too long to start.'));
worker.kill('SIGTERM');
}
}, 20_000);
}, 180_000);
});
}
}
+10
View File
@@ -160,6 +160,16 @@ where
"session"."userId" = $1
and "session"."id" in ($2)
-- AccessRepository.duplicate.checkOwnerAccess
select
"asset"."duplicateId"
from
"asset"
where
"asset"."duplicateId" in ($1)
and "asset"."ownerId" = $2
and "asset"."deletedAt" is null
-- AccessRepository.memory.checkOwnerAccess
select
"memory"."id"
+22
View File
@@ -164,6 +164,28 @@ order by
"album"."createdAt" desc,
"album"."createdAt" desc
-- AlbumRepository.getByAssetIds
select
"album"."id",
"album_asset"."assetId"
from
"album"
inner join "album_asset" on "album_asset"."albumId" = "album"."id"
where
(
"album"."ownerId" = $1
or exists (
select
from
"album_user"
where
"album_user"."albumId" = "album"."id"
and "album_user"."userId" = $2
)
)
and "album_asset"."assetId" in ($3)
and "album"."deletedAt" is null
-- AlbumRepository.getMetadataForIds
select
"album_asset"."albumId" as "albumId",
+5 -2
View File
@@ -249,6 +249,7 @@ where
select
"asset"."id",
"asset"."checksum",
"asset"."checksumAlgorithm",
"asset"."deviceAssetId",
"asset"."deviceId",
"asset"."fileCreatedAt",
@@ -264,6 +265,7 @@ select
"asset"."type",
"asset"."width",
"asset"."height",
"asset"."isEdited",
(
select
coalesce(json_agg(agg), '[]')
@@ -435,12 +437,13 @@ select
"asset_file"
where
"asset_file"."assetId" = "asset"."id"
and "asset_file"."type" = $1
and "asset_file"."type" = 'preview'
and "asset_file"."isEdited" = false
) as "previewFile"
from
"asset"
where
"asset"."id" = $2
"asset"."id" = $1
-- AssetJobRepository.getForSyncAssets
select
+4 -3
View File
@@ -637,13 +637,14 @@ select
"asset_file"
where
"asset_file"."assetId" = "asset"."id"
and "asset_file"."type" = $1
and "asset_file"."type" = 'encoded_video'
and "asset_file"."isEdited" = false
) as "encodedVideoPath"
from
"asset"
where
"asset"."id" = $2
and "asset"."type" = $3
"asset"."id" = $1
and "asset"."type" = $2
-- AssetRepository.getForOcr
select
+88 -21
View File
@@ -15,7 +15,26 @@ with
inner join lateral (
select
"asset".*,
"asset_exif" as "exifInfo"
to_json("asset_exif") as "exifInfo",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"tag"."id",
"tag"."value",
"tag"."createdAt",
"tag"."updatedAt",
"tag"."color",
"tag"."parentId"
from
"tag"
inner join "tag_asset" on "tag"."id" = "tag_asset"."tagId"
where
"tag_asset"."assetId" = "asset"."id"
) as agg
) as "tags"
from
"asset_exif"
where
@@ -29,36 +48,84 @@ with
and "asset"."stackId" is null
group by
"asset"."duplicateId"
),
"unique" as (
select
"duplicateId"
from
"duplicates"
where
json_array_length("assets") = $2
),
"removed_unique" as (
update "asset"
set
"duplicateId" = $3
from
"unique"
where
"asset"."duplicateId" = "unique"."duplicateId"
)
select
*
from
"duplicates"
where
not exists (
json_array_length("assets") > $2
-- DuplicateRepository.cleanupSingletonGroups
with
"singletons" as (
select
"duplicateId"
from
"unique"
"asset"
where
"unique"."duplicateId" = "duplicates"."duplicateId"
"ownerId" = $1::uuid
and "duplicateId" is not null
and "deletedAt" is null
and "stackId" is null
group by
"duplicateId"
having
count("id") = $2
)
update "asset"
set
"duplicateId" = $3
from
"singletons"
where
"asset"."duplicateId" = "singletons"."duplicateId"
-- DuplicateRepository.get
select
"asset"."duplicateId",
json_agg(
"asset2"
order by
"asset"."localDateTime" asc
) as "assets"
from
"asset"
inner join lateral (
select
"asset".*,
to_json("asset_exif") as "exifInfo",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"tag"."id",
"tag"."value",
"tag"."createdAt",
"tag"."updatedAt",
"tag"."color",
"tag"."parentId"
from
"tag"
inner join "tag_asset" on "tag"."id" = "tag_asset"."tagId"
where
"tag_asset"."assetId" = "asset"."id"
) as agg
) as "tags"
from
"asset_exif"
where
"asset_exif"."assetId" = "asset"."id"
) as "asset2" on true
where
"asset"."visibility" in ('archive', 'timeline')
and "asset"."duplicateId" = $1::uuid
and "asset"."deletedAt" is null
and "asset"."stackId" is null
group by
"asset"."duplicateId"
-- DuplicateRepository.delete
update "asset"
+13 -10
View File
@@ -176,7 +176,7 @@ select
where
"asset_file"."assetId" = "asset"."id"
and "asset_file"."type" = 'preview'
and "asset_file"."isEdited" = $1
and "asset_file"."isEdited" = false
) as "previewPath"
from
"person"
@@ -184,7 +184,7 @@ from
inner join "asset" on "asset_face"."assetId" = "asset"."id"
left join "asset_exif" on "asset_exif"."assetId" = "asset"."id"
where
"person"."id" = $2
"person"."id" = $1
and "asset_face"."deletedAt" is null
-- PersonRepository.reassignFace
@@ -195,18 +195,21 @@ where
"asset_face"."id" = $2
-- PersonRepository.getByName
with
"similarity_threshold" as (
select
set_config('pg_trgm.word_similarity_threshold', '0.5', true) as "thresh"
)
select
"person".*
from
"similarity_threshold",
"person"
where
(
"person"."ownerId" = $1
and (
lower("person"."name") like $2
or lower("person"."name") like $3
)
)
"person"."ownerId" = $1
and f_unaccent ("person"."name") %> f_unaccent ($2)
order by
f_unaccent ("person"."name") <->>> f_unaccent ($3)
limit
$4
@@ -228,12 +231,12 @@ select
from
"asset_face"
left join "asset" on "asset"."id" = "asset_face"."assetId"
and "asset_face"."personId" = $1
and "asset"."visibility" = 'timeline'
and "asset"."deletedAt" is null
where
"asset_face"."deletedAt" is null
and "asset_face"."isVisible" is true
and "asset_face"."personId" = $1
-- PersonRepository.getNumberOfPeople
select
+5 -1
View File
@@ -84,7 +84,6 @@ select
from
"asset"
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
left join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
where
"asset"."visibility" = $1
and "asset"."fileCreatedAt" >= $2
@@ -254,6 +253,7 @@ where
and "visibility" = $2
and "deletedAt" is null
and "state" is not null
and "state" != $3
-- SearchRepository.getCities
select distinct
@@ -266,6 +266,7 @@ where
and "visibility" = $2
and "deletedAt" is null
and "city" is not null
and "city" != $3
-- SearchRepository.getCameraMakes
select distinct
@@ -278,6 +279,7 @@ where
and "visibility" = $2
and "deletedAt" is null
and "make" is not null
and "make" != $3
-- SearchRepository.getCameraModels
select distinct
@@ -290,6 +292,7 @@ where
and "visibility" = $2
and "deletedAt" is null
and "model" is not null
and "model" != $3
-- SearchRepository.getCameraLensModels
select distinct
@@ -302,3 +305,4 @@ where
and "visibility" = $2
and "deletedAt" is null
and "lensModel" is not null
and "lensModel" != $3
+126 -48
View File
@@ -3,37 +3,64 @@
-- SharedLinkRepository.get
select
"shared_link".*,
coalesce(
json_agg("a") filter (
where
"a"."id" is not null
),
'[]'
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"asset".*,
to_json("exifInfo") as "exifInfo"
from
"shared_link_asset"
inner join "asset" on "asset"."id" = "shared_link_asset"."assetId"
inner join lateral (
select
"asset_exif"."assetId",
"asset_exif"."autoStackId",
"asset_exif"."bitsPerSample",
"asset_exif"."city",
"asset_exif"."colorspace",
"asset_exif"."country",
"asset_exif"."dateTimeOriginal",
"asset_exif"."description",
"asset_exif"."exifImageHeight",
"asset_exif"."exifImageWidth",
"asset_exif"."exposureTime",
"asset_exif"."fileSizeInByte",
"asset_exif"."fNumber",
"asset_exif"."focalLength",
"asset_exif"."fps",
"asset_exif"."iso",
"asset_exif"."latitude",
"asset_exif"."lensModel",
"asset_exif"."livePhotoCID",
"asset_exif"."longitude",
"asset_exif"."make",
"asset_exif"."model",
"asset_exif"."modifyDate",
"asset_exif"."orientation",
"asset_exif"."profileDescription",
"asset_exif"."projectionType",
"asset_exif"."rating",
"asset_exif"."state",
"asset_exif"."tags",
"asset_exif"."timeZone"
from
"asset_exif"
where
"asset_exif"."assetId" = "asset"."id"
) as "exifInfo" on true
where
"shared_link"."id" = "shared_link_asset"."sharedLinkId"
and "asset"."deletedAt" is null
order by
"asset"."fileCreatedAt" asc
) as agg
) as "assets",
to_json("album") as "album"
from
"shared_link"
left join lateral (
select
"asset".*,
to_json("exifInfo") as "exifInfo"
from
"shared_link_asset"
inner join "asset" on "asset"."id" = "shared_link_asset"."assetId"
inner join lateral (
select
"asset_exif".*
from
"asset_exif"
where
"asset_exif"."assetId" = "asset"."id"
) as "exifInfo" on true
where
"shared_link"."id" = "shared_link_asset"."sharedLinkId"
and "asset"."deletedAt" is null
order by
"asset"."fileCreatedAt" asc
) as "a" on true
left join lateral (
select
"album".*,
@@ -60,7 +87,36 @@ from
"asset"
inner join lateral (
select
"asset_exif".*
"asset_exif"."assetId",
"asset_exif"."autoStackId",
"asset_exif"."bitsPerSample",
"asset_exif"."city",
"asset_exif"."colorspace",
"asset_exif"."country",
"asset_exif"."dateTimeOriginal",
"asset_exif"."description",
"asset_exif"."exifImageHeight",
"asset_exif"."exifImageWidth",
"asset_exif"."exposureTime",
"asset_exif"."fileSizeInByte",
"asset_exif"."fNumber",
"asset_exif"."focalLength",
"asset_exif"."fps",
"asset_exif"."iso",
"asset_exif"."latitude",
"asset_exif"."lensModel",
"asset_exif"."livePhotoCID",
"asset_exif"."longitude",
"asset_exif"."make",
"asset_exif"."model",
"asset_exif"."modifyDate",
"asset_exif"."orientation",
"asset_exif"."profileDescription",
"asset_exif"."projectionType",
"asset_exif"."rating",
"asset_exif"."state",
"asset_exif"."tags",
"asset_exif"."timeZone"
from
"asset_exif"
where
@@ -74,7 +130,12 @@ from
) as "assets" on true
inner join lateral (
select
"user".*
"id",
"name",
"email",
"avatarColor",
"profileImagePath",
"profileChangedAt"
from
"user"
where
@@ -95,9 +156,6 @@ where
"shared_link"."type" = $3
or "album"."id" is not null
)
group by
"shared_link"."id",
"album".*
order by
"shared_link"."createdAt" desc
@@ -134,21 +192,12 @@ from
"album"
inner join lateral (
select
"user"."id",
"user"."email",
"user"."createdAt",
"user"."profileImagePath",
"user"."isAdmin",
"user"."shouldChangePassword",
"user"."deletedAt",
"user"."oauthId",
"user"."updatedAt",
"user"."storageLabel",
"user"."name",
"user"."quotaSizeInBytes",
"user"."quotaUsageInBytes",
"user"."status",
"user"."profileChangedAt"
"id",
"name",
"email",
"avatarColor",
"profileImagePath",
"profileChangedAt"
from
"user"
where
@@ -267,7 +316,36 @@ from
"asset"
inner join lateral (
select
*
"asset_exif"."assetId",
"asset_exif"."autoStackId",
"asset_exif"."bitsPerSample",
"asset_exif"."city",
"asset_exif"."colorspace",
"asset_exif"."country",
"asset_exif"."dateTimeOriginal",
"asset_exif"."description",
"asset_exif"."exifImageHeight",
"asset_exif"."exifImageWidth",
"asset_exif"."exposureTime",
"asset_exif"."fileSizeInByte",
"asset_exif"."fNumber",
"asset_exif"."focalLength",
"asset_exif"."fps",
"asset_exif"."iso",
"asset_exif"."latitude",
"asset_exif"."lensModel",
"asset_exif"."livePhotoCID",
"asset_exif"."longitude",
"asset_exif"."make",
"asset_exif"."model",
"asset_exif"."modifyDate",
"asset_exif"."orientation",
"asset_exif"."profileDescription",
"asset_exif"."projectionType",
"asset_exif"."rating",
"asset_exif"."state",
"asset_exif"."tags",
"asset_exif"."timeZone"
from
"asset_exif"
where
-1
View File
@@ -582,7 +582,6 @@ where
"asset_face"."updateId" < $1
and "asset_face"."updateId" > $2
and "asset"."ownerId" = $3
and "asset_face"."isVisible" = $4
order by
"asset_face"."updateId" asc
+25 -1
View File
@@ -1,5 +1,5 @@
import { Injectable } from '@nestjs/common';
import { Kysely, sql } from 'kysely';
import { Kysely, NotNull, sql } from 'kysely';
import { InjectKysely } from 'nestjs-kysely';
import { ChunkedSet, DummyValue, GenerateSql } from 'src/decorators';
import { AlbumUserRole, AssetVisibility } from 'src/enum';
@@ -285,6 +285,28 @@ class AuthDeviceAccess {
}
}
class DuplicateAccess {
constructor(private db: Kysely<DB>) {}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] })
@ChunkedSet({ paramIndex: 1 })
async checkOwnerAccess(userId: string, duplicateIds: Set<string>) {
if (duplicateIds.size === 0) {
return new Set<string>();
}
return this.db
.selectFrom('asset')
.select('asset.duplicateId')
.where('asset.duplicateId', 'in', [...duplicateIds])
.where('asset.ownerId', '=', userId)
.where('asset.deletedAt', 'is', null)
.$narrowType<{ duplicateId: NotNull }>()
.execute()
.then((assets) => new Set(assets.map((asset) => asset.duplicateId)));
}
}
class NotificationAccess {
constructor(private db: Kysely<DB>) {}
@@ -488,6 +510,7 @@ export class AccessRepository {
album: AlbumAccess;
asset: AssetAccess;
authDevice: AuthDeviceAccess;
duplicate: DuplicateAccess;
memory: MemoryAccess;
notification: NotificationAccess;
person: PersonAccess;
@@ -503,6 +526,7 @@ export class AccessRepository {
this.album = new AlbumAccess(db);
this.asset = new AssetAccess(db);
this.authDevice = new AuthDeviceAccess(db);
this.duplicate = new DuplicateAccess(db);
this.memory = new MemoryAccess(db);
this.notification = new NotificationAccess(db);
this.person = new PersonAccess(db);
+44 -1
View File
@@ -125,6 +125,44 @@ export class AlbumRepository {
.execute();
}
@GenerateSql({ params: [DummyValue.UUID, [DummyValue.UUID]] })
@ChunkedSet({ paramIndex: 1 })
async getByAssetIds(ownerId: string, assetIds: string[]): Promise<Map<string, string[]>> {
if (assetIds.length === 0) {
return new Map();
}
const results = await this.db
.selectFrom('album')
.select('album.id')
.innerJoin('album_asset', 'album_asset.albumId', 'album.id')
.where((eb) =>
eb.or([
eb('album.ownerId', '=', ownerId),
eb.exists(
eb
.selectFrom('album_user')
.whereRef('album_user.albumId', '=', 'album.id')
.where('album_user.userId', '=', ownerId),
),
]),
)
.where('album_asset.assetId', 'in', assetIds)
.where('album.deletedAt', 'is', null)
.select('album_asset.assetId')
.execute();
// Group by assetId
const map = new Map<string, string[]>();
for (const row of results) {
const existing = map.get(row.assetId) ?? [];
existing.push(row.id);
map.set(row.assetId, existing);
}
return map;
}
@GenerateSql({ params: [[DummyValue.UUID]] })
@ChunkedArray()
async getMetadataForIds(ids: string[]): Promise<AlbumAssetCount[]> {
@@ -339,7 +377,12 @@ export class AlbumRepository {
if (values.length === 0) {
return;
}
await this.db.insertInto('album_asset').values(values).execute();
await this.db
.insertInto('album_asset')
.values(values)
// Allow idempotent album sync without failing on existing album memberships.
.onConflict((oc) => oc.columns(['albumId', 'assetId']).doNothing())
.execute();
}
/**
+4 -2
View File
@@ -380,8 +380,10 @@ export class AssetRepository {
return this.db.insertInto('asset').values(asset).returningAll().executeTakeFirstOrThrow();
}
createAll(assets: Insertable<AssetTable>[]) {
return this.db.insertInto('asset').values(assets).returningAll().execute();
@ChunkedArray({ chunkSize: 4000 })
async createAll(assets: Insertable<AssetTable>[]) {
const ids = await this.db.insertInto('asset').values(assets).returning('id').execute();
return ids.map(({ id }) => id);
}
@GenerateSql({ params: [DummyValue.UUID, { year: 2000, day: 1, month: 1 }] })
@@ -5,9 +5,11 @@ import { QueueOptions } from 'bullmq';
import { plainToInstance } from 'class-transformer';
import { validateSync } from 'class-validator';
import { Request, Response } from 'express';
import { HelmetOptions } from 'helmet';
import { RedisOptions } from 'ioredis';
import { CLS_ID, ClsModuleOptions } from 'nestjs-cls';
import { OpenTelemetryModuleOptions } from 'nestjs-otel/lib/interfaces';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { citiesFile, excludePaths, IWorker } from 'src/constants';
import { Telemetry } from 'src/decorators';
@@ -58,6 +60,10 @@ export interface EnvData {
config: ClsModuleOptions;
};
helmet: {
config?: HelmetOptions;
};
database: {
config: DatabaseConnectionParams;
skipMigrations: boolean;
@@ -69,6 +75,10 @@ export interface EnvData {
server: string;
};
versionCheck: {
url: string;
};
network: {
trustedProxies: string[];
};
@@ -143,6 +153,25 @@ const asSet = <T>(value: string | undefined, defaults: T[]) => {
return new Set(values.length === 0 ? defaults : (values as T[]));
};
const resolveHelmetFile = (helmetFile: 'true' | 'false' | string | undefined) => {
// default is off
if (!helmetFile || helmetFile === 'false') {
return;
}
helmetFile =
helmetFile === 'true'
? // eslint-disable-next-line unicorn/prefer-module
join(__dirname, '..', '..', 'helmet.json')
: helmetFile;
try {
return JSON.parse(readFileSync(helmetFile).toString()) as HelmetOptions;
} catch (error) {
throw new Error(`Failed to read helmet file: ${helmetFile}`, { cause: error });
}
};
const getEnv = (): EnvData => {
const dto = plainToInstance(EnvDto, process.env);
const errors = validateSync(dto);
@@ -289,8 +318,16 @@ const getEnv = (): EnvData => {
vectorExtension,
},
helmet: {
config: resolveHelmetFile(dto.IMMICH_HELMET_FILE),
},
licensePublicKey: isProd ? productionKeys : stagingKeys,
versionCheck: {
url: isProd ? 'https://version.immich.cloud/version' : 'https://version.dev.immich.cloud/version',
},
network: {
trustedProxies: dto.IMMICH_TRUSTED_PROXIES ?? ['linklocal', 'uniquelocal'],
},
+96 -20
View File
@@ -1,13 +1,19 @@
import { Injectable } from '@nestjs/common';
import { Kysely, NotNull, Selectable, ShallowDehydrateObject, sql } from 'kysely';
import { jsonArrayFrom } from 'kysely/helpers/postgres';
import { InjectKysely } from 'nestjs-kysely';
import { columns } from 'src/database';
import { Chunked, DummyValue, GenerateSql } from 'src/decorators';
import { MapAsset } from 'src/dtos/asset-response.dto';
import { AssetType, VectorIndex } from 'src/enum';
import { probes } from 'src/repositories/database.repository';
import { DB } from 'src/schema';
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
import { anyUuid, asUuid, withDefaultVisibility } from 'src/utils/database';
// Maximum number of candidate duplicates to return from vector search
const DUPLICATE_SEARCH_LIMIT = 64;
interface DuplicateSearch {
assetId: string;
embedding: string;
@@ -34,20 +40,39 @@ export class DuplicateRepository {
qb
.selectFrom('asset')
.$call(withDefaultVisibility)
// Use innerJoinLateral to build a composite object per asset that includes
// exifInfo and tags. This "asset2" object is then aggregated via jsonAgg.
// Tags must be included here (not via separate joins) so they appear in the
// final MapAsset[] output - needed for tag synchronization during resolution.
.innerJoinLateral(
(qb) =>
qb
.selectFrom('asset_exif')
.selectAll('asset')
.select((eb) =>
eb.table('asset_exif').$castTo<ShallowDehydrateObject<Selectable<AssetExifTable>>>().as('exifInfo'),
eb.fn
.toJson('asset_exif')
.$castTo<ShallowDehydrateObject<Selectable<AssetExifTable>>>()
.as('exifInfo'),
)
.select((eb) =>
jsonArrayFrom(
eb
.selectFrom('tag')
.select(columns.tag)
.innerJoin('tag_asset', 'tag.id', 'tag_asset.tagId')
.whereRef('tag_asset.assetId', '=', 'asset.id'),
).as('tags'),
)
.whereRef('asset_exif.assetId', '=', 'asset.id')
.as('asset2'),
(join) => join.onTrue(),
)
.select('asset.duplicateId')
.select((eb) => eb.fn.jsonAgg('asset2').orderBy('asset.localDateTime', 'asc').as('assets'))
.select((eb) =>
eb.fn.jsonAgg('asset2').orderBy('asset.localDateTime', 'asc').$castTo<MapAsset[]>().as('assets'),
)
.where('asset.ownerId', '=', asUuid(userId))
.where('asset.duplicateId', 'is not', null)
.$narrowType<{ duplicateId: NotNull }>()
@@ -55,29 +80,80 @@ export class DuplicateRepository {
.where('asset.stackId', 'is', null)
.groupBy('asset.duplicateId'),
)
.with('unique', (qb) =>
qb
.selectFrom('duplicates')
.select('duplicateId')
.where((eb) => eb(eb.fn('json_array_length', ['assets']), '=', 1)),
)
.with('removed_unique', (qb) =>
qb
.updateTable('asset')
.set({ duplicateId: null })
.from('unique')
.whereRef('asset.duplicateId', '=', 'unique.duplicateId'),
)
.selectFrom('duplicates')
.selectAll()
// TODO: compare with filtering by json_array_length > 1
.where(({ not, exists }) =>
not(exists((eb) => eb.selectFrom('unique').whereRef('unique.duplicateId', '=', 'duplicates.duplicateId'))),
)
// Filter out singleton groups (only 1 asset) directly in the query
.where((eb) => eb(eb.fn('json_array_length', ['assets']), '>', 1))
.execute()
);
}
@GenerateSql({ params: [DummyValue.UUID] })
async cleanupSingletonGroups(userId: string): Promise<void> {
// Remove duplicateId from assets that are the only member of their duplicate group
await this.db
.with('singletons', (qb) =>
qb
.selectFrom('asset')
.select('duplicateId')
.where('ownerId', '=', asUuid(userId))
.where('duplicateId', 'is not', null)
.$narrowType<{ duplicateId: NotNull }>()
.where('deletedAt', 'is', null)
.where('stackId', 'is', null)
.groupBy('duplicateId')
.having((eb) => eb.fn.count('id'), '=', 1),
)
.updateTable('asset')
.set({ duplicateId: null })
.from('singletons')
.whereRef('asset.duplicateId', '=', 'singletons.duplicateId')
.execute();
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] })
async get(duplicateId: string): Promise<{ duplicateId: string; assets: MapAsset[] } | undefined> {
const result = await this.db
.selectFrom('asset')
.$call(withDefaultVisibility)
// Use innerJoinLateral to build a composite object per asset that includes
// exifInfo and tags. This "asset2" object is then aggregated via jsonAgg.
// Tags must be included here (not via separate joins) so they appear in the
// final MapAsset[] output - needed for tag synchronization during resolution.
.innerJoinLateral(
(qb) =>
qb
.selectFrom('asset_exif')
.selectAll('asset')
.select((eb) => eb.fn.toJson('asset_exif').as('exifInfo'))
.select((eb) =>
jsonArrayFrom(
eb
.selectFrom('tag')
.select(columns.tag)
.innerJoin('tag_asset', 'tag.id', 'tag_asset.tagId')
.whereRef('tag_asset.assetId', '=', 'asset.id'),
).as('tags'),
)
.whereRef('asset_exif.assetId', '=', 'asset.id')
.as('asset2'),
(join) => join.onTrue(),
)
.select('asset.duplicateId')
.select((eb) => eb.fn.jsonAgg('asset2').orderBy('asset.localDateTime', 'asc').$castTo<MapAsset[]>().as('assets'))
.where('asset.duplicateId', '=', asUuid(duplicateId))
.where('asset.deletedAt', 'is', null)
.where('asset.stackId', 'is', null)
.groupBy('asset.duplicateId')
.executeTakeFirst();
if (!result || !result.duplicateId) {
return;
}
return { duplicateId: result.duplicateId, assets: result.assets };
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] })
async delete(userId: string, id: string): Promise<void> {
await this.db
@@ -134,7 +210,7 @@ export class DuplicateRepository {
.where('asset.id', '!=', asUuid(assetId))
.where('asset.stackId', 'is', null)
.orderBy('distance')
.limit(64),
.limit(DUPLICATE_SEARCH_LIMIT),
)
.selectFrom('cte')
.selectAll()
@@ -233,6 +233,9 @@ export class JobRepository {
case JobName.FacialRecognitionQueueAll: {
return { jobId: JobName.FacialRecognitionQueueAll };
}
case JobName.VersionCheck: {
return { jobId: JobName.VersionCheck };
}
default: {
return null;
}
+18 -10
View File
@@ -1,5 +1,19 @@
import { Injectable, InternalServerErrorException } from '@nestjs/common';
import type { UserInfoResponse } from 'openid-client' with { 'resolution-mode': 'import' };
import {
allowInsecureRequests,
authorizationCodeGrant,
buildAuthorizationUrl,
calculatePKCECodeChallenge,
ClientSecretBasic,
ClientSecretPost,
discovery,
fetchUserInfo,
None,
randomPKCECodeVerifier,
randomState,
skipSubjectCheck,
type UserInfoResponse,
} from 'openid-client';
import { OAuthTokenEndpointAuthMethod } from 'src/enum';
import { LoggingRepository } from 'src/repositories/logging.repository';
@@ -24,8 +38,6 @@ export class OAuthRepository {
}
async authorize(config: OAuthConfig, redirectUrl: string, state?: string, codeChallenge?: string) {
const { buildAuthorizationUrl, randomState, randomPKCECodeVerifier, calculatePKCECodeChallenge } =
await import('openid-client');
const client = await this.getClient(config);
state ??= randomState();
@@ -64,7 +76,6 @@ export class OAuthRepository {
expectedState: string,
codeVerifier: string,
): Promise<OAuthProfile> {
const { authorizationCodeGrant, fetchUserInfo, ...oidc } = await import('openid-client');
const client = await this.getClient(config);
const pkceCodeVerifier = client.serverMetadata().supportsPKCE() ? codeVerifier : undefined;
@@ -77,7 +88,7 @@ export class OAuthRepository {
this.logger.debug('Using ID token claims instead of userinfo endpoint');
profile = tokenClaims as OAuthProfile;
} else {
profile = await fetchUserInfo(client, tokens.access_token, oidc.skipSubjectCheck);
profile = await fetchUserInfo(client, tokens.access_token, skipSubjectCheck);
}
if (!profile.sub) {
@@ -124,7 +135,6 @@ export class OAuthRepository {
timeout,
}: OAuthConfig) {
try {
const { allowInsecureRequests, discovery } = await import('openid-client');
return await discovery(
new URL(issuerUrl),
clientId,
@@ -134,7 +144,7 @@ export class OAuthRepository {
userinfo_signed_response_alg: profileSigningAlgorithm === 'none' ? undefined : profileSigningAlgorithm,
id_token_signed_response_alg: signingAlgorithm,
},
await this.getTokenAuthMethod(tokenEndpointAuthMethod, clientSecret),
this.getTokenAuthMethod(tokenEndpointAuthMethod, clientSecret),
{
execute: [allowInsecureRequests],
timeout,
@@ -146,9 +156,7 @@ export class OAuthRepository {
}
}
private async getTokenAuthMethod(tokenEndpointAuthMethod: OAuthTokenEndpointAuthMethod, clientSecret?: string) {
const { None, ClientSecretPost, ClientSecretBasic } = await import('openid-client');
private getTokenAuthMethod(tokenEndpointAuthMethod: OAuthTokenEndpointAuthMethod, clientSecret?: string) {
if (!clientSecret) {
return None();
}
@@ -58,6 +58,7 @@ export class OcrRepository {
})
upsert(assetId: string, ocrDataList: Insertable<AssetOcrTable>[], searchText: string) {
let query = this.db.with('deleted_ocr', (db) => db.deleteFrom('asset_ocr').where('assetId', '=', assetId));
// eslint-disable-next-line unicorn/prefer-ternary
if (ocrDataList.length > 0) {
(query as any) = query
.with('inserted_ocr', (db) => db.insertInto('asset_ocr').values(ocrDataList))
+11 -22
View File
@@ -9,7 +9,7 @@ import { DB } from 'src/schema';
import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
import { FaceSearchTable } from 'src/schema/tables/face-search.table';
import { PersonTable } from 'src/schema/tables/person.table';
import { removeUndefinedKeys } from 'src/utils/database';
import { removeUndefinedKeys, withFilePath } from 'src/utils/database';
import { paginationHelper, PaginationOptions } from 'src/utils/pagination';
export interface PersonSearchOptions {
@@ -282,15 +282,7 @@ export class PersonRepository {
'asset.originalPath',
'asset_exif.orientation as exifOrientation',
])
.select((eb) =>
eb
.selectFrom('asset_file')
.select('asset_file.path')
.whereRef('asset_file.assetId', '=', 'asset.id')
.where('asset_file.type', '=', sql.lit(AssetFileType.Preview))
.where('asset_file.isEdited', '=', false)
.as('previewPath'),
)
.select((eb) => withFilePath(eb, AssetFileType.Preview).as('previewPath'))
.where('person.id', '=', id)
.where('asset_face.deletedAt', 'is', null)
.executeTakeFirst();
@@ -318,18 +310,15 @@ export class PersonRepository {
@GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING, { withHidden: true }] })
getByName(userId: string, personName: string, { withHidden }: PersonNameSearchOptions) {
return this.db
.selectFrom('person')
.selectAll('person')
.where((eb) =>
eb.and([
eb('person.ownerId', '=', userId),
eb.or([
eb(eb.fn('lower', ['person.name']), 'like', `${personName.toLowerCase()}%`),
eb(eb.fn('lower', ['person.name']), 'like', `% ${personName.toLowerCase()}%`),
]),
]),
.with('similarity_threshold', (db) =>
db.selectNoFrom(sql`set_config('pg_trgm.word_similarity_threshold', '0.5', true)`.as('thresh')),
)
.limit(1000)
.selectFrom(['similarity_threshold', 'person'])
.selectAll('person')
.where('person.ownerId', '=', userId)
.where(() => sql`f_unaccent("person"."name") %> f_unaccent(${personName})`)
.orderBy(sql`f_unaccent("person"."name") <->>> f_unaccent(${personName})`)
.limit(100)
.$if(!withHidden, (qb) => qb.where('person.isHidden', '=', false))
.execute();
}
@@ -352,13 +341,13 @@ export class PersonRepository {
.leftJoin('asset', (join) =>
join
.onRef('asset.id', '=', 'asset_face.assetId')
.on('asset_face.personId', '=', personId)
.on('asset.visibility', '=', sql.lit(AssetVisibility.Timeline))
.on('asset.deletedAt', 'is', null),
)
.select((eb) => eb.fn.count(eb.fn('distinct', ['asset.id'])).as('count'))
.where('asset_face.deletedAt', 'is', null)
.where('asset_face.isVisible', 'is', true)
.where('asset_face.personId', '=', personId)
.executeTakeFirst();
return {
+5 -7
View File
@@ -8,7 +8,7 @@ import { AssetStatus, AssetType, AssetVisibility, VectorIndex } from 'src/enum';
import { probes } from 'src/repositories/database.repository';
import { DB } from 'src/schema';
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
import { anyUuid, searchAssetBuilder, withExif } from 'src/utils/database';
import { anyUuid, searchAssetBuilder, withExifInner } from 'src/utils/database';
import { paginationHelper } from 'src/utils/pagination';
import { isValidInteger } from 'src/validation';
@@ -270,7 +270,7 @@ export class SearchRepository {
const orderDirection = (options.orderDirection?.toLowerCase() || 'desc') as OrderByDirection;
return searchAssetBuilder(this.db, options)
.selectAll('asset')
.$call(withExif)
.$call(withExifInner)
.where('asset_exif.fileSizeInByte', '>', options.minFileSize || 0)
.orderBy('asset_exif.fileSizeInByte', orderDirection)
.limit(size)
@@ -502,10 +502,7 @@ export class SearchRepository {
return res.map((row) => row.lensModel!);
}
private getExifField<K extends 'city' | 'state' | 'country' | 'make' | 'model' | 'lensModel'>(
field: K,
userIds: string[],
) {
private getExifField(field: 'city' | 'state' | 'country' | 'make' | 'model' | 'lensModel', userIds: string[]) {
return this.db
.selectFrom('asset_exif')
.select(field)
@@ -514,6 +511,7 @@ export class SearchRepository {
.where('ownerId', '=', anyUuid(userIds))
.where('visibility', '=', AssetVisibility.Timeline)
.where('deletedAt', 'is', null)
.where(field, 'is not', null);
.where(field, 'is not', null)
.where(field, '!=', '');
}
}
@@ -17,6 +17,11 @@ export interface GitHubRelease {
body: string;
}
export interface VersionResponse {
version: string;
published_at: string;
}
export interface ServerBuildVersions {
nodejs: string;
ffmpeg: string;
@@ -59,17 +64,18 @@ export class ServerInfoRepository {
this.logger.setContext(ServerInfoRepository.name);
}
async getGitHubRelease(): Promise<GitHubRelease> {
async getLatestRelease(): Promise<VersionResponse> {
try {
const response = await fetch('https://api.github.com/repos/immich-app/immich/releases/latest');
const { versionCheck } = this.configRepository.getEnv();
const response = await fetch(versionCheck.url);
if (!response.ok) {
throw new Error(`GitHub API request failed with status ${response.status}: ${await response.text()}`);
throw new Error(`Version check request failed with status ${response.status}: ${await response.text()}`);
}
return response.json();
} catch (error) {
throw new Error('Failed to fetch GitHub release', { cause: error });
throw new Error('Failed to fetch latest release', { cause: error });
}
}
+49 -105
View File
@@ -1,5 +1,5 @@
import { Injectable } from '@nestjs/common';
import { Insertable, Kysely, Selectable, ShallowDehydrateObject, sql, Updateable } from 'kysely';
import { ExpressionBuilder, Insertable, Kysely, Selectable, ShallowDehydrateObject, sql, Updateable } from 'kysely';
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
import _ from 'lodash';
import { InjectKysely } from 'nestjs-kysely';
@@ -17,6 +17,41 @@ export type SharedLinkSearchOptions = {
albumId?: string;
};
const withSharedAssets = (eb: ExpressionBuilder<DB, 'shared_link'>) => {
return eb
.selectFrom('shared_link_asset')
.whereRef('shared_link.id', '=', 'shared_link_asset.sharedLinkId')
.innerJoin('asset', 'asset.id', 'shared_link_asset.assetId')
.where('asset.deletedAt', 'is', null)
.selectAll('asset')
.orderBy('asset.fileCreatedAt', 'asc');
};
export const withExifInfo = (eb: ExpressionBuilder<DB, 'asset'>) => {
return eb
.selectFrom('asset_exif')
.select(columns.exif)
.whereRef('asset_exif.assetId', '=', 'asset.id')
.as('exifInfo');
};
const withAlbumOwner = (eb: ExpressionBuilder<DB, 'album'>) => {
return eb
.selectFrom('user')
.select(columns.user)
.whereRef('user.id', '=', 'album.ownerId')
.where('user.deletedAt', 'is', null)
.as('owner');
};
const withSharedLinkAlbum = (eb: ExpressionBuilder<DB, 'shared_link'>) => {
return eb
.selectFrom('album')
.selectAll('album')
.whereRef('album.id', '=', 'shared_link.albumId')
.where('album.deletedAt', 'is', null);
};
@Injectable()
export class SharedLinkRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {}
@@ -26,35 +61,16 @@ export class SharedLinkRepository {
return this.db
.selectFrom('shared_link')
.selectAll('shared_link')
.leftJoinLateral(
(eb) =>
eb
.selectFrom('shared_link_asset')
.whereRef('shared_link.id', '=', 'shared_link_asset.sharedLinkId')
.innerJoin('asset', 'asset.id', 'shared_link_asset.assetId')
.where('asset.deletedAt', 'is', null)
.selectAll('asset')
.innerJoinLateral(
(eb) =>
eb
.selectFrom('asset_exif')
.selectAll('asset_exif')
.whereRef('asset_exif.assetId', '=', 'asset.id')
.as('exifInfo'),
(join) => join.onTrue(),
)
.select((eb) => eb.fn.toJson('exifInfo').as('exifInfo'))
.orderBy('asset.fileCreatedAt', 'asc')
.as('a'),
(join) => join.onTrue(),
.select((eb) =>
jsonArrayFrom(
withSharedAssets(eb)
.innerJoinLateral(withExifInfo, (join) => join.onTrue())
.select((eb) => eb.fn.toJson('exifInfo').as('exifInfo')),
).as('assets'),
)
.leftJoinLateral(
(eb) =>
eb
.selectFrom('album')
.selectAll('album')
.whereRef('album.id', '=', 'shared_link.albumId')
.where('album.deletedAt', 'is', null)
withSharedLinkAlbum(eb)
.leftJoin('album_asset', 'album_asset.albumId', 'album.id')
.leftJoinLateral(
(eb) =>
@@ -63,30 +79,13 @@ export class SharedLinkRepository {
.selectAll('asset')
.whereRef('album_asset.assetId', '=', 'asset.id')
.where('asset.deletedAt', 'is', null)
.innerJoinLateral(
(eb) =>
eb
.selectFrom('asset_exif')
.selectAll('asset_exif')
.whereRef('asset_exif.assetId', '=', 'asset.id')
.as('exifInfo'),
(join) => join.onTrue(),
)
.innerJoinLateral(withExifInfo, (join) => join.onTrue())
.select((eb) => eb.fn.toJson(eb.table('exifInfo')).as('exifInfo'))
.orderBy('asset.fileCreatedAt', 'asc')
.as('assets'),
(join) => join.onTrue(),
)
.innerJoinLateral(
(eb) =>
eb
.selectFrom('user')
.selectAll('user')
.whereRef('user.id', '=', 'album.ownerId')
.where('user.deletedAt', 'is', null)
.as('owner'),
(join) => join.onTrue(),
)
.innerJoinLateral(withAlbumOwner, (join) => join.onTrue())
.select((eb) =>
eb.fn
.coalesce(
@@ -104,17 +103,6 @@ export class SharedLinkRepository {
.as('album'),
(join) => join.onTrue(),
)
.select((eb) =>
eb.fn
.coalesce(eb.fn.jsonAgg('a').filterWhere('a.id', 'is not', null), sql`'[]'`)
.$castTo<
(ShallowDehydrateObject<Selectable<AssetTable>> & {
exifInfo: ShallowDehydrateObject<Selectable<AssetExifTable>>;
})[]
>()
.as('assets'),
)
.groupBy(['shared_link.id', sql`"album".*`])
.select((eb) => eb.fn.toJson(eb.table('album')).$castTo<ShallowDehydrateObject<Album> | null>().as('album'))
.where('shared_link.id', '=', id)
.where('shared_link.userId', '=', userId)
@@ -128,53 +116,13 @@ export class SharedLinkRepository {
return this.db
.selectFrom('shared_link')
.selectAll('shared_link')
.select((eb) => jsonArrayFrom(withSharedAssets(eb).limit(1)).as('assets'))
.where('shared_link.userId', '=', userId)
.select((eb) =>
jsonArrayFrom(
eb
.selectFrom('shared_link_asset')
.whereRef('shared_link.id', '=', 'shared_link_asset.sharedLinkId')
.innerJoin('asset', 'asset.id', 'shared_link_asset.assetId')
.where('asset.deletedAt', 'is', null)
.selectAll('asset')
.orderBy('asset.fileCreatedAt', 'asc')
.limit(1),
).as('assets'),
)
.leftJoinLateral(
(eb) =>
eb
.selectFrom('album')
.selectAll('album')
.whereRef('album.id', '=', 'shared_link.albumId')
.innerJoinLateral(
(eb) =>
eb
.selectFrom('user')
.select([
'user.id',
'user.email',
'user.createdAt',
'user.profileImagePath',
'user.isAdmin',
'user.shouldChangePassword',
'user.deletedAt',
'user.oauthId',
'user.updatedAt',
'user.storageLabel',
'user.name',
'user.quotaSizeInBytes',
'user.quotaUsageInBytes',
'user.status',
'user.profileChangedAt',
])
.whereRef('user.id', '=', 'album.ownerId')
.where('user.deletedAt', 'is', null)
.as('owner'),
(join) => join.onTrue(),
)
withSharedLinkAlbum(eb)
.innerJoinLateral(withAlbumOwner, (join) => join.onTrue())
.select((eb) => eb.fn.toJson('owner').as('owner'))
.where('album.deletedAt', 'is', null)
.as('album'),
(join) => join.onTrue(),
)
@@ -283,11 +231,7 @@ export class SharedLinkRepository {
.selectFrom('asset')
.whereRef('asset.id', '=', 'shared_link_asset.assetId')
.selectAll('asset')
.innerJoinLateral(
(eb) =>
eb.selectFrom('asset_exif').whereRef('asset_exif.assetId', '=', 'asset.id').selectAll().as('exifInfo'),
(join) => join.onTrue(),
)
.innerJoinLateral(withExifInfo, (join) => join.onTrue())
.as('assets'),
(join) => join.onTrue(),
)
@@ -489,7 +489,6 @@ class AssetFaceSync extends BaseSync {
])
.leftJoin('asset', 'asset.id', 'asset_face.assetId')
.where('asset.ownerId', '=', options.userId)
.where('asset_face.isVisible', '=', true)
.stream();
}
}
+6 -1
View File
@@ -1,5 +1,5 @@
import { registerEnum } from '@immich/sql-tools';
import { AssetStatus, AssetVisibility, SourceType } from 'src/enum';
import { AssetStatus, AssetVisibility, ChecksumAlgorithm, SourceType } from 'src/enum';
export const assets_status_enum = registerEnum({
name: 'assets_status_enum',
@@ -15,3 +15,8 @@ export const asset_visibility_enum = registerEnum({
name: 'asset_visibility_enum',
values: Object.values(AssetVisibility),
});
export const asset_checksum_algorithm_enum = registerEnum({
name: 'asset_checksum_algorithm_enum',
values: Object.values(ChecksumAlgorithm),
});
@@ -0,0 +1,10 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
// Sync query for faces was incorrect on server <=2.6.2
await sql`DELETE FROM session_sync_checkpoint WHERE type in ('AssetFaceV1', 'AssetFaceV2')`.execute(db);
}
export async function down(): Promise<void> {
// Not implemented
}
@@ -0,0 +1,21 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`CREATE TYPE "asset_checksum_algorithm_enum" AS ENUM ('sha1','sha1-path');`.execute(db);
await sql`ALTER TABLE "asset" ADD "checksumAlgorithm" asset_checksum_algorithm_enum;`.execute(db);
await sql`
UPDATE "asset"
SET "checksumAlgorithm" = CASE
WHEN "isExternal" = true THEN 'sha1-path'::asset_checksum_algorithm_enum
ELSE 'sha1'::asset_checksum_algorithm_enum
END
`.execute(db);
await sql`ALTER TABLE "asset" ALTER COLUMN "checksumAlgorithm" SET NOT NULL;`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "asset" DROP COLUMN "checksumAlgorithm";`.execute(db);
await sql`DROP TYPE "asset_checksum_algorithm_enum";`.execute(db);
}
@@ -0,0 +1,11 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`CREATE INDEX "idx_person_name_trigram" ON "person" USING gin (f_unaccent("name") gin_trgm_ops);`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('index_idx_person_name_trigram', '{"type":"index","name":"idx_person_name_trigram","sql":"CREATE INDEX \\"idx_person_name_trigram\\" ON \\"person\\" USING gin (f_unaccent(\\"name\\") gin_trgm_ops);"}'::jsonb);`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`DROP INDEX "idx_person_name_trigram";`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'index_idx_person_name_trigram';`.execute(db);
}
+5 -2
View File
@@ -12,8 +12,8 @@ import {
UpdateDateColumn,
} from '@immich/sql-tools';
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { AssetStatus, AssetType, AssetVisibility } from 'src/enum';
import { asset_visibility_enum, assets_status_enum } from 'src/schema/enums';
import { AssetStatus, AssetType, AssetVisibility, ChecksumAlgorithm } from 'src/enum';
import { asset_checksum_algorithm_enum, asset_visibility_enum, assets_status_enum } from 'src/schema/enums';
import { asset_delete_audit } from 'src/schema/functions';
import { LibraryTable } from 'src/schema/tables/library.table';
import { StackTable } from 'src/schema/tables/stack.table';
@@ -95,6 +95,9 @@ export class AssetTable {
@Column({ type: 'bytea', index: true })
checksum!: Buffer; // sha1 checksum
@Column({ enum: asset_checksum_algorithm_enum })
checksumAlgorithm!: ChecksumAlgorithm;
@ForeignKeyColumn(() => AssetTable, { nullable: true, onUpdate: 'CASCADE', onDelete: 'SET NULL' })
livePhotoVideoId!: string | null;
+6
View File
@@ -5,6 +5,7 @@ import {
CreateDateColumn,
ForeignKeyColumn,
Generated,
Index,
PrimaryGeneratedColumn,
Table,
Timestamp,
@@ -16,6 +17,11 @@ import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
import { UserTable } from 'src/schema/tables/user.table';
@Table('person')
@Index({
name: 'idx_person_name_trigram',
using: 'gin',
expression: 'f_unaccent("name") gin_trgm_ops',
})
@UpdatedAtTrigger('person_updatedAt')
@AfterDeleteTrigger({
scope: 'statement',
+36
View File
@@ -0,0 +1,36 @@
import { ApiService, render } from 'src/services/api.service';
describe(ApiService.name, () => {
describe('render', () => {
it('should correctly render open graph tags', () => {
const output = render('<!-- metadata:tags -->', {
title: 'title',
description: 'description',
imageUrl: 'https://demo.immich.app/api/assets/123',
});
expect(output).toContain('<meta property="og:title" content="title" />');
expect(output).toContain('<meta property="og:description" content="description" />');
expect(output).toContain('<meta property="og:image" content="https://demo.immich.app/api/assets/123" />');
});
it('should escape html tags', () => {
expect(
render('<!-- metadata:tags -->', {
title: "<script>console.log('hello')</script>Test",
description: 'description',
}),
).toContain(
'<meta property="og:title" content="&lt;script&gt;console.log(&#39;hello&#39;)&lt;/script&gt;Test" />',
);
});
it('should escape quotes', () => {
expect(
render('<!-- metadata:tags -->', {
title: `0;url=https://example.com" http-equiv="refresh`,
description: 'description',
}),
).toContain('<meta property="og:title" content="0;url=https://example.com&quot; http-equiv=&quot;refresh" />');
});
});
});
+2 -11
View File
@@ -1,19 +1,16 @@
import { Injectable, NotAcceptableException } from '@nestjs/common';
import { Interval } from '@nestjs/schedule';
import { NextFunction, Request, Response } from 'express';
import { escape } from 'lodash';
import { readFileSync } from 'node:fs';
import sanitizeHtml from 'sanitize-html';
import { ONE_HOUR } from 'src/constants';
import { ConfigRepository } from 'src/repositories/config.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { AuthService } from 'src/services/auth.service';
import { SharedLinkService } from 'src/services/shared-link.service';
import { VersionService } from 'src/services/version.service';
import { OpenGraphTags } from 'src/utils/misc';
export const render = (index: string, meta: OpenGraphTags) => {
const [title, description, imageUrl] = [meta.title, meta.description, meta.imageUrl].map((item) =>
item ? sanitizeHtml(item, { allowedTags: [] }) : '',
item ? escape(item) : '',
);
const tags = `
@@ -40,18 +37,12 @@ export class ApiService {
constructor(
private authService: AuthService,
private sharedLinkService: SharedLinkService,
private versionService: VersionService,
private configRepository: ConfigRepository,
private logger: LoggingRepository,
) {
this.logger.setContext(ApiService.name);
}
@Interval(ONE_HOUR.as('milliseconds'))
async onVersionCheck() {
await this.versionService.handleQueueVersionCheck();
}
ssr(excludePaths: string[]) {
const { resourcePaths } = this.configRepository.getEnv();
@@ -111,6 +111,7 @@ const validVideos = [
'.mpg',
'.mts',
'.mxf',
'.ts',
'.vob',
'.webm',
'.wmv',
@@ -691,6 +692,24 @@ describe(AssetMediaService.name, () => {
);
expect(mocks.asset.getForThumbnail).toHaveBeenCalledWith(asset.id, AssetFileType.Thumbnail, true);
});
it('should not include original filename if requested using a shared link with showExif false', async () => {
const asset = AssetFactory.from().file({ type: AssetFileType.Preview }).build();
mocks.access.asset.checkSharedLinkAccess.mockResolvedValue(new Set([asset.id]));
mocks.asset.getForThumbnail.mockResolvedValue({ ...asset, path: asset.files[0].path });
const auth = AuthFactory.from().sharedLink({ showExif: false }).build();
await expect(sut.viewThumbnail(auth, asset.id, { size: AssetMediaSize.PREVIEW })).resolves.toEqual(
new ImmichFileResponse({
path: asset.files[0].path,
cacheControl: CacheControl.PrivateWithCache,
contentType: 'image/jpeg',
fileName: `${asset.id}_preview.jpg`,
}),
);
});
});
describe('playbackVideo', () => {
+7 -1
View File
@@ -27,6 +27,7 @@ import {
AssetStatus,
AssetVisibility,
CacheControl,
ChecksumAlgorithm,
JobName,
Permission,
StorageFolder,
@@ -256,7 +257,9 @@ export class AssetMediaService extends BaseService {
throw new NotFoundException('Asset media not found');
}
const fileName = `${getFileNameWithoutExtension(originalFileName)}_${size}${getFilenameExtension(path)}`;
const fileNameBase =
auth.sharedLink && !auth.sharedLink.showExif ? id : getFileNameWithoutExtension(originalFileName);
const fileName = `${fileNameBase}_${size}${getFilenameExtension(path)}`;
return new ImmichFileResponse({
fileName,
@@ -356,6 +359,7 @@ export class AssetMediaService extends BaseService {
await this.addToSharedLink(auth.sharedLink, duplicateId);
}
this.logger.debug(`Duplicate asset upload rejected: existing asset ${duplicateId}`);
return { status: AssetMediaStatus.DUPLICATE, id: duplicateId };
}
@@ -424,6 +428,7 @@ export class AssetMediaService extends BaseService {
deviceId: asset.deviceId,
type: asset.type,
checksum: asset.checksum,
checksumAlgorithm: asset.checksumAlgorithm,
fileCreatedAt: asset.fileCreatedAt,
localDateTime: asset.localDateTime,
fileModifiedAt: asset.fileModifiedAt,
@@ -445,6 +450,7 @@ export class AssetMediaService extends BaseService {
libraryId: null,
checksum: file.checksum,
checksumAlgorithm: ChecksumAlgorithm.sha1File,
originalPath: file.originalPath,
deviceAssetId: dto.deviceAssetId,
@@ -27,6 +27,7 @@ describe(DatabaseBackupService.name, () => {
mocks.systemMetadata as never,
mocks.process,
mocks.database as never,
mocks.user as never,
mocks.cron as never,
mocks.job as never,
maintenanceHealthRepositoryMock as never,
@@ -187,6 +188,7 @@ describe(DatabaseBackupService.name, () => {
mocks.systemMetadata as never,
mocks.process,
mocks.database as never,
mocks.user as never,
mocks.cron as never,
mocks.job as never,
void 0 as never,
@@ -400,6 +402,7 @@ describe(DatabaseBackupService.name, () => {
mocks.systemMetadata as never,
mocks.process,
mocks.database as never,
mocks.user as never,
mocks.cron as never,
mocks.job as never,
void 0 as never,
@@ -474,6 +477,7 @@ describe(DatabaseBackupService.name, () => {
mocks.systemMetadata as never,
mocks.process,
mocks.database as never,
mocks.user as never,
mocks.cron as never,
mocks.job as never,
void 0 as never,
@@ -536,6 +540,7 @@ describe(DatabaseBackupService.name, () => {
mocks.systemMetadata as never,
mocks.process,
mocks.database as never,
mocks.user as never,
mocks.cron as never,
mocks.job as never,
void 0 as never,
@@ -663,6 +668,7 @@ describe(DatabaseBackupService.name, () => {
mocks.systemMetadata as never,
mocks.process,
mocks.database as never,
mocks.user as never,
mocks.cron as never,
mocks.job as never,
maintenanceHealthRepositoryMock,
@@ -678,6 +684,8 @@ describe(DatabaseBackupService.name, () => {
it('should successfully restore a backup', async () => {
let writtenToPsql = '';
mocks.user.hasAdmin.mockResolvedValue(true);
mocks.process.spawnDuplexStream.mockImplementationOnce(() => mockDuplex()('command', 0, 'data', ''));
mocks.process.spawnDuplexStream.mockImplementationOnce(() => mockDuplex()('command', 0, 'data', ''));
mocks.process.spawnDuplexStream.mockImplementationOnce(() => {
@@ -740,6 +748,8 @@ describe(DatabaseBackupService.name, () => {
it('should generate pg_dumpall specific SQL instructions', async () => {
let writtenToPsql = '';
mocks.user.hasAdmin.mockResolvedValue(true);
mocks.process.spawnDuplexStream.mockImplementationOnce(() => mockDuplex()('command', 0, 'data', ''));
mocks.process.spawnDuplexStream.mockImplementationOnce(() => mockDuplex()('command', 0, 'data', ''));
mocks.process.spawnDuplexStream.mockImplementationOnce(() => {
@@ -834,7 +844,24 @@ describe(DatabaseBackupService.name, () => {
expect(mocks.process.spawnDuplexStream).toHaveBeenCalledTimes(4);
});
it('should rollback if there is no admin user', async () => {
mocks.user.hasAdmin.mockResolvedValue(false);
const progress = vitest.fn();
await expect(
sut.restoreDatabaseBackup('development-filename.sql', progress),
).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: Server health check failed, no admin exists.]`);
expect(progress).toHaveBeenCalledWith('backup', 0.05);
expect(progress).toHaveBeenCalledWith('migrations', 0.9);
expect(progress).toHaveBeenCalledWith('rollback', 0);
expect(mocks.user.hasAdmin).toHaveBeenCalled();
expect(mocks.process.spawnDuplexStream).toHaveBeenCalledTimes(4);
});
it('should rollback if API healthcheck fails', async () => {
mocks.user.hasAdmin.mockResolvedValue(true);
maintenanceHealthRepositoryMock.checkApiHealth.mockRejectedValue(new Error('Health Error'));
const progress = vitest.fn();
@@ -846,6 +873,7 @@ describe(DatabaseBackupService.name, () => {
expect(progress).toHaveBeenCalledWith('migrations', 0.9);
expect(progress).toHaveBeenCalledWith('rollback', 0);
expect(mocks.user.hasAdmin).toHaveBeenCalled();
expect(maintenanceHealthRepositoryMock.checkApiHealth).toHaveBeenCalled();
expect(mocks.process.spawnDuplexStream).toHaveBeenCalledTimes(4);
});
+11 -1
View File
@@ -20,6 +20,7 @@ import { LoggingRepository } from 'src/repositories/logging.repository';
import { ProcessRepository } from 'src/repositories/process.repository';
import { StorageRepository } from 'src/repositories/storage.repository';
import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository';
import { UserRepository } from 'src/repositories/user.repository';
import { getConfig } from 'src/utils/config';
import {
findDatabaseBackupVersion,
@@ -40,6 +41,7 @@ export class DatabaseBackupService {
private readonly systemMetadataRepository: SystemMetadataRepository,
private readonly processRepository: ProcessRepository,
private readonly databaseRepository: DatabaseRepository,
private readonly userRepository: UserRepository,
@Optional()
private readonly cronRepository: CronRepository,
@Optional()
@@ -281,6 +283,7 @@ export class DatabaseBackupService {
async listBackups(): Promise<DatabaseBackupListResponseDto> {
const backupsFolder = StorageCore.getBaseFolder(StorageFolder.Backups);
const files = await this.storageRepository.readdir(backupsFolder);
const timezone = DateTime.local().zoneName;
const validFiles = files
.filter((fn) => isValidDatabaseBackupName(fn))
@@ -290,7 +293,7 @@ export class DatabaseBackupService {
const backups = await Promise.all(
validFiles.map(async (filename) => {
const stats = await this.storageRepository.stat(path.join(backupsFolder, filename));
return { filename, filesize: stats.size };
return { filename, filesize: stats.size, timezone };
}),
);
@@ -405,7 +408,14 @@ export class DatabaseBackupService {
try {
progressCb?.('migrations', 0.9);
await this.databaseRepository.runMigrations();
const hasAdmin = await this.userRepository.hasAdmin();
if (!hasAdmin) {
throw new Error('Server health check failed, no admin exists.');
}
await this.maintenanceHealthRepository.checkApiHealth();
} catch (error) {
progressCb?.('rollback', 0);
+215 -3
View File
@@ -1,12 +1,13 @@
import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto';
import { MapAsset } from 'src/dtos/asset-response.dto';
import { AssetType, AssetVisibility, JobName, JobStatus } from 'src/enum';
import { DuplicateService } from 'src/services/duplicate.service';
import { SearchService } from 'src/services/search.service';
import { AssetFactory } from 'test/factories/asset.factory';
import { authStub } from 'test/fixtures/auth.stub';
import { getForDuplicate } from 'test/mappers';
import { newUuid } from 'test/small.factory';
import { makeStream, newTestService, ServiceMocks } from 'test/utils';
import { beforeEach, vitest } from 'vitest';
import { beforeEach, describe, expect, it, vitest } from 'vitest';
vitest.useFakeTimers();
@@ -26,7 +27,7 @@ const hasDupe = {
duplicateId: 'duplicate-id',
};
describe(SearchService.name, () => {
describe(DuplicateService.name, () => {
let sut: DuplicateService;
let mocks: ServiceMocks;
@@ -41,6 +42,8 @@ describe(SearchService.name, () => {
describe('getDuplicates', () => {
it('should get duplicates', async () => {
const asset = AssetFactory.from().exif().build();
mocks.access.duplicate.checkOwnerAccess.mockResolvedValue(new Set(['duplicate-id']));
mocks.duplicateRepository.cleanupSingletonGroups.mockResolvedValue();
mocks.duplicateRepository.getAll.mockResolvedValue([
{
duplicateId: 'duplicate-id',
@@ -51,9 +54,24 @@ describe(SearchService.name, () => {
{
duplicateId: 'duplicate-id',
assets: [expect.objectContaining({ id: asset.id }), expect.objectContaining({ id: asset.id })],
suggestedKeepAssetIds: [asset.id],
},
]);
});
it('should return suggestedKeepAssetIds based on file size', async () => {
const smallAsset = AssetFactory.from().exif({ fileSizeInByte: 1000 }).build();
const largeAsset = AssetFactory.from().exif({ fileSizeInByte: 5000 }).build();
mocks.duplicateRepository.cleanupSingletonGroups.mockResolvedValue();
mocks.duplicateRepository.getAll.mockResolvedValue([
{
duplicateId: 'duplicate-id',
assets: [getForDuplicate(smallAsset), getForDuplicate(largeAsset)],
},
]);
const result = await sut.getDuplicates(authStub.admin);
expect(result[0].suggestedKeepAssetIds).toEqual([largeAsset.id]);
});
});
describe('handleQueueSearchDuplicates', () => {
@@ -131,6 +149,200 @@ describe(SearchService.name, () => {
});
});
describe('resolve', () => {
it('should handle mixed success and failure', async () => {
const asset = AssetFactory.create();
mocks.access.duplicate.checkOwnerAccess.mockResolvedValue(new Set(['group-1', 'group-2']));
mocks.duplicateRepository.get.mockResolvedValueOnce(void 0);
mocks.duplicateRepository.get.mockResolvedValueOnce({
duplicateId: 'group-2',
assets: [asset as unknown as MapAsset],
});
await expect(
sut.resolve(authStub.admin, {
groups: [
{ duplicateId: 'group-1', keepAssetIds: [], trashAssetIds: [] },
{ duplicateId: 'group-2', keepAssetIds: [asset.id], trashAssetIds: [] },
],
}),
).resolves.toEqual([
{ id: 'group-1', success: false, error: BulkIdErrorReason.NOT_FOUND },
{ id: 'group-2', success: true },
]);
});
it('should catch and report errors', async () => {
mocks.access.duplicate.checkOwnerAccess.mockResolvedValue(new Set(['group-1']));
mocks.duplicateRepository.get.mockRejectedValue(new Error('Database error'));
await expect(
sut.resolve(authStub.admin, {
groups: [{ duplicateId: 'group-1', keepAssetIds: [], trashAssetIds: [] }],
}),
).resolves.toEqual([{ id: 'group-1', success: false, error: BulkIdErrorReason.UNKNOWN }]);
});
});
describe('resolveGroup (via resolve)', () => {
it('should fail if duplicate group not found', async () => {
mocks.access.duplicate.checkOwnerAccess.mockResolvedValue(new Set(['missing-id']));
mocks.duplicateRepository.get.mockResolvedValue(void 0);
await expect(
sut.resolve(authStub.admin, {
groups: [{ duplicateId: 'missing-id', keepAssetIds: [], trashAssetIds: [] }],
}),
).resolves.toEqual([
{
id: 'missing-id',
success: false,
error: BulkIdErrorReason.NOT_FOUND,
},
]);
});
it('should skip when keepAssetIds contains non-member', async () => {
const asset = AssetFactory.create();
mocks.access.duplicate.checkOwnerAccess.mockResolvedValue(new Set(['group-1']));
mocks.duplicateRepository.get.mockResolvedValue({
duplicateId: 'group-1',
assets: [asset as unknown as MapAsset],
});
await expect(
sut.resolve(authStub.admin, {
groups: [{ duplicateId: 'group-1', keepAssetIds: ['asset-999', asset.id], trashAssetIds: [] }],
}),
).resolves.toEqual([{ id: 'group-1', success: true }]);
});
it('should skip when trashAssetIds contains non-member', async () => {
const asset = AssetFactory.create();
mocks.access.duplicate.checkOwnerAccess.mockResolvedValue(new Set(['group-1']));
mocks.duplicateRepository.get.mockResolvedValue({
duplicateId: 'group-1',
assets: [asset as unknown as MapAsset],
});
await expect(
sut.resolve(authStub.admin, {
groups: [{ duplicateId: 'group-1', keepAssetIds: [asset.id], trashAssetIds: ['asset-999'] }],
}),
).resolves.toEqual([{ id: 'group-1', success: true }]);
});
it('should fail if keepAssetIds and trashAssetIds overlap', async () => {
const asset1 = AssetFactory.create();
const asset2 = AssetFactory.create();
mocks.access.duplicate.checkOwnerAccess.mockResolvedValue(new Set(['group-1']));
mocks.duplicateRepository.get.mockResolvedValue({
duplicateId: 'group-1',
assets: [asset1 as unknown as MapAsset, asset2 as unknown as MapAsset],
});
const result = await sut.resolve(authStub.admin, {
groups: [{ duplicateId: 'group-1', keepAssetIds: [asset1.id], trashAssetIds: [asset1.id] }],
});
expect(result[0].success).toBe(false);
expect(result[0].errorMessage).toContain('An asset cannot be in both keepAssetIds and trashAssetIds');
});
it('should fail if keepAssetIds and trashAssetIds do not cover all assets', async () => {
const asset1 = AssetFactory.create();
const asset2 = AssetFactory.create();
const asset3 = AssetFactory.create();
mocks.access.duplicate.checkOwnerAccess.mockResolvedValue(new Set(['group-1']));
mocks.duplicateRepository.get.mockResolvedValue({
duplicateId: 'group-1',
assets: [asset1 as unknown as MapAsset, asset2 as unknown as MapAsset, asset3 as unknown as MapAsset],
});
const result = await sut.resolve(authStub.admin, {
groups: [{ duplicateId: 'group-1', keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
});
expect(result[0].success).toBe(false);
expect(result[0].errorMessage).toContain('Every asset must be in either keepAssetIds or trashAssetIds');
});
it('should fail if partial trash without keepers', async () => {
const asset1 = AssetFactory.create();
const asset2 = AssetFactory.create();
mocks.access.duplicate.checkOwnerAccess.mockResolvedValue(new Set(['group-1']));
mocks.duplicateRepository.get.mockResolvedValue({
duplicateId: 'group-1',
assets: [asset1 as unknown as MapAsset, asset2 as unknown as MapAsset],
});
const result = await sut.resolve(authStub.admin, {
groups: [{ duplicateId: 'group-1', keepAssetIds: [], trashAssetIds: [asset1.id] }],
});
expect(result[0].success).toBe(false);
expect(result[0].errorMessage).toContain('Every asset must be in either keepAssetIds or trashAssetIds');
});
it('should sync merged tags to asset_exif.tags', async () => {
const asset1 = AssetFactory.create();
const asset2 = AssetFactory.create();
mocks.access.duplicate.checkOwnerAccess.mockResolvedValue(new Set(['group-1']));
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-2']));
mocks.access.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-1', 'tag-2']));
mocks.duplicateRepository.get.mockResolvedValue({
duplicateId: 'group-1',
assets: [
{
...asset1,
tags: [
{
id: 'tag-1',
value: 'Work',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
parentId: null,
color: null,
},
],
},
{
...asset2,
tags: [
{
id: 'tag-2',
value: 'Travel',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
parentId: null,
color: null,
},
],
},
] as any,
});
const result = await sut.resolve(authStub.admin, {
groups: [{ duplicateId: 'group-1', keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }],
});
expect(result[0].success).toBe(true);
// Verify tags were applied to tag_asset table
expect(mocks.tag.replaceAssetTags).toHaveBeenCalledWith(asset1.id, ['tag-1', 'tag-2']);
// Verify merged tag values were written to asset_exif.tags so SidecarWrite preserves them
expect(mocks.asset.updateAllExif).toHaveBeenCalledWith([asset1.id], { tags: ['Work', 'Travel'] });
// Verify SidecarWrite was queued (to write tags to sidecar)
expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.SidecarWrite, data: { id: asset1.id } }]);
});
// NOTE: The following integration-style tests are covered by E2E tests instead
// to avoid complex mock setup. The validation and error-handling logic above
// is thoroughly unit tested.
});
describe('handleSearchDuplicates', () => {
beforeEach(() => {
mocks.systemMetadata.get.mockResolvedValue({
+275 -8
View File
@@ -1,24 +1,84 @@
import { Injectable } from '@nestjs/common';
import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants';
import { OnJob } from 'src/decorators';
import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { mapAsset } from 'src/dtos/asset-response.dto';
import { BulkIdErrorReason, BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { MapAsset, mapAsset } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { DuplicateResponseDto } from 'src/dtos/duplicate.dto';
import { AssetVisibility, JobName, JobStatus, QueueName } from 'src/enum';
import { DuplicateResolveDto, DuplicateResolveGroupDto, DuplicateResponseDto } from 'src/dtos/duplicate.dto';
import { AssetStatus, AssetVisibility, JobName, JobStatus, Permission, QueueName } from 'src/enum';
import { AssetDuplicateResult } from 'src/repositories/search.repository';
import { BaseService } from 'src/services/base.service';
import { JobItem, JobOf } from 'src/types';
import { suggestDuplicateKeepAssetIds } from 'src/utils/duplicate';
import { isDuplicateDetectionEnabled } from 'src/utils/misc';
type ResolveRequest = {
assetUpdate: {
isFavorite?: boolean;
visibility?: AssetVisibility;
};
exifUpdate: {
rating?: number;
latitude?: number;
longitude?: number;
description?: string;
};
mergedAlbumIds: string[];
mergedTagIds: string[];
mergedTagValues: string[];
};
const uniqueNonEmptyLines = (values: Array<string | null | undefined>): string[] => {
const unique = new Set<string>();
const lines: string[] = [];
for (const value of values) {
if (!value) {
continue;
}
for (const line of value.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || unique.has(trimmed)) {
continue;
}
unique.add(trimmed);
lines.push(trimmed);
}
}
return lines;
};
const getUniqueCoordinate = (assets: MapAsset[], key: 'latitude' | 'longitude'): number | null => {
const values = assets
.map((asset) => asset.exifInfo?.[key])
.filter((value): value is number => Number.isFinite(value));
if (values.length === 0) {
return null;
}
const unique = new Set(values);
return unique.size === 1 ? [...unique][0] : null;
};
@Injectable()
export class DuplicateService extends BaseService {
async getDuplicates(auth: AuthDto): Promise<DuplicateResponseDto[]> {
// Clean up singleton groups (assets that are the only member of their duplicate group)
await this.duplicateRepository.cleanupSingletonGroups(auth.user.id);
const duplicates = await this.duplicateRepository.getAll(auth.user.id);
return duplicates.map(({ duplicateId, assets }) => ({
duplicateId,
assets: assets.map((asset) => mapAsset(asset, { auth })),
}));
return duplicates.map(({ duplicateId, assets }) => {
const mappedAssets = assets.map((asset) => mapAsset(asset, { auth }));
return {
duplicateId,
assets: mappedAssets,
suggestedKeepAssetIds: suggestDuplicateKeepAssetIds(mappedAssets),
};
});
}
async delete(auth: AuthDto, id: string): Promise<void> {
@@ -29,6 +89,213 @@ export class DuplicateService extends BaseService {
await this.duplicateRepository.deleteAll(auth.user.id, dto.ids);
}
async resolve(auth: AuthDto, dto: DuplicateResolveDto) {
const duplicateIds = dto.groups.map(({ duplicateId }) => duplicateId);
await this.requireAccess({ auth, permission: Permission.DuplicateDelete, ids: duplicateIds });
const results: BulkIdResponseDto[] = [];
for (const group of dto.groups) {
try {
results.push(await this.resolveGroup(auth, group));
} catch (error: Error | any) {
this.logger.error(`Error resolving duplicate group ${group.duplicateId}: ${error}`, error?.stack);
results.push({ id: group.duplicateId, success: false, error: BulkIdErrorReason.UNKNOWN });
}
}
return results;
}
private async resolveGroup(auth: AuthDto, group: DuplicateResolveGroupDto): Promise<BulkIdResponseDto> {
const { duplicateId, keepAssetIds, trashAssetIds } = group;
const duplicateGroup = await this.duplicateRepository.get(duplicateId);
if (!duplicateGroup) {
return { id: duplicateId, success: false, error: BulkIdErrorReason.NOT_FOUND };
}
const groupAssetIds = new Set(duplicateGroup.assets.map((a) => a.id));
// ignore/skip asset IDs not in the group
const idsToKeep = keepAssetIds.filter((id) => groupAssetIds.has(id));
const idsToTrash = trashAssetIds.filter((id) => groupAssetIds.has(id));
for (const assetId of groupAssetIds) {
if (idsToKeep.includes(assetId) && idsToTrash.includes(assetId)) {
return {
id: duplicateId,
success: false,
error: BulkIdErrorReason.VALIDATION,
errorMessage: 'An asset cannot be in both keepAssetIds and trashAssetIds',
};
}
if (!idsToKeep.includes(assetId) && !idsToTrash.includes(assetId)) {
return {
id: duplicateId,
success: false,
error: BulkIdErrorReason.VALIDATION,
errorMessage: 'Every asset must be in either keepAssetIds or trashAssetIds',
};
}
}
if (idsToTrash.length > 0) {
const ids = await this.checkAccess({ auth, permission: Permission.AssetDelete, ids: idsToTrash });
if (ids.size !== idsToTrash.length) {
return {
id: duplicateId,
success: false,
error: BulkIdErrorReason.NO_PERMISSION,
errorMessage: 'No permission to delete assets',
};
}
}
const assetAlbumMap = await this.albumRepository.getByAssetIds(auth.user.id, [...groupAssetIds]);
const { assetUpdate, exifUpdate, mergedAlbumIds, mergedTagIds, mergedTagValues } = this.getSyncMergeResult(
duplicateGroup.assets,
assetAlbumMap,
);
if (mergedAlbumIds.length > 0) {
const allowedAlbumIds = await this.checkAccess({
auth,
permission: Permission.AlbumAssetCreate,
ids: mergedAlbumIds,
});
const allowedShareIds = await this.checkAccess({
auth,
permission: Permission.AssetShare,
ids: idsToKeep,
});
if (allowedAlbumIds.size > 0 && allowedShareIds.size > 0) {
await this.albumRepository.addAssetIdsToAlbums(
[...allowedAlbumIds].flatMap((albumId) => [...allowedShareIds].map((assetId) => ({ albumId, assetId }))),
);
}
}
if (mergedTagIds.length > 0) {
const allowedTagIds = await this.checkAccess({
auth,
permission: Permission.TagAsset,
ids: mergedTagIds,
});
if (allowedTagIds.size > 0) {
// Replace tags for each keeper asset to ensure all merged tags are applied
await Promise.all(idsToKeep.map((assetId) => this.tagRepository.replaceAssetTags(assetId, [...allowedTagIds])));
// Update asset_exif.tags so the subsequent SidecarWrite + MetadataExtraction
// cycle preserves the merged tags (updateAllExif locks the property automatically)
await this.assetRepository.updateAllExif(idsToKeep, { tags: mergedTagValues });
}
}
if (idsToKeep.length > 0) {
const hasExifUpdate = Object.keys(exifUpdate).length > 0;
const hasTagUpdate = mergedTagIds.length > 0;
if (hasExifUpdate) {
await this.assetRepository.updateAllExif(idsToKeep, exifUpdate);
}
if (hasExifUpdate || hasTagUpdate) {
await this.jobRepository.queueAll(idsToKeep.map((id) => ({ name: JobName.SidecarWrite, data: { id } })));
}
await this.assetRepository.updateAll(idsToKeep, { duplicateId: null, ...assetUpdate });
}
if (idsToTrash.length > 0) {
// TODO: this is duplicated with AssetService.deleteAssets
const { trash } = await this.getConfig({ withCache: true });
const force = !trash.enabled;
await this.assetRepository.updateAll(idsToTrash, {
deletedAt: new Date(),
status: force ? AssetStatus.Deleted : AssetStatus.Trashed,
duplicateId: null,
});
await this.eventRepository.emit(force ? 'AssetDeleteAll' : 'AssetTrashAll', {
assetIds: idsToTrash,
userId: auth.user.id,
});
}
return { id: duplicateId, success: true };
}
private getSyncMergeResult(assets: MapAsset[], assetAlbumMap: Map<string, string[]> = new Map()): ResolveRequest {
const response: ResolveRequest = {
mergedAlbumIds: [],
mergedTagIds: [],
mergedTagValues: [],
assetUpdate: {},
exifUpdate: {},
};
response.assetUpdate.isFavorite = assets.some((asset) => asset.isFavorite);
const visibilityOrder = [AssetVisibility.Locked, AssetVisibility.Archive, AssetVisibility.Timeline];
let visibility = visibilityOrder.find((level) => assets.some((asset) => asset.visibility === level));
if (!visibility && assets.some((asset) => asset.visibility === AssetVisibility.Hidden)) {
visibility = AssetVisibility.Hidden;
}
if (visibility) {
response.assetUpdate.visibility = visibility;
}
let rating = 0;
for (const asset of assets) {
const assetRating = asset.exifInfo?.rating ?? 0;
if (assetRating > rating) {
rating = assetRating;
}
}
if (rating > 0) {
response.exifUpdate.rating = rating;
}
const descriptionLines = uniqueNonEmptyLines(assets.map((asset) => asset.exifInfo?.description));
const description = descriptionLines.length > 0 ? descriptionLines.join('\n') : null;
if (description !== null) {
response.exifUpdate.description = description;
}
const latitude = getUniqueCoordinate(assets, 'latitude');
const longitude = getUniqueCoordinate(assets, 'longitude');
if (latitude !== null && longitude !== null) {
response.exifUpdate.latitude = latitude;
response.exifUpdate.longitude = longitude;
}
const albumIdSet = new Set<string>();
for (const [, albumIds] of assetAlbumMap) {
for (const albumId of albumIds) {
albumIdSet.add(albumId);
}
}
response.mergedAlbumIds = [...albumIdSet];
const allTags = assets.flatMap((asset) => asset.tags ?? []);
const tagIds = [...new Set(allTags.map((tag) => tag.id).filter((id): id is string => !!id))];
const tagValues = [...new Set(allTags.map((tag) => tag.value).filter((v): v is string => !!v))];
if (tagIds.length > 0) {
response.mergedTagIds = tagIds;
response.mergedTagValues = tagValues;
}
return response;
}
@OnJob({ name: JobName.AssetDetectDuplicatesQueueAll, queue: QueueName.DuplicateDetection })
async handleQueueSearchDuplicates({ force }: JobOf<JobName.AssetDetectDuplicatesQueueAll>): Promise<JobStatus> {
const { machineLearning } = await this.getConfig({ withCache: false });
+1 -1
View File
@@ -560,7 +560,7 @@ describe(LibraryService.name, () => {
paths: ['/data/user1/photo.jpg'],
};
mocks.asset.createAll.mockResolvedValue([asset]);
mocks.asset.createAll.mockResolvedValue([asset.id]);
mocks.library.get.mockResolvedValue(library);
await expect(sut.handleSyncFiles(mockLibraryJob)).resolves.toBe(JobStatus.Success);
+13 -8
View File
@@ -17,7 +17,17 @@ import {
ValidateLibraryImportPathResponseDto,
ValidateLibraryResponseDto,
} from 'src/dtos/library.dto';
import { AssetStatus, AssetType, CronJob, DatabaseLock, ImmichWorker, JobName, JobStatus, QueueName } from 'src/enum';
import {
AssetStatus,
AssetType,
ChecksumAlgorithm,
CronJob,
DatabaseLock,
ImmichWorker,
JobName,
JobStatus,
QueueName,
} from 'src/enum';
import { ArgOf } from 'src/repositories/event.repository';
import { AssetSyncResult } from 'src/repositories/library.repository';
import { AssetTable } from 'src/schema/tables/asset.table';
@@ -256,13 +266,7 @@ export class LibraryService extends BaseService {
),
);
const assetIds: string[] = [];
for (let i = 0; i < assetImports.length; i += 5000) {
// Chunk the imports to avoid the postgres limit of max parameters at once
const chunk = assetImports.slice(i, i + 5000);
await this.assetRepository.createAll(chunk).then((assets) => assetIds.push(...assets.map((asset) => asset.id)));
}
const assetIds = await this.assetRepository.createAll(assetImports);
const progressMessage =
job.progressCounter && job.totalAssets
@@ -400,6 +404,7 @@ export class LibraryService extends BaseService {
ownerId,
libraryId,
checksum: this.cryptoRepository.hashSha1(`path:${assetPath}`),
checksumAlgorithm: ChecksumAlgorithm.sha1Path,
originalPath: assetPath,
fileCreatedAt: stat.mtime,
+7 -1
View File
@@ -756,7 +756,13 @@ export class MediaService extends BaseService {
return false;
}
const name = formatLongName === 'QuickTime / MOV' ? VideoContainer.Mov : (formatName as VideoContainer);
const formatLongNameMapping: Record<string, VideoContainer> = {
'QuickTime / MOV': VideoContainer.Mov,
'Matroska / WebM': VideoContainer.Webm,
};
const name = (formatLongName ? formatLongNameMapping[formatLongName] : undefined) ?? (formatName as VideoContainer);
return name !== VideoContainer.Mp4 && !ffmpegConfig.acceptedContainers.includes(name);
}
+26 -2
View File
@@ -7,6 +7,7 @@ import {
AssetFileType,
AssetType,
AssetVisibility,
ChecksumAlgorithm,
ExifOrientation,
ImmichWorker,
JobName,
@@ -652,6 +653,7 @@ describe(MetadataService.name, () => {
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id);
expect(mocks.asset.create).toHaveBeenCalledWith({
checksum: expect.any(Buffer),
checksumAlgorithm: ChecksumAlgorithm.sha1File,
deviceAssetId: 'NONE',
deviceId: 'NONE',
fileCreatedAt: asset.fileCreatedAt,
@@ -705,6 +707,7 @@ describe(MetadataService.name, () => {
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id);
expect(mocks.asset.create).toHaveBeenCalledWith({
checksum: expect.any(Buffer),
checksumAlgorithm: ChecksumAlgorithm.sha1File,
deviceAssetId: 'NONE',
deviceId: 'NONE',
fileCreatedAt: asset.fileCreatedAt,
@@ -758,6 +761,7 @@ describe(MetadataService.name, () => {
expect(mocks.storage.readFile).toHaveBeenCalledWith(asset.originalPath, expect.any(Object));
expect(mocks.asset.create).toHaveBeenCalledWith({
checksum: expect.any(Buffer),
checksumAlgorithm: ChecksumAlgorithm.sha1File,
deviceAssetId: 'NONE',
deviceId: 'NONE',
fileCreatedAt: asset.fileCreatedAt,
@@ -1641,12 +1645,32 @@ describe(MetadataService.name, () => {
);
});
it('should not overwrite existing width/height if they already exist', async () => {
const asset = AssetFactory.create({ width: 1920, height: 1080 });
it('should overwrite existing width/height for unedited assets', async () => {
const asset = AssetFactory.create({ width: 1920, height: 1080, isEdited: false });
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
mockReadTags({ ImageWidth: 1280, ImageHeight: 720 });
await sut.handleMetadataExtraction({ id: asset.id });
expect(mocks.asset.update).toHaveBeenCalledWith(
expect.objectContaining({
width: 1280,
height: 720,
}),
);
});
it('should not overwrite existing width/height for edited assets', async () => {
const asset = AssetFactory.create({ width: 1920, height: 1080, isEdited: true });
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
mockReadTags({ ImageWidth: 1280, ImageHeight: 720 });
await sut.handleMetadataExtraction({ id: asset.id });
expect(mocks.asset.update).toHaveBeenCalledWith(
expect.objectContaining({
width: undefined,
height: undefined,
}),
);
expect(mocks.asset.update).not.toHaveBeenCalledWith(
expect.objectContaining({
width: 1280,
+5 -4
View File
@@ -14,6 +14,7 @@ import {
AssetFileType,
AssetType,
AssetVisibility,
ChecksumAlgorithm,
DatabaseLock,
ExifOrientation,
ImmichWorker,
@@ -327,10 +328,9 @@ export class MetadataService extends BaseService {
fileCreatedAt: dates.dateTimeOriginal ?? undefined,
fileModifiedAt: stats.mtime,
// only update the dimensions if they don't already exist
// we don't want to overwrite width/height that are modified by edits
width: asset.width == null ? assetWidth : undefined,
height: asset.height == null ? assetHeight : undefined,
// Keep unedited assets in sync with the file on disk, but don't overwrite edited dimensions.
width: !asset.isEdited || asset.width == null ? assetWidth : undefined,
height: !asset.isEdited || asset.height == null ? assetHeight : undefined,
}),
async () => {
await this.assetRepository.upsertExif(exifData, { lockedPropertiesBehavior: 'skip' });
@@ -676,6 +676,7 @@ export class MetadataService extends BaseService {
fileModifiedAt: stats.mtime,
localDateTime: dates.localDateTime,
checksum,
checksumAlgorithm: ChecksumAlgorithm.sha1File,
ownerId: asset.ownerId,
originalPath: StorageCore.getAndroidMotionPath(asset, motionAssetId),
originalFileName: `${parse(asset.originalFileName).name}.mp4`,
+87 -1
View File
@@ -12,7 +12,13 @@ import { PersonFactory } from 'test/factories/person.factory';
import { UserFactory } from 'test/factories/user.factory';
import { authStub } from 'test/fixtures/auth.stub';
import { systemConfigStub } from 'test/fixtures/system-config.stub';
import { getAsDetectedFace, getForAssetFace, getForDetectedFaces, getForFacialRecognitionJob } from 'test/mappers';
import {
getAsDetectedFace,
getForAsset,
getForAssetFace,
getForDetectedFaces,
getForFacialRecognitionJob,
} from 'test/mappers';
import { newDate, newUuid } from 'test/small.factory';
import { makeStream, newTestService, ServiceMocks } from 'test/utils';
@@ -370,6 +376,86 @@ describe(PersonService.name, () => {
});
});
describe('createFace', () => {
it('should create a manual face and initialize the person feature photo creation', async () => {
const auth = AuthFactory.create();
const asset = AssetFactory.create();
const person = PersonFactory.create({ faceAssetId: null });
const featureFace = AssetFaceFactory.create({
assetId: asset.id,
personId: person.id,
sourceType: SourceType.Manual,
});
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id]));
mocks.asset.getById.mockResolvedValue(getForAsset(asset));
mocks.person.getById.mockResolvedValue(person);
mocks.person.getRandomFace.mockResolvedValue(featureFace);
mocks.person.update.mockResolvedValue({ ...person, faceAssetId: featureFace.id });
await expect(
sut.createFace(auth, {
assetId: asset.id,
personId: person.id,
imageHeight: 500,
imageWidth: 400,
x: 10,
y: 20,
width: 100,
height: 110,
}),
).resolves.toBeUndefined();
expect(mocks.asset.getById).toHaveBeenCalledWith(asset.id, { edits: true, exifInfo: true });
expect(mocks.person.createAssetFace).toHaveBeenCalledWith({
assetId: asset.id,
personId: person.id,
imageHeight: 500,
imageWidth: 400,
boundingBoxX1: 10,
boundingBoxX2: 110,
boundingBoxY1: 20,
boundingBoxY2: 130,
sourceType: SourceType.Manual,
});
expect(mocks.person.getRandomFace).toHaveBeenCalledWith(person.id);
expect(mocks.person.update).toHaveBeenCalledWith({ id: person.id, faceAssetId: featureFace.id });
expect(mocks.job.queueAll).toHaveBeenCalledWith([
{ name: JobName.PersonGenerateThumbnail, data: { id: person.id } },
]);
});
it('should not update the person feature photo if one already exists', async () => {
const auth = AuthFactory.create();
const asset = AssetFactory.create();
const person = PersonFactory.create({ faceAssetId: newUuid() });
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id]));
mocks.asset.getById.mockResolvedValue(getForAsset(asset));
mocks.person.getById.mockResolvedValue(person);
await expect(
sut.createFace(auth, {
assetId: asset.id,
personId: person.id,
imageHeight: 500,
imageWidth: 400,
x: 10,
y: 20,
width: 100,
height: 110,
}),
).resolves.toBeUndefined();
expect(mocks.person.createAssetFace).toHaveBeenCalledOnce();
expect(mocks.person.getRandomFace).not.toHaveBeenCalled();
expect(mocks.person.update).not.toHaveBeenCalled();
expect(mocks.job.queueAll).not.toHaveBeenCalled();
});
});
describe('createNewFeaturePhoto', () => {
it('should change person feature photo', async () => {
const person = PersonFactory.create();
+9 -1
View File
@@ -631,7 +631,11 @@ export class PersonService extends BaseService {
this.requireAccess({ auth, permission: Permission.PersonRead, ids: [dto.personId] }),
]);
const asset = await this.assetRepository.getById(dto.assetId, { edits: true, exifInfo: true });
const [asset, person] = await Promise.all([
this.assetRepository.getById(dto.assetId, { edits: true, exifInfo: true }),
this.findOrFail(dto.personId),
]);
if (!asset) {
throw new NotFoundException('Asset not found');
}
@@ -689,6 +693,10 @@ export class PersonService extends BaseService {
boundingBoxY2: Math.round(bottomRight.y),
sourceType: SourceType.Manual,
});
if (!person.faceAssetId) {
await this.createNewFeaturePhoto([person.id]);
}
}
async deleteFace(auth: AuthDto, id: string, dto: AssetFaceDeleteDto): Promise<void> {
+42 -33
View File
@@ -2,20 +2,14 @@ import { DateTime } from 'luxon';
import { SemVer } from 'semver';
import { defaults } from 'src/config';
import { serverVersion } from 'src/constants';
import { ImmichEnvironment, JobName, JobStatus, SystemMetadataKey } from 'src/enum';
import { CronJob, JobName, JobStatus, SystemMetadataKey } from 'src/enum';
import { VersionService } from 'src/services/version.service';
import { mockEnvData } from 'test/repositories/config.repository.mock';
import { factory } from 'test/small.factory';
import { newTestService, ServiceMocks } from 'test/utils';
const mockRelease = (version: string) => ({
id: 1,
url: 'https://api.github.com/repos/owner/repo/releases/1',
tag_name: version,
name: 'Release 1000',
created_at: DateTime.utc().toISO(),
const mockVersionResponse = (version: string) => ({
version,
published_at: DateTime.utc().toISO(),
body: '',
});
describe(VersionService.name, () => {
@@ -24,6 +18,8 @@ describe(VersionService.name, () => {
beforeEach(() => {
({ sut, mocks } = newTestService(VersionService));
mocks.cron.create.mockResolvedValue();
mocks.cron.update.mockResolvedValue();
});
it('should work', () => {
@@ -50,6 +46,21 @@ describe(VersionService.name, () => {
await expect(sut.onBootstrap()).resolves.toBeUndefined();
expect(mocks.versionHistory.create).not.toHaveBeenCalled();
});
it('should create a version check cron job when the database lock is acquired', async () => {
mocks.database.tryLock.mockResolvedValue(true);
mocks.versionHistory.getLatest.mockResolvedValue({
id: 'version-1',
createdAt: new Date(),
version: serverVersion.toString(),
});
await sut.onBootstrap();
expect(mocks.cron.create).toHaveBeenCalledWith(
expect.objectContaining({
name: CronJob.VersionCheck,
}),
);
});
});
describe('getVersion', () => {
@@ -78,34 +89,32 @@ describe(VersionService.name, () => {
});
describe('handVersionCheck', () => {
beforeEach(() => {
mocks.config.getEnv.mockReturnValue(mockEnvData({ environment: ImmichEnvironment.Production }));
});
it('should not run in dev mode', async () => {
mocks.config.getEnv.mockReturnValue(mockEnvData({ environment: ImmichEnvironment.Development }));
await expect(sut.handleVersionCheck()).resolves.toEqual(JobStatus.Skipped);
});
it('should not run if the last check was < 60 minutes ago', async () => {
mocks.systemMetadata.get.mockResolvedValue({
checkedAt: DateTime.utc().minus({ minutes: 5 }).toISO(),
releaseVersion: '1.0.0',
});
await expect(sut.handleVersionCheck()).resolves.toEqual(JobStatus.Skipped);
});
it('should not run if version check is disabled', async () => {
mocks.systemMetadata.get.mockResolvedValue({ newVersionCheck: { enabled: false } });
await expect(sut.handleVersionCheck()).resolves.toEqual(JobStatus.Skipped);
});
it('should run if it has been > 60 minutes', async () => {
mocks.serverInfo.getGitHubRelease.mockResolvedValue(mockRelease('v100.0.0'));
mocks.systemMetadata.get.mockResolvedValue({
checkedAt: DateTime.utc().minus({ minutes: 65 }).toISO(),
it('should skip if the last check was less than 50 seconds ago', async () => {
mocks.systemMetadata.get.mockResolvedValueOnce(null).mockResolvedValueOnce({
checkedAt: DateTime.utc().minus({ seconds: 30 }).toISO(),
releaseVersion: '1.0.0',
});
await expect(sut.handleVersionCheck()).resolves.toEqual(JobStatus.Skipped);
expect(mocks.serverInfo.getLatestRelease).not.toHaveBeenCalled();
});
it('should run if the last check was more than 50 seconds ago', async () => {
mocks.systemMetadata.get.mockResolvedValueOnce(null).mockResolvedValueOnce({
checkedAt: DateTime.utc().minus({ seconds: 60 }).toISO(),
releaseVersion: '1.0.0',
});
mocks.serverInfo.getLatestRelease.mockResolvedValue(mockVersionResponse(serverVersion.toString()));
await expect(sut.handleVersionCheck()).resolves.toEqual(JobStatus.Success);
expect(mocks.serverInfo.getLatestRelease).toHaveBeenCalled();
});
it('should run and notify if a new version is available', async () => {
mocks.serverInfo.getLatestRelease.mockResolvedValue(mockVersionResponse('v100.0.0'));
await expect(sut.handleVersionCheck()).resolves.toEqual(JobStatus.Success);
expect(mocks.systemMetadata.set).toHaveBeenCalled();
expect(mocks.logger.log).toHaveBeenCalled();
@@ -113,7 +122,7 @@ describe(VersionService.name, () => {
});
it('should not notify if the version is equal', async () => {
mocks.serverInfo.getGitHubRelease.mockResolvedValue(mockRelease(serverVersion.toString()));
mocks.serverInfo.getLatestRelease.mockResolvedValue(mockVersionResponse(serverVersion.toString()));
await expect(sut.handleVersionCheck()).resolves.toEqual(JobStatus.Success);
expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.VersionCheckState, {
checkedAt: expect.any(String),
@@ -122,8 +131,8 @@ describe(VersionService.name, () => {
expect(mocks.websocket.clientBroadcast).not.toHaveBeenCalled();
});
it('should handle a github error', async () => {
mocks.serverInfo.getGitHubRelease.mockRejectedValue(new Error('GitHub is down'));
it('should handle a version check error', async () => {
mocks.serverInfo.getLatestRelease.mockRejectedValue(new Error('Version service is down'));
await expect(sut.handleVersionCheck()).resolves.toEqual(JobStatus.Failed);
expect(mocks.systemMetadata.set).not.toHaveBeenCalled();
expect(mocks.websocket.clientBroadcast).not.toHaveBeenCalled();
+19 -13
View File
@@ -4,10 +4,11 @@ import semver, { SemVer } from 'semver';
import { serverVersion } from 'src/constants';
import { OnEvent, OnJob } from 'src/decorators';
import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.dto';
import { DatabaseLock, ImmichEnvironment, JobName, JobStatus, QueueName, SystemMetadataKey } from 'src/enum';
import { CronJob, DatabaseLock, ImmichWorker, JobName, JobStatus, QueueName, SystemMetadataKey } from 'src/enum';
import { ArgOf } from 'src/repositories/event.repository';
import { BaseService } from 'src/services/base.service';
import { VersionCheckMetadata } from 'src/types';
import { handlePromiseError } from 'src/utils/misc';
const asNotification = ({ checkedAt, releaseVersion }: VersionCheckMetadata): ReleaseNotification => {
return {
@@ -20,9 +21,21 @@ const asNotification = ({ checkedAt, releaseVersion }: VersionCheckMetadata): Re
@Injectable()
export class VersionService extends BaseService {
@OnEvent({ name: 'AppBootstrap' })
@OnEvent({ name: 'AppBootstrap', workers: [ImmichWorker.Microservices] })
async onBootstrap(): Promise<void> {
await this.handleVersionCheck();
const hasLock = await this.databaseRepository.tryLock(DatabaseLock.VersionCheck);
if (hasLock) {
await this.handleVersionCheck();
const randomMinute = Math.floor(Math.random() * 60);
const expression = `${randomMinute} * * * *`;
this.logger.debug(`Scheduling version check for cron ${expression}`);
this.cronRepository.create({
name: CronJob.VersionCheck,
expression,
onTick: () => handlePromiseError(this.handleQueueVersionCheck(), this.logger),
});
}
await this.databaseRepository.withLock(DatabaseLock.VersionHistory, async () => {
const previous = await this.versionRepository.getLatest();
@@ -71,11 +84,6 @@ export class VersionService extends BaseService {
try {
this.logger.debug('Running version check');
const { environment } = this.configRepository.getEnv();
if (environment === ImmichEnvironment.Development) {
return JobStatus.Skipped;
}
const { newVersionCheck } = await this.getConfig({ withCache: true });
if (!newVersionCheck.enabled) {
return JobStatus.Skipped;
@@ -84,15 +92,13 @@ export class VersionService extends BaseService {
const versionCheck = await this.systemMetadataRepository.get(SystemMetadataKey.VersionCheckState);
if (versionCheck?.checkedAt) {
const lastUpdate = DateTime.fromISO(versionCheck.checkedAt);
const elapsedTime = DateTime.now().diff(lastUpdate).as('minutes');
// check once per hour (max)
if (elapsedTime < 60) {
const elapsedTime = DateTime.now().diff(lastUpdate).as('seconds');
if (elapsedTime < 50) {
return JobStatus.Skipped;
}
}
const { tag_name: releaseVersion, published_at: publishedAt } =
await this.serverInfoRepository.getGitHubRelease();
const { version: releaseVersion, published_at: publishedAt } = await this.serverInfoRepository.getLatestRelease();
const metadata: VersionCheckMetadata = { checkedAt: DateTime.utc().toISO(), releaseVersion };
await this.systemMetadataRepository.set(SystemMetadataKey.VersionCheckState, metadata);
+5
View File
@@ -241,6 +241,11 @@ const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRe
return ids.has(auth.user.id) ? new Set([auth.user.id]) : new Set();
}
case Permission.DuplicateRead:
case Permission.DuplicateDelete: {
return access.duplicate.checkOwnerAccess(auth.user.id, ids);
}
case Permission.AuthDeviceDelete: {
return await access.authDevice.checkOwnerAccess(auth.user.id, ids);
}
+3 -2
View File
@@ -126,12 +126,13 @@ export function withFiles(eb: ExpressionBuilder<DB, 'asset'>, type?: AssetFileTy
).as('files');
}
export function withFilePath(eb: ExpressionBuilder<DB, 'asset'>, type: AssetFileType) {
export function withFilePath(eb: ExpressionBuilder<DB, 'asset'>, type: AssetFileType, isEdited = false) {
return eb
.selectFrom('asset_file')
.select('asset_file.path')
.whereRef('asset_file.assetId', '=', 'asset.id')
.where('asset_file.type', '=', type);
.where('asset_file.type', '=', sql.lit(type))
.where('asset_file.isEdited', '=', sql.lit(isEdited));
}
export function withFacesAndPeople(
+178
View File
@@ -0,0 +1,178 @@
import { AssetResponseDto } from 'src/dtos/asset-response.dto';
import { AssetType, AssetVisibility } from 'src/enum';
import { getExifCount, suggestDuplicate, suggestDuplicateKeepAssetIds } from 'src/utils/duplicate';
import { describe, expect, it } from 'vitest';
const createAsset = (
id: string,
fileSizeInByte: number | null = null,
exifFields: Record<string, unknown> = {},
): AssetResponseDto => ({
id,
type: AssetType.Image,
thumbhash: null,
localDateTime: new Date().toISOString(),
duration: '0:00:00.00000',
hasMetadata: true,
width: 1920,
height: 1080,
createdAt: new Date().toISOString(),
deviceAssetId: 'device-asset-1',
deviceId: 'device-1',
ownerId: 'owner-1',
originalPath: '/path/to/asset',
originalFileName: 'asset.jpg',
fileCreatedAt: new Date().toISOString(),
fileModifiedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
isFavorite: false,
isArchived: false,
isTrashed: false,
isOffline: false,
isEdited: false,
visibility: AssetVisibility.Timeline,
checksum: 'checksum',
exifInfo:
fileSizeInByte !== null || Object.keys(exifFields).length > 0 ? { fileSizeInByte, ...exifFields } : undefined,
});
describe('duplicate utils', () => {
describe('getExifCount', () => {
it('should return 0 for asset without exifInfo', () => {
const asset = createAsset('asset-1');
asset.exifInfo = undefined;
expect(getExifCount(asset)).toBe(0);
});
it('should return 0 for empty exifInfo', () => {
const asset = createAsset('asset-1');
asset.exifInfo = {};
expect(getExifCount(asset)).toBe(0);
});
it('should count all truthy values in exifInfo', () => {
const asset = createAsset('asset-1', 1000, {
make: 'Canon',
model: 'EOS 5D',
dateTimeOriginal: new Date(),
timeZone: 'UTC',
latitude: 40.7128,
longitude: -74.006,
city: 'New York',
state: 'NY',
country: 'USA',
description: 'A photo',
rating: 5,
});
// fileSizeInByte (1000) + 11 other truthy fields = 12
expect(getExifCount(asset)).toBe(12);
});
it('should not count null or undefined values', () => {
const asset = createAsset('asset-1', 1000, {
make: 'Canon',
model: null,
latitude: undefined,
city: '',
rating: 0,
});
// fileSizeInByte (1000) + make ('Canon') = 2 truthy values
// model (null), latitude (undefined), city (''), rating (0) are all falsy
expect(getExifCount(asset)).toBe(2);
});
});
describe('suggestDuplicate', () => {
it('should return undefined for empty list', () => {
expect(suggestDuplicate([])).toBeUndefined();
});
it('should return the single asset for list with one asset', () => {
const asset = createAsset('asset-1', 1000);
expect(suggestDuplicate([asset])).toEqual(asset);
});
it('should return asset with largest file size', () => {
const small = createAsset('small', 1000);
const large = createAsset('large', 5000);
const medium = createAsset('medium', 3000);
expect(suggestDuplicate([small, large, medium])?.id).toBe('large');
expect(suggestDuplicate([large, small, medium])?.id).toBe('large');
expect(suggestDuplicate([medium, small, large])?.id).toBe('large');
});
it('should use EXIF count as tie-breaker when file sizes are equal', () => {
const lessExif = createAsset('less-exif', 1000, { make: 'Canon' });
const moreExif = createAsset('more-exif', 1000, {
make: 'Canon',
model: 'EOS 5D',
dateTimeOriginal: new Date(),
city: 'New York',
});
expect(suggestDuplicate([lessExif, moreExif])?.id).toBe('more-exif');
expect(suggestDuplicate([moreExif, lessExif])?.id).toBe('more-exif');
});
it('should handle assets with no exifInfo (treat as 0 file size)', () => {
const noExif = createAsset('no-exif');
noExif.exifInfo = undefined;
const withExif = createAsset('with-exif', 1000);
expect(suggestDuplicate([noExif, withExif])?.id).toBe('with-exif');
});
it('should handle assets with exifInfo but no fileSizeInByte', () => {
const noFileSize = createAsset('no-file-size');
noFileSize.exifInfo = { make: 'Canon', model: 'EOS 5D' };
const withFileSize = createAsset('with-file-size', 1000);
expect(suggestDuplicate([noFileSize, withFileSize])?.id).toBe('with-file-size');
});
it('should return last asset when all have same file size and EXIF count', () => {
const asset1 = createAsset('asset-1', 1000, { make: 'Canon' });
const asset2 = createAsset('asset-2', 1000, { make: 'Nikon' });
// Both have same file size (1000) and same EXIF count (2: fileSizeInByte + make)
// Should return the last one in the sorted array
const result = suggestDuplicate([asset1, asset2]);
// Since they're equal, the last one after sorting should be returned
expect(result).toBeDefined();
expect(['asset-1', 'asset-2']).toContain(result?.id);
});
it('should prioritize file size over EXIF count', () => {
const largeWithLessExif = createAsset('large-less-exif', 5000, { make: 'Canon' });
const smallWithMoreExif = createAsset('small-more-exif', 1000, {
make: 'Canon',
model: 'EOS 5D',
dateTimeOriginal: new Date(),
city: 'New York',
state: 'NY',
country: 'USA',
});
expect(suggestDuplicate([largeWithLessExif, smallWithMoreExif])?.id).toBe('large-less-exif');
});
});
describe('suggestDuplicateKeepAssetIds', () => {
it('should return empty array for empty list', () => {
expect(suggestDuplicateKeepAssetIds([])).toEqual([]);
});
it('should return array with single asset ID', () => {
const asset = createAsset('asset-1', 1000);
expect(suggestDuplicateKeepAssetIds([asset])).toEqual(['asset-1']);
});
it('should return array with best asset ID', () => {
const small = createAsset('small', 1000);
const large = createAsset('large', 5000);
expect(suggestDuplicateKeepAssetIds([small, large])).toEqual(['large']);
});
});
});
+60
View File
@@ -0,0 +1,60 @@
import { AssetResponseDto } from 'src/dtos/asset-response.dto';
/**
* Counts all truthy values in the exifInfo object.
* This matches the client implementation in web/src/lib/utils/exif-utils.ts
*
* @param asset Asset with optional exifInfo
* @returns Count of truthy EXIF values
*/
export const getExifCount = (asset: AssetResponseDto): number => {
return Object.values(asset.exifInfo ?? {}).filter(Boolean).length;
};
/**
* Suggests the best duplicate asset to keep from a list of duplicates.
* This is a direct port of the client logic from web/src/lib/utils/duplicate-utils.ts
*
* The best asset is determined by the following criteria:
* 1. Largest image file size in bytes
* 2. Largest count of EXIF data (as tie-breaker)
*
* @param assets List of duplicate assets
* @returns The best asset to keep, or undefined if empty list
*/
export const suggestDuplicate = (assets: AssetResponseDto[]): AssetResponseDto | undefined => {
if (assets.length === 0) {
return undefined;
}
// Sort by file size ascending (smallest first)
let duplicateAssets = [...assets].toSorted(
(a, b) => (a.exifInfo?.fileSizeInByte ?? 0) - (b.exifInfo?.fileSizeInByte ?? 0),
);
// Get the largest file size (last element after sorting)
const largestFileSize = duplicateAssets.at(-1)?.exifInfo?.fileSizeInByte ?? 0;
// Filter to keep only assets with the largest file size
duplicateAssets = duplicateAssets.filter((asset) => (asset.exifInfo?.fileSizeInByte ?? 0) === largestFileSize);
// If there are multiple assets with the same file size, sort by EXIF count
if (duplicateAssets.length >= 2) {
duplicateAssets = duplicateAssets.toSorted((a, b) => getExifCount(a) - getExifCount(b));
}
// Return the last asset (highest EXIF count among highest file size)
return duplicateAssets.at(-1);
};
/**
* Suggests the best duplicate asset IDs to keep from a list of duplicates.
* Returns an array with a single asset ID (the best candidate), or empty if no assets.
*
* @param assets List of duplicate assets
* @returns Array of suggested asset IDs to keep (0 or 1 element)
*/
export const suggestDuplicateKeepAssetIds = (assets: AssetResponseDto[]): string[] => {
const suggested = suggestDuplicate(assets);
return suggested ? [suggested.id] : [];
};
+18
View File
@@ -0,0 +1,18 @@
import { serverVersion } from 'src/constants';
import { configureUserAgent } from 'src/utils/fetch';
describe('fetch', () => {
it('should set the default user-agent header', async () => {
const spy = vi.fn().mockResolvedValue(new Response());
const original = globalThis.fetch;
globalThis.fetch = spy;
configureUserAgent();
await globalThis.fetch('http://test.local');
const headers: Headers = spy.mock.calls[0][1].headers;
expect(headers.get('User-Agent')).toBe(`immich-server/${serverVersion}`);
globalThis.fetch = original;
});
});
+12
View File
@@ -0,0 +1,12 @@
import { serverVersion } from 'src/constants';
export function configureUserAgent() {
const originalFetch = globalThis.fetch;
globalThis.fetch = (input, init) => {
const headers = new Headers(init?.headers);
if (!headers.has('User-Agent')) {
headers.set('User-Agent', `immich-server/${serverVersion}`);
}
return originalFetch(input, { ...init, headers });
};
}
+1
View File
@@ -83,6 +83,7 @@ describe('mimeTypes', () => {
{ mimetype: 'video/mp2t', extension: '.m2t' },
{ mimetype: 'video/mp2t', extension: '.m2ts' },
{ mimetype: 'video/mp2t', extension: '.mts' },
{ mimetype: 'video/mp2t', extension: '.ts' },
{ mimetype: 'video/mp4', extension: '.mp4' },
{ mimetype: 'video/mpeg', extension: '.mpe' },
{ mimetype: 'video/mpeg', extension: '.mpeg' },
+1
View File
@@ -114,6 +114,7 @@ const video: Record<string, string[]> = {
'.mpg': ['video/mpeg'],
'.mts': ['video/mp2t'],
'.mxf': ['application/mxf'],
'.ts': ['video/mp2t'],
'.vob': ['video/mpeg'],
'.webm': ['video/webm'],
'.wmv': ['video/x-ms-wmv'],
+29
View File
@@ -0,0 +1,29 @@
import { getAppVersionFromUA } from 'src/utils/request';
describe(getAppVersionFromUA.name, () => {
it('should get the app version for android', () => {
expect(getAppVersionFromUA('immich-android/1.123.4')).toEqual('1.123.4');
});
it('should get the app version for ios', () => {
expect(getAppVersionFromUA('immich-ios/1.123.4')).toEqual('1.123.4');
});
it('should get the app version for unknown', () => {
expect(getAppVersionFromUA('immich-unknown/1.123.4')).toEqual('1.123.4');
});
describe('legacy format', () => {
it('should get the app version from the old android format', () => {
expect(getAppVersionFromUA('Immich_Android_1.123.4')).toEqual('1.123.4');
});
it('should get the app version from the old ios format', () => {
expect(getAppVersionFromUA('Immich_iOS_1.123.4')).toEqual('1.123.4');
});
it('should get the app version from the old unknown format', () => {
expect(getAppVersionFromUA('Immich_Unknown_1.123.4')).toEqual('1.123.4');
});
});
});
+5 -2
View File
@@ -7,8 +7,11 @@ export const fromChecksum = (checksum: string): Buffer => {
export const fromMaybeArray = <T>(param: T | T[]) => (Array.isArray(param) ? param[0] : param);
const getAppVersionFromUA = (ua: string) =>
ua.match(/^Immich_(?:Android|iOS)_(?<appVersion>.+)$/)?.groups?.appVersion ?? null;
export const getAppVersionFromUA = (ua: string) =>
ua.match(/^immich-(?:android|ios|unknown)\/(?<appVersion>.+)$/)?.groups?.appVersion ??
// legacy format
ua.match(/^Immich_(?:Android|iOS|Unknown)_(?<appVersion>.+)$/)?.groups?.appVersion ??
null;
export const getUserAgentDetails = (headers: IncomingHttpHeaders) => {
const userAgent = UAParser(headers['user-agent']);
+2 -1
View File
@@ -1,5 +1,5 @@
import { Selectable } from 'kysely';
import { AssetFileType, AssetStatus, AssetType, AssetVisibility } from 'src/enum';
import { AssetFileType, AssetStatus, AssetType, AssetVisibility, ChecksumAlgorithm } from 'src/enum';
import { AssetTable } from 'src/schema/tables/asset.table';
import { StackTable } from 'src/schema/tables/stack.table';
import { AssetEditFactory } from 'test/factories/asset-edit.factory';
@@ -53,6 +53,7 @@ export class AssetFactory {
updateId: newUuidV7(),
status: AssetStatus.Active,
checksum: newSha1(),
checksumAlgorithm: ChecksumAlgorithm.sha1File,
deviceAssetId: '',
deviceId: '',
duplicateId: null,
+8 -4
View File
@@ -1,4 +1,5 @@
import { Selectable, ShallowDehydrateObject } from 'kysely';
import { MapAsset } from 'src/dtos/asset-response.dto';
import { AssetEditActionItem } from 'src/dtos/editing.dto';
import { ActivityTable } from 'src/schema/tables/activity.table';
import { AssetTable } from 'src/schema/tables/asset.table';
@@ -125,6 +126,7 @@ export const getForMemory = (memory: ReturnType<MemoryFactory['build']>) => ({
export const getForMetadataExtraction = (asset: ReturnType<AssetFactory['build']>) => ({
id: asset.id,
checksum: asset.checksum,
checksumAlgorithm: asset.checksumAlgorithm,
deviceAssetId: asset.deviceAssetId,
deviceId: asset.deviceId,
fileCreatedAt: asset.fileCreatedAt,
@@ -138,6 +140,7 @@ export const getForMetadataExtraction = (asset: ReturnType<AssetFactory['build']
originalPath: asset.originalPath,
ownerId: asset.ownerId,
type: asset.type,
isEdited: asset.isEdited,
width: asset.width,
height: asset.height,
faces: asset.faces.map((face) => getDehydrated(face)),
@@ -203,10 +206,11 @@ export const getForStack = (stack: ReturnType<StackFactory['build']>) => ({
})),
});
export const getForDuplicate = (asset: ReturnType<AssetFactory['build']>) => ({
...getDehydrated(asset),
exifInfo: getDehydrated(asset.exifInfo),
});
export const getForDuplicate = (asset: ReturnType<AssetFactory['build']>) =>
({
...getDehydrated(asset),
exifInfo: getDehydrated(asset.exifInfo),
}) as unknown as MapAsset;
export const getForSharedLink = (sharedLink: ReturnType<SharedLinkFactory['build']>) => ({
...sharedLink,
+7
View File
@@ -12,6 +12,7 @@ import {
AlbumUserRole,
AssetType,
AssetVisibility,
ChecksumAlgorithm,
MemoryType,
SourceType,
SyncEntityType,
@@ -25,6 +26,7 @@ import { AssetEditRepository } from 'src/repositories/asset-edit.repository';
import { AssetJobRepository } from 'src/repositories/asset-job.repository';
import { AssetRepository } from 'src/repositories/asset.repository';
import { ConfigRepository } from 'src/repositories/config.repository';
import { CronRepository } from 'src/repositories/cron.repository';
import { CryptoRepository } from 'src/repositories/crypto.repository';
import { DatabaseRepository } from 'src/repositories/database.repository';
import { EmailRepository } from 'src/repositories/email.repository';
@@ -499,6 +501,10 @@ const newMockRepository = <T>(key: ClassConstructor<T>) => {
});
}
case CronRepository: {
return automock(CronRepository, { args: [undefined, { setContext: () => {} }], strict: false });
}
case EmailRepository: {
return automock(EmailRepository, { args: [{ setContext: () => {} }] });
}
@@ -547,6 +553,7 @@ const assetInsert = (asset: Partial<Insertable<AssetTable>> = {}) => {
deviceId: '',
originalFileName: '',
checksum: randomBytes(32),
checksumAlgorithm: ChecksumAlgorithm.sha1File,
type: AssetType.Image,
originalPath: '/path/to/something.jpg',
ownerId: 'not-a-valid-uuid',
@@ -115,4 +115,33 @@ describe(AssetJobRepository.name, () => {
);
});
});
describe('getForOcr', () => {
it('should not return the edited preview file', async () => {
const { ctx, sut } = setup();
const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id });
await ctx.newAssetFile({
assetId: asset.id,
type: AssetFileType.Preview,
path: 'preview_edited.jpg',
isEdited: true,
});
await ctx.newAssetFile({
assetId: asset.id,
type: AssetFileType.Preview,
path: 'preview_unedited.jpg',
isEdited: false,
});
const result = await sut.getForOcr(asset.id);
expect(result).toEqual(
expect.objectContaining({
previewFile: 'preview_unedited.jpg',
}),
);
});
});
});
@@ -5,6 +5,7 @@ import { AccessRepository } from 'src/repositories/access.repository';
import { AssetEditRepository } from 'src/repositories/asset-edit.repository';
import { AssetRepository } from 'src/repositories/asset.repository';
import { DatabaseRepository } from 'src/repositories/database.repository';
import { JobRepository } from 'src/repositories/job.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { PersonRepository } from 'src/repositories/person.repository';
import { StorageRepository } from 'src/repositories/storage.repository';
@@ -20,7 +21,7 @@ const setup = (db?: Kysely<DB>) => {
return newMediumService(PersonService, {
database: db || defaultDatabase,
real: [AccessRepository, DatabaseRepository, PersonRepository, AssetRepository, AssetEditRepository],
mock: [LoggingRepository, StorageRepository],
mock: [JobRepository, LoggingRepository, StorageRepository],
});
};
@@ -89,6 +90,7 @@ describe(PersonService.name, () => {
const { person } = await ctx.newPerson({ ownerId: user.id });
const { asset } = await ctx.newAsset({ id: factory.uuid(), ownerId: user.id, width: 200, height: 200 });
await ctx.newExif({ assetId: asset.id, exifImageHeight: 200, exifImageWidth: 200 });
ctx.getMock(JobRepository).queueAll.mockResolvedValue();
const auth = factory.auth({ user });
@@ -128,6 +130,7 @@ describe(PersonService.name, () => {
const { person } = await ctx.newPerson({ ownerId: user.id });
const { asset } = await ctx.newAsset({ id: factory.uuid(), ownerId: user.id, width: 150, height: 200 });
await ctx.newExif({ assetId: asset.id, exifImageHeight: 200, exifImageWidth: 200 });
ctx.getMock(JobRepository).queueAll.mockResolvedValue();
await ctx.newEdits(asset.id, {
edits: [
@@ -199,6 +202,7 @@ describe(PersonService.name, () => {
const { person } = await ctx.newPerson({ ownerId: user.id });
const { asset } = await ctx.newAsset({ id: factory.uuid(), ownerId: user.id, width: 100, height: 200 });
await ctx.newExif({ assetId: asset.id, exifImageWidth: 200, exifImageHeight: 100 });
ctx.getMock(JobRepository).queueAll.mockResolvedValue();
await ctx.newEdits(asset.id, {
edits: [
@@ -263,6 +267,7 @@ describe(PersonService.name, () => {
const { person } = await ctx.newPerson({ ownerId: user.id });
const { asset } = await ctx.newAsset({ id: factory.uuid(), ownerId: user.id, width: 200, height: 100 });
await ctx.newExif({ assetId: asset.id, exifImageHeight: 100, exifImageWidth: 200 });
ctx.getMock(JobRepository).queueAll.mockResolvedValue();
await ctx.newEdits(asset.id, {
edits: [
@@ -327,6 +332,7 @@ describe(PersonService.name, () => {
const { person } = await ctx.newPerson({ ownerId: user.id });
const { asset } = await ctx.newAsset({ id: factory.uuid(), ownerId: user.id, width: 200, height: 150 });
await ctx.newExif({ assetId: asset.id, exifImageHeight: 200, exifImageWidth: 200 });
ctx.getMock(JobRepository).queueAll.mockResolvedValue();
await ctx.newEdits(asset.id, {
edits: [
@@ -400,6 +406,7 @@ describe(PersonService.name, () => {
const { person } = await ctx.newPerson({ ownerId: user.id });
const { asset } = await ctx.newAsset({ id: factory.uuid(), ownerId: user.id, width: 150, height: 100 });
await ctx.newExif({ assetId: asset.id, exifImageHeight: 100, exifImageWidth: 200 });
ctx.getMock(JobRepository).queueAll.mockResolvedValue();
await ctx.newEdits(asset.id, {
edits: [
@@ -473,6 +480,7 @@ describe(PersonService.name, () => {
const { person } = await ctx.newPerson({ ownerId: user.id });
const { asset } = await ctx.newAsset({ id: factory.uuid(), ownerId: user.id, width: 200, height: 150 });
await ctx.newExif({ assetId: asset.id, exifImageHeight: 200, exifImageWidth: 150 });
ctx.getMock(JobRepository).queueAll.mockResolvedValue();
await ctx.newEdits(asset.id, {
edits: [
@@ -543,6 +551,7 @@ describe(PersonService.name, () => {
const { person } = await ctx.newPerson({ ownerId: user.id });
const { asset } = await ctx.newAsset({ id: factory.uuid(), ownerId: user.id, width: 150, height: 100 });
await ctx.newExif({ assetId: asset.id, exifImageHeight: 200, exifImageWidth: 200 });
ctx.getMock(JobRepository).queueAll.mockResolvedValue();
await ctx.newEdits(asset.id, {
edits: [
@@ -622,6 +631,7 @@ describe(PersonService.name, () => {
const { person } = await ctx.newPerson({ ownerId: user.id });
const { asset } = await ctx.newAsset({ id: factory.uuid(), ownerId: user.id, width: 100, height: 100 });
await ctx.newExif({ assetId: asset.id, exifImageHeight: 100, exifImageWidth: 100 });
ctx.getMock(JobRepository).queueAll.mockResolvedValue();
await ctx.newEdits(asset.id, {
edits: [
@@ -692,6 +702,7 @@ describe(PersonService.name, () => {
const { person } = await ctx.newPerson({ ownerId: user.id });
const { asset } = await ctx.newAsset({ id: factory.uuid(), ownerId: user.id, width: 100, height: 100 });
await ctx.newExif({ assetId: asset.id, exifImageHeight: 200, exifImageWidth: 100, orientation: '6' });
ctx.getMock(JobRepository).queueAll.mockResolvedValue();
await ctx.newEdits(asset.id, {
edits: [
@@ -1,4 +1,5 @@
import { Kysely } from 'kysely';
import { SearchSuggestionType } from 'src/dtos/search.dto';
import { AccessRepository } from 'src/repositories/access.repository';
import { AssetRepository } from 'src/repositories/asset.repository';
import { DatabaseRepository } from 'src/repositories/database.repository';
@@ -108,4 +109,25 @@ describe(SearchService.name, () => {
expect(response.assets.items[0].id).toBe(unstackedAsset.id);
});
});
describe('getSearchSuggestions', () => {
it('should filter out empty search suggestions', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id });
await ctx.newExif({ assetId: asset.id, make: 'Canon' });
const { asset: assetWithEmptyMake } = await ctx.newAsset({ ownerId: user.id });
await ctx.newExif({ assetId: assetWithEmptyMake.id, make: '' });
const auth = factory.auth({ user: { id: user.id } });
const suggestions = await sut.getSearchSuggestions(auth, {
type: SearchSuggestionType.CAMERA_MAKE,
includeNull: true,
});
expect(suggestions).toEqual(['Canon', null]);
});
});
});
@@ -372,6 +372,43 @@ describe(SharedLinkService.name, () => {
});
describe('get', () => {
it('should return an album shared link with assets', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const { album } = await ctx.newAlbum({ ownerId: user.id });
const [{ asset: asset1 }, { asset: asset2 }] = await Promise.all([
ctx.newAsset({ ownerId: user.id }),
ctx.newAsset({ ownerId: user.id }),
]);
await Promise.all([
ctx.newExif({ assetId: asset1.id, make: 'Canon' }),
ctx.newExif({ assetId: asset2.id, make: 'Canon' }),
]);
const sharedLinkRepo = ctx.get(SharedLinkRepository);
const sharedLink = await sharedLinkRepo.create({
key: randomBytes(16),
id: factory.uuid(),
userId: user.id,
albumId: album.id,
allowUpload: true,
type: SharedLinkType.Album,
});
await sharedLinkRepo.addAssets(sharedLink.id, [asset1.id, asset2.id]);
const result = await sut.get(auth, sharedLink.id);
const assetIds = result.assets.map((asset) => asset.id);
expect(result).toMatchObject({
id: sharedLink.id,
album: expect.objectContaining({ id: album.id }),
});
expect(assetIds).toHaveLength(2);
expect(assetIds).toEqual(expect.arrayContaining([asset1.id, asset2.id]));
});
it('should not return trashed assets for an individual shared link', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
@@ -1,6 +1,7 @@
import { Kysely } from 'kysely';
import { serverVersion } from 'src/constants';
import { JobName } from 'src/enum';
import { CronRepository } from 'src/repositories/cron.repository';
import { DatabaseRepository } from 'src/repositories/database.repository';
import { JobRepository } from 'src/repositories/job.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
@@ -16,7 +17,7 @@ const setup = (db?: Kysely<DB>) => {
return newMediumService(VersionService, {
database: db || defaultDatabase,
real: [DatabaseRepository, VersionHistoryRepository],
mock: [LoggingRepository, JobRepository],
mock: [LoggingRepository, JobRepository, CronRepository],
});
};
@@ -33,6 +33,10 @@ export const newAccessRepositoryMock = (): IAccessRepositoryMock => {
checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()),
},
duplicate: {
checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()),
},
memory: {
checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()),
},
@@ -35,11 +35,19 @@ const envData: EnvData = {
vectorExtension: DatabaseExtension.Vectors,
},
helmet: {
config: {},
},
licensePublicKey: {
client: 'client-public-key',
server: 'server-public-key',
},
versionCheck: {
url: 'https://version.immich.cloud/version',
},
network: {
trustedProxies: [],
},
+4
View File
@@ -1,4 +1,8 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
"tsBuildInfoFile": "./dist/tsconfig.build.tsbuildinfo",
},
"exclude": ["dist", "node_modules", "upload", "test", "e2e", "**/*spec.ts"]
}
+3 -1
View File
@@ -19,10 +19,12 @@
"preserveWatchOutput": true,
"paths": {
"src/*": ["./src/*"],
"test/*": ["./test/*"]
},
"baseUrl": "./",
"rootDir": ".",
"jsx": "react",
"types": ["vitest/globals"],
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo",
"noErrorTruncation": true
},
"exclude": ["dist", "node_modules", "upload"]