mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
Merge branch 'main' into feat/mobile-ocr
This commit is contained in:
+1
-1
@@ -1 +1 @@
|
||||
24.13.1
|
||||
24.14.1
|
||||
|
||||
+2
-2
@@ -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,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
@@ -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")/..")"
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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}] `);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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']));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { IsString } from 'class-validator';
|
||||
export class DatabaseBackupDto {
|
||||
filename!: string;
|
||||
filesize!: number;
|
||||
timezone!: string;
|
||||
}
|
||||
|
||||
export class DatabaseBackupListResponseDto {
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -42,6 +42,10 @@ export class EnvDto {
|
||||
@Optional()
|
||||
IMMICH_CONFIG_FILE?: string;
|
||||
|
||||
@IsString()
|
||||
@Optional()
|
||||
IMMICH_HELMET_FILE?: string;
|
||||
|
||||
@IsEnum(ImmichEnvironment)
|
||||
@Optional()
|
||||
IMMICH_ENV?: ImmichEnvironment;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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="<script>console.log('hello')</script>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" http-equiv="refresh" />');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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] : [];
|
||||
};
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
};
|
||||
}
|
||||
@@ -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' },
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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']);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: [],
|
||||
},
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"tsBuildInfoFile": "./dist/tsconfig.build.tsbuildinfo",
|
||||
},
|
||||
"exclude": ["dist", "node_modules", "upload", "test", "e2e", "**/*spec.ts"]
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user