Merge main

This commit is contained in:
izzy 2026-07-22 10:48:14 +01:00
commit 5ab95df4c0
No known key found for this signature in database
787 changed files with 33462 additions and 25302 deletions

View file

@ -1,4 +1,4 @@
FROM ghcr.io/immich-app/base-server-dev:202606161235@sha256:9f88b07acc8b7bf37a1dd3d5a19193f664443eaaab4e08e9f9341414c5e4b23f AS builder
FROM ghcr.io/immich-app/base-server-dev:202607211135@sha256:83c9ff3f7390111596a2dcd24a746c3f8618d58259dbc7aae36618d153462f18 AS builder
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
CI=1 \
COREPACK_HOME=/tmp \
@ -56,7 +56,7 @@ FROM builder AS tools
ARG TARGETPLATFORM
COPY --from=ghcr.io/jdx/mise:2026.6.10@sha256:f57ac375a262f52f8ac3f9101348dbff2187d5e4b59612154f2f2808dbe46ef6 /usr/local/bin/mise /usr/local/bin/mise
COPY --from=ghcr.io/jdx/mise:2026.7.11@sha256:6599c81b0da6206cbf5151df97a36794d70aaec4574ceb92d13783b42080b055 /usr/local/bin/mise /usr/local/bin/mise
WORKDIR /app
COPY ./mise.toml ./mise.toml
@ -82,7 +82,7 @@ RUN --mount=type=cache,id=pnpm-packages,target=/buildcache/pnpm-store \
--mount=type=cache,id=mise-tools-${TARGETPLATFORM},target=/buildcache/mise \
mise //:plugins
FROM ghcr.io/immich-app/base-server-prod:202606161235@sha256:c6d59e3923f548d29a212b4dc51b6281a722cfa1da7972a009c0f3830f5762d6
FROM ghcr.io/immich-app/base-server-prod:202607211135@sha256:ced131da7523544fe975cfd25abd67386e712e39496340b437cd95a08d0a18f3
WORKDIR /usr/src/app
ENV NODE_ENV=production \

View file

@ -1,8 +1,8 @@
# dev build
FROM ghcr.io/immich-app/base-server-dev:202606161235@sha256:9f88b07acc8b7bf37a1dd3d5a19193f664443eaaab4e08e9f9341414c5e4b23f AS dev
FROM ghcr.io/immich-app/base-server-dev:202607211135@sha256:83c9ff3f7390111596a2dcd24a746c3f8618d58259dbc7aae36618d153462f18 AS dev
COPY --from=ghcr.io/jdx/mise:2026.6.10@sha256:f57ac375a262f52f8ac3f9101348dbff2187d5e4b59612154f2f2808dbe46ef6 /usr/local/bin/mise /usr/local/bin/mise
COPY --from=ghcr.io/jdx/mise:2026.7.11@sha256:6599c81b0da6206cbf5151df97a36794d70aaec4574ceb92d13783b42080b055 /usr/local/bin/mise /usr/local/bin/mise
RUN echo "devdir=/buildcache/node-gyp" >> /usr/local/etc/npmrc && \
echo "store-dir=/buildcache/pnpm-store" >> /usr/local/etc/npmrc && \

View file

@ -39,7 +39,7 @@ export default typescriptEslint.config([
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-floating-promises': 'error',
'unicorn/prevent-abbreviations': 'off',
'unicorn/name-replacements': 'off',
'unicorn/filename-case': 'off',
'unicorn/no-null': 'off',
'unicorn/prefer-top-level-await': 'off',
@ -49,6 +49,25 @@ export default typescriptEslint.config([
'unicorn/prefer-structured-clone': 'off',
'unicorn/no-for-loop': 'off',
'unicorn/no-array-sort': 'off',
'unicorn/no-unreadable-for-of-expression': 'off',
'unicorn/no-break-in-nested-loop': 'off',
'unicorn/no-top-level-assignment-in-function': 'off',
'unicorn/prefer-uint8array-base64': 'off',
'unicorn/max-nested-calls': 'off',
'unicorn/no-declarations-before-early-exit': 'off',
'unicorn/no-unreadable-object-destructuring': 'off',
// maybe we do want to enable this later. TBD
'unicorn/prefer-await': 'off',
'unicorn/consistent-class-member-order': 'off',
'unicorn/class-reference-in-static-methods': ['error', { preferThis: false, preferSuper: false }],
'unicorn/no-unsafe-property-key': 'off',
'unicorn/consistent-boolean-name': 'off',
'unicorn/no-computed-property-existence-check': 'off',
'unicorn/no-non-function-verb-prefix': 'off',
'unicorn/prefer-simple-condition-first': 'off',
// prefer the typescript-eslint type-aware version
'unicorn/require-array-sort-compare': 'off',
'@typescript-eslint/require-array-sort-compare': 'error',
'@typescript-eslint/await-thenable': 'error',
'@typescript-eslint/no-misused-promises': 'error',
'@typescript-eslint/switch-exhaustiveness-check': ['error', { considerDefaultExhaustiveForUnions: true }],

View file

@ -5,6 +5,7 @@
"script-src": ["'self'", "'wasm-unsafe-eval'", "'unsafe-inline'", "https://www.gstatic.com"],
"style-src": ["'self'", "'unsafe-inline'"],
"img-src": ["'self'", "data:", "blob:"],
"media-src": ["'self'", "data:", "blob:"],
"connect-src": [
"'self'",
"blob:",

View file

@ -1,6 +1,6 @@
{
"name": "immich",
"version": "3.0.0-rc.2",
"version": "3.0.3",
"description": "",
"author": "",
"private": true,
@ -50,14 +50,14 @@
"@nestjs/websockets": "^11.0.4",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/context-async-hooks": "^2.0.0",
"@opentelemetry/exporter-prometheus": "^0.219.0",
"@opentelemetry/instrumentation-http": "^0.219.0",
"@opentelemetry/instrumentation-ioredis": "^0.67.0",
"@opentelemetry/instrumentation-nestjs-core": "^0.65.0",
"@opentelemetry/instrumentation-pg": "^0.71.0",
"@opentelemetry/exporter-prometheus": "^0.220.0",
"@opentelemetry/instrumentation-http": "^0.220.0",
"@opentelemetry/instrumentation-ioredis": "^0.68.0",
"@opentelemetry/instrumentation-nestjs-core": "^0.66.0",
"@opentelemetry/instrumentation-pg": "^0.72.0",
"@opentelemetry/resources": "^2.0.1",
"@opentelemetry/sdk-metrics": "^2.0.1",
"@opentelemetry/sdk-node": "^0.219.0",
"@opentelemetry/sdk-node": "^0.220.0",
"@opentelemetry/semantic-conventions": "^1.34.0",
"@react-email/components": "^1.0.0",
"@react-email/render": "^2.0.0",
@ -139,7 +139,7 @@
"@types/luxon": "^3.6.2",
"@types/mock-fs": "^4.13.1",
"@types/multer": "^2.0.0",
"@types/node": "^24.13.2",
"@types/node": "^24.13.3",
"@types/nodemailer": "^8.0.0",
"@types/picomatch": "^4.0.0",
"@types/pngjs": "^6.0.5",
@ -152,7 +152,7 @@
"eslint": "^10.0.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-unicorn": "^64.0.0",
"eslint-plugin-unicorn": "^72.0.0",
"globals": "^17.0.0",
"mock-fs": "^5.2.0",
"pngjs": "^7.0.0",
@ -162,7 +162,8 @@
"supertest": "^7.1.0",
"tailwindcss": "^3.4.0",
"testcontainers": "^12.0.0",
"typescript": "^6.0.0",
"@typescript/native": "npm:typescript@^7.0.2",
"typescript": "npm:@typescript/typescript6@^6.0.2",
"typescript-eslint": "^8.28.0",
"unplugin-swc": "^1.4.5",
"vite-tsconfig-paths": "^6.0.0",

View file

@ -70,6 +70,7 @@ const commonImports = [
const bullImports = [BullModule.forRoot(bull.config), BullModule.registerQueue(...bull.queues)];
// eslint-disable-next-line unicorn/no-top-level-side-effects
configureUserAgent();
export class BaseModule implements OnModuleInit, OnModuleDestroy {

View file

@ -23,6 +23,7 @@ const handleError = (label: string, error: Error | any) => {
console.error(`${label} error: ${error}`);
};
// eslint-disable-next-line unicorn/no-exports-in-scripts
export class SqlLogger {
queries: string[] = [];
errors: Array<{ error: string | Error; query: string }> = [];
@ -109,10 +110,12 @@ class SqlGenerator {
const instance = this.app.get<Repository>(Repository);
// normal repositories
data.push(...(await this.runTargets(instance, `${Repository.name}`)));
data.push(...(await this.runTargets(instance, Repository.name)));
// nested repositories
if (Repository.name === AccessRepository.name || Repository.name === SyncRepository.name) {
// probably a bug that this fails linting?
// eslint-disable-next-line unicorn/prefer-object-iterable-methods
for (const key of Object.keys(instance)) {
const subInstance = (instance as any)[key];
data.push(...(await this.runTargets(subInstance, `${Repository.name}.${key}`)));
@ -127,7 +130,7 @@ class SqlGenerator {
for (const key of this.getPropertyNames(instance)) {
const target = instance[key];
if (!(typeof target === 'function')) {
if (typeof target !== 'function') {
continue;
}

View file

@ -36,7 +36,7 @@ export class ChangeMediaLocationCommand extends CommandRunner {
{},
);
const success = await this.service.migrateFilePaths({
const isSuccess = await this.service.migrateFilePaths({
oldValue,
newValue,
confirm: async ({ sourceFolder, targetFolder }) => {
@ -65,7 +65,7 @@ export class ChangeMediaLocationCommand extends CommandRunner {
...
)`;
console.log(`\n ${success ? successMessage : 'No rows were updated'}\n`);
console.log(`\n ${isSuccess ? successMessage : 'No rows were updated'}\n`);
await this.showSamplePaths('after');
} catch (error) {

View file

@ -4,6 +4,7 @@ import {
AudioCodec,
Colorspace,
CQMode,
HlsVideoResolution,
ImageFormat,
LogLevel,
OAuthTokenEndpointAuthMethod,
@ -49,6 +50,8 @@ export type SystemConfig = {
tonemap: ToneMapping;
realtime: {
enabled: boolean;
videoCodecs: VideoCodec[];
resolutions: HlsVideoResolution[];
};
};
integrityChecks: {
@ -249,6 +252,8 @@ export const defaults = Object.freeze<SystemConfig>({
accelDecode: true,
realtime: {
enabled: false,
videoCodecs: [VideoCodec.H264, VideoCodec.Hevc],
resolutions: [HlsVideoResolution.p480, HlsVideoResolution.p720, HlsVideoResolution.p1080],
},
},
integrityChecks: {

View file

@ -235,14 +235,65 @@ export const HLS_PLAYLIST_CONTENT_TYPE = 'application/vnd.apple.mpegurl';
export const HLS_SEGMENT_DURATION = 2;
export const HLS_SEGMENT_FILENAME_REGEX = /^seg_(\d+)\.m4s$/;
export const HLS_VARIANTS = [
{ resolution: 480, codec: VideoCodec.Av1, bitrate: 1_000_000, codecString: 'av01.0.04M.08' },
{ resolution: 480, codec: VideoCodec.Hevc, bitrate: 1_200_000, codecString: 'hvc1.1.6.L90.B0' },
{ resolution: 480, codec: VideoCodec.H264, bitrate: 2_500_000, codecString: 'avc1.64001e' },
{ resolution: 720, codec: VideoCodec.Av1, bitrate: 2_000_000, codecString: 'av01.0.08M.08' },
{ resolution: 720, codec: VideoCodec.Hevc, bitrate: 2_500_000, codecString: 'hvc1.1.6.L93.B0' },
{ resolution: 720, codec: VideoCodec.H264, bitrate: 5_000_000, codecString: 'avc1.64001f' },
{ resolution: 1080, codec: VideoCodec.Av1, bitrate: 4_000_000, codecString: 'av01.0.09M.08' },
{ resolution: 1080, codec: VideoCodec.Hevc, bitrate: 4_500_000, codecString: 'hvc1.1.6.L120.B0' },
{ resolution: 1080, codec: VideoCodec.H264, bitrate: 8_000_000, codecString: 'avc1.640028' },
{ resolution: 480, codec: VideoCodec.Av1, bitrate: 1_000_000 },
{ resolution: 480, codec: VideoCodec.Hevc, bitrate: 1_200_000 },
{ resolution: 480, codec: VideoCodec.H264, bitrate: 2_500_000 },
{ resolution: 720, codec: VideoCodec.Av1, bitrate: 2_000_000 },
{ resolution: 720, codec: VideoCodec.Hevc, bitrate: 2_500_000 },
{ resolution: 720, codec: VideoCodec.H264, bitrate: 5_000_000 },
{ resolution: 1080, codec: VideoCodec.Av1, bitrate: 4_000_000 },
{ resolution: 1080, codec: VideoCodec.Hevc, bitrate: 4_500_000 },
{ resolution: 1080, codec: VideoCodec.H264, bitrate: 8_000_000 },
{ resolution: 1440, codec: VideoCodec.Av1, bitrate: 7_000_000 },
{ resolution: 1440, codec: VideoCodec.Hevc, bitrate: 8_000_000 },
{ resolution: 1440, codec: VideoCodec.H264, bitrate: 14_000_000 },
{ resolution: 2160, codec: VideoCodec.Av1, bitrate: 12_000_000 },
{ resolution: 2160, codec: VideoCodec.Hevc, bitrate: 14_000_000 },
{ resolution: 2160, codec: VideoCodec.H264, bitrate: 25_000_000 },
];
export const HLS_VERSION = 7;
export type CodecLevel = { maxFrame: number; maxRate: number; token: string };
// H.264 High profile: token is the hex level_idc.
export const H264_LEVELS: CodecLevel[] = [
{ maxFrame: 1620, maxRate: 40_500, token: '1e' }, // 3.0
{ maxFrame: 3600, maxRate: 108_000, token: '1f' }, // 3.1
{ maxFrame: 5120, maxRate: 216_000, token: '20' }, // 3.2
{ maxFrame: 8192, maxRate: 245_760, token: '28' }, // 4.0
{ maxFrame: 8704, maxRate: 522_240, token: '2a' }, // 4.2
{ maxFrame: 22_080, maxRate: 589_824, token: '32' }, // 5.0
{ maxFrame: 36_864, maxRate: 983_040, token: '33' }, // 5.1
{ maxFrame: 36_864, maxRate: 2_073_600, token: '34' }, // 5.2
{ maxFrame: 139_264, maxRate: 4_177_920, token: '3c' }, // 6.0
{ maxFrame: 139_264, maxRate: 8_355_840, token: '3d' }, // 6.1
{ maxFrame: 139_264, maxRate: 16_711_680, token: '3e' }, // 6.2
];
// HEVC Main profile, Main tier: token is `L` + level_idc (level × 30).
export const HEVC_LEVELS: CodecLevel[] = [
{ maxFrame: 552_960, maxRate: 16_588_800, token: 'L90' }, // 3.0
{ maxFrame: 983_040, maxRate: 33_177_600, token: 'L93' }, // 3.1
{ maxFrame: 2_228_224, maxRate: 66_846_720, token: 'L120' }, // 4.0
{ maxFrame: 2_228_224, maxRate: 133_693_440, token: 'L123' }, // 4.1
{ maxFrame: 8_912_896, maxRate: 267_386_880, token: 'L150' }, // 5.0
{ maxFrame: 8_912_896, maxRate: 534_773_760, token: 'L153' }, // 5.1
{ maxFrame: 8_912_896, maxRate: 1_069_547_520, token: 'L156' }, // 5.2
{ maxFrame: 35_651_584, maxRate: 1_069_547_520, token: 'L180' }, // 6.0
{ maxFrame: 35_651_584, maxRate: 2_139_095_040, token: 'L183' }, // 6.1
{ maxFrame: 35_651_584, maxRate: 4_278_190_080, token: 'L186' }, // 6.2
];
// AV1 Main profile (0), Main tier (M): token is the two-digit seq_level_idx + `M`.
export const AV1_LEVELS: CodecLevel[] = [
{ maxFrame: 665_856, maxRate: 19_975_168, token: '04M' }, // 3.0
{ maxFrame: 1_065_024, maxRate: 31_950_336, token: '05M' }, // 3.1
{ maxFrame: 2_359_296, maxRate: 70_778_880, token: '08M' }, // 4.0
{ maxFrame: 2_359_296, maxRate: 141_557_760, token: '09M' }, // 4.1
{ maxFrame: 8_912_896, maxRate: 267_386_880, token: '12M' }, // 5.0
{ maxFrame: 8_912_896, maxRate: 534_773_760, token: '13M' }, // 5.1
{ maxFrame: 8_912_896, maxRate: 1_069_547_520, token: '14M' }, // 5.2
{ maxFrame: 35_651_584, maxRate: 1_069_547_520, token: '16M' }, // 6.0
{ maxFrame: 35_651_584, maxRate: 2_139_095_040, token: '17M' }, // 6.1
{ maxFrame: 35_651_584, maxRate: 4_278_190_080, token: '18M' }, // 6.2
];

View file

@ -7,6 +7,7 @@ import {
AlbumsAddAssetsDto,
AlbumsAddAssetsResponseDto,
AlbumStatisticsResponseDto,
AlbumUserParamDto,
CreateAlbumDto,
GetAlbumsDto,
UpdateAlbumDto,
@ -18,7 +19,7 @@ import { MapMarkerResponseDto } from 'src/dtos/map.dto';
import { ApiTag, Permission } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { AlbumService } from 'src/services/album.service';
import { ParseMeUUIDPipe, UUIDParamDto } from 'src/validation';
import { UUIDParamDto } from 'src/validation';
@ApiTags(ApiTag.Albums)
@Controller('albums')
@ -175,8 +176,7 @@ export class AlbumController {
})
updateAlbumUser(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@Param('userId', new ParseMeUUIDPipe({ version: '4' })) userId: string,
@Param() { id, userId }: AlbumUserParamDto,
@Body() dto: UpdateAlbumUserDto,
): Promise<void> {
return this.service.updateUser(auth, id, userId, dto);
@ -190,11 +190,7 @@ export class AlbumController {
description: 'Remove a user from an album. Use an ID of "me" to leave a shared album.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
removeUserFromAlbum(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@Param('userId', new ParseMeUUIDPipe({ version: '4' })) userId: string,
): Promise<void> {
removeUserFromAlbum(@Auth() auth: AuthDto, @Param() { id, userId }: AlbumUserParamDto): Promise<void> {
return this.service.removeUser(auth, id, userId);
}
}

View file

@ -128,10 +128,10 @@ export class AssetMediaController {
this.logger.deprecate(
'Calling the thumbnail endpoint with size=original is deprecated. Use the :id/original endpoint instead',
);
const [_, reqSearch] = req.url.split('?');
const [_, reqSearch] = req.url.split('?', 2);
const redirSearchParams = new URLSearchParams(reqSearch);
redirSearchParams.delete('size');
return res.redirect('original' + '?' + redirSearchParams.toString());
return res.redirect('original?' + redirSearchParams.toString());
}
const viewThumbnailRes = await this.service.viewThumbnail(auth, id, dto);
@ -142,7 +142,7 @@ export class AssetMediaController {
// viewThumbnailRes is a AssetMediaRedirectResponse
// which redirects to the original asset or a specific size to make better use of caching
const { targetSize } = viewThumbnailRes;
const [reqPath, reqSearch] = req.url.split('?');
const [reqPath, reqSearch] = req.url.split('?', 2);
let redirPath: string;
const redirSearchParams = new URLSearchParams(reqSearch);
if (targetSize === 'original') {

View file

@ -32,7 +32,9 @@ describe(DownloadController.name, () => {
it('should be an authenticated route', async () => {
const stream = new Readable({
read() {
// eslint-disable-next-line unicorn/no-this-outside-of-class
this.push('test');
// eslint-disable-next-line unicorn/no-this-outside-of-class
this.push(null);
},
});

View file

@ -65,12 +65,14 @@ export class MaintenanceController {
@GetLoginDetails() loginDetails: LoginDetails,
@Res({ passthrough: true }) res: Response,
): Promise<void> {
if (dto.action !== MaintenanceAction.End) {
const { jwt } = await this.service.startMaintenance(dto, auth.user.name);
return respondWithCookie(res, undefined, {
isSecure: loginDetails.isSecure,
values: [{ key: ImmichCookie.MaintenanceToken, value: jwt }],
});
if (dto.action === MaintenanceAction.End) {
return;
}
const { jwt } = await this.service.startMaintenance(dto, auth.user.name);
return respondWithCookie(res, undefined, {
isSecure: loginDetails.isSecure,
values: [{ key: ImmichCookie.MaintenanceToken, value: jwt }],
});
}
}

View file

@ -28,7 +28,7 @@ export class SearchController {
constructor(private service: SearchService) {}
@Post('metadata')
@Authenticated({ permission: Permission.AssetRead })
@Authenticated({ permission: Permission.AssetRead, sharedLink: true })
@HttpCode(HttpStatus.OK)
@Endpoint({
summary: 'Search assets by metadata',

View file

@ -14,10 +14,10 @@ function validConfig() {
notifications: { smtp: { from: string; transport: { host: string } } };
server: { externalDomain: string };
};
config.oauth.mobileRedirectUri = config.oauth.mobileRedirectUri || 'https://example.com';
config.server.externalDomain = config.server.externalDomain || 'https://example.com';
config.notifications.smtp.from = config.notifications.smtp.from || 'noreply@example.com';
config.notifications.smtp.transport.host = config.notifications.smtp.transport.host || 'localhost';
config.oauth.mobileRedirectUri ||= 'https://example.com';
config.server.externalDomain ||= 'https://example.com';
config.notifications.smtp.from ||= 'noreply@example.com';
config.notifications.smtp.transport.host ||= 'localhost';
return config;
}

View file

@ -33,7 +33,7 @@ describe(TimelineController.name, () => {
expect(service.getTimeBuckets).toHaveBeenCalledWith(
undefined,
expect.objectContaining({
bbox: { west: 11.075_683, south: 49.416_711, east: 11.117_589, north: 49.454_875 },
bbox: { west: 11.075683, south: 49.416711, east: 11.117589, north: 49.454875 },
}),
);
});

View file

@ -202,20 +202,20 @@ export class StorageCore {
let move = await this.moveRepository.getByEntity(entityId, pathType);
if (move) {
this.logger.log(`Attempting to finish incomplete move: ${move.oldPath} => ${move.newPath}`);
const oldPathExists = await this.storageRepository.checkFileExists(move.oldPath);
const newPathExists = await this.storageRepository.checkFileExists(move.newPath);
const newPathCheck = newPathExists ? move.newPath : null;
const actualPath = oldPathExists ? move.oldPath : newPathCheck;
const isOldPathExists = await this.storageRepository.checkFileExists(move.oldPath);
const isNewPathExists = await this.storageRepository.checkFileExists(move.newPath);
const newPathCheck = isNewPathExists ? move.newPath : null;
const actualPath = isOldPathExists ? move.oldPath : newPathCheck;
if (!actualPath) {
this.logger.warn('Unable to complete move. File does not exist at either location.');
return;
}
const fileAtNewLocation = actualPath === move.newPath;
this.logger.log(`Found file at ${fileAtNewLocation ? 'new' : 'old'} location`);
const isFileAtNewLocation = actualPath === move.newPath;
this.logger.log(`Found file at ${isFileAtNewLocation ? 'new' : 'old'} location`);
if (
fileAtNewLocation &&
isFileAtNewLocation &&
!(await this.verifyNewPathContentsMatchesExpected(move.oldPath, move.newPath, assetInfo))
) {
this.logger.fatal(
@ -349,7 +349,7 @@ export class StorageCore {
}
static getNestedPath(folder: StorageFolder, ownerId: string, filename: string): string {
return join(this.getNestedFolder(folder, ownerId, filename), filename);
return join(StorageCore.getNestedFolder(folder, ownerId, filename), filename);
}
static getTempPathInDir(dir: string): string {

View file

@ -368,6 +368,7 @@ export const columns = {
'plugin_method.types',
'plugin_method.schema',
'plugin_method.hostFunctions',
'plugin_method.allowedHosts',
'plugin_method.uiHints',
],
syncAsset: [

View file

@ -54,9 +54,8 @@ function chunks<T>(collection: Array<T> | Set<T>, size: number): Array<Array<T>>
result.push(chunk);
}
return result;
} else {
return _.chunk(collection, size);
}
return _.chunk(collection, size);
}
/**
@ -82,11 +81,13 @@ export function Chunked(
(Array.isArray(argument) && argument.length <= chunkSize) ||
(argument instanceof Set && argument.size <= chunkSize)
) {
// eslint-disable-next-line unicorn/no-this-outside-of-class
return originalMethod.apply(this, arguments_);
}
return Promise.all(
chunks(argument, chunkSize).map((chunk) => {
// eslint-disable-next-line unicorn/no-this-outside-of-class
return Reflect.apply(originalMethod, this, [
...arguments_.slice(0, parameterIndex),
chunk,
@ -190,11 +191,11 @@ type CustomExtensions = {
};
enum ApiState {
'Stable' = 'Stable',
'Alpha' = 'Alpha',
'Beta' = 'Beta',
'Internal' = 'Internal',
'Deprecated' = 'Deprecated',
Stable = 'Stable',
Alpha = 'Alpha',
Beta = 'Beta',
Internal = 'Internal',
Deprecated = 'Deprecated',
}
export class HistoryBuilder {
private hasDeprecated = false;

View file

@ -1,6 +1,7 @@
import { ShallowDehydrateObject } from 'kysely';
import { createZodDto } from 'nestjs-zod';
import { AlbumUser, AuthSharedLink } from 'src/database';
import { HistoryBuilder } from 'src/decorators';
import { BulkIdErrorReasonSchema } from 'src/dtos/asset-ids.response.dto';
import { MapAsset } from 'src/dtos/asset-response.dto';
import { UserResponseSchema, mapUser } from 'src/dtos/user.dto';
@ -140,6 +141,19 @@ export const AlbumResponseSchema = z
})
.meta({ id: 'AlbumResponseDto' });
const AlbumUserParamSchema = z.object({
id: z.uuidv4().describe('Album ID'),
// TODO: disallow 'me' as a shortcut in v4 and type userId as uuidv4
userId: z
.string()
.refine((value) => value === 'me' || z.uuidv4().safeParse(value).success, {
error: 'Must be a UUID v4 or "me"',
})
.describe('Album user ID, or "me" to reference the current user.')
.meta(new HistoryBuilder().updated('v3', '"me" as a value is deprecated').getExtensions()),
});
export class AlbumUserParamDto extends createZodDto(AlbumUserParamSchema) {}
export class AddUsersDto extends createZodDto(AddUsersSchema) {}
export class AlbumUserCreateDto extends createZodDto(AlbumUserCreateSchema) {}
export class CreateAlbumDto extends createZodDto(CreateAlbumSchema) {}

View file

@ -175,7 +175,7 @@ const peopleFromFaces = (faces?: MaybeDehydrated<AssetFace>[]): PersonResponseDt
}
}
return [...peopleMap.values()];
return peopleMap.values().toArray();
};
const mapStack = (entity: { stack?: Stack | null }) => {

View file

@ -12,6 +12,9 @@ const JsonSchemaPropertySchema = z
description: z.string().describe('Description'),
default: z.any().optional().describe('Default value'),
enum: z.array(z.string()).optional().describe('Valid choices for enum types'),
minimum: z.number().optional().describe('Minimum value for number types'),
maximum: z.number().optional().describe('Maximum value for number types'),
precision: z.number().default(1).optional().describe('Smallest interval (granularity) for number types'),
array: z.boolean().optional().describe('Type is an array type'),
required: z.array(z.string()).optional().describe('A list of required properties'),
uiHint: z

View file

@ -92,10 +92,7 @@ export class LibraryResponseDto extends createZodDto(LibraryResponseSchema) {}
export class LibraryStatsResponseDto extends createZodDto(LibraryStatsResponseSchema) {}
export function mapLibrary(entity: Library): LibraryResponseDto {
let assetCount = 0;
if (entity.assets) {
assetCount = entity.assets.length;
}
const assetCount = entity.assets ? entity.assets.length : 0;
return {
id: entity.id,
ownerId: entity.ownerId,

View file

@ -4,13 +4,13 @@ import { HistoryBuilder } from 'src/decorators';
import { AssetResponseSchema, mapAsset } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { AssetOrderWithRandomSchema, MemoryType, MemoryTypeSchema } from 'src/enum';
import { isoDatetimeToDate, nonEmptyPartial, stringToBool } from 'src/validation';
import { isoDatetimeToDate, isoDateToDate, nonEmptyPartial, stringToBool } from 'src/validation';
import z from 'zod';
const MemorySearchSchema = z
.object({
type: MemoryTypeSchema.optional(),
for: isoDatetimeToDate.optional().describe('Filter by date'),
for: isoDateToDate.optional().describe('Filter by date'),
isTrashed: stringToBool.optional().describe('Include trashed memories'),
isSaved: stringToBool.optional().describe('Filter by saved status'),
size: z.coerce.number().int().min(1).optional().describe('Number of memories to return'),

View file

@ -18,6 +18,11 @@ const PluginManifestMethodSchema = z
description: z.string().min(1).describe('Method description'),
types: z.array(WorkflowTypeSchema).min(1).describe('Workflow type'),
hostFunctions: z.boolean().optional().default(false).describe('Method uses host functions'),
allowedHosts: z
.array(z.string())
.optional()
.default([])
.describe('Hostnames the method can access (use * for wildcards)'),
schema: PluginManifestMethodSchemaSchema.describe('Schema'),
uiHints: z.array(z.string()).optional().describe('Ui hints, for example "filter"'),
})

View file

@ -11,6 +11,7 @@ import {
AudioCodecSchema,
ColorspaceSchema,
CQModeSchema,
HlsVideoResolutionSchema,
ImageFormatSchema,
LogLevelSchema,
OAuthTokenEndpointAuthMethodSchema,
@ -73,7 +74,12 @@ const SystemConfigIntegrityJobSchema = z
const SystemConfigIntegrityChecksumJobSchema = SystemConfigIntegrityJobSchema.extend({
timeLimit: z.int().nonnegative().describe('How long the integrity checksum job may run for'),
percentageLimit: z.int().nonnegative().describe('Percentage limit of the integrity checksum job'),
percentageLimit: z
.float32()
.nonnegative()
.max(1)
.describe('Percentage limit of the integrity checksum job')
.meta({ format: 'double' }),
})
.describe('Integrity checksum job config')
.meta({ id: 'SystemConfigIntegrityChecksumJob' });
@ -117,6 +123,8 @@ const SystemConfigFFmpegSchema = z
realtime: z
.object({
enabled: configBool.describe('Enable real-time HLS transcoding (alpha)'),
videoCodecs: z.array(VideoCodecSchema).describe('Video codecs to use for real-time HLS transcoding'),
resolutions: z.array(HlsVideoResolutionSchema).describe('Resolutions to use for real-time HLS transcoding'),
})
.meta({ id: 'SystemConfigFFmpegRealtimeDto' }),
})

View file

@ -98,6 +98,13 @@ const CastUpdateSchema = z
.optional()
.meta({ id: 'CastUpdate' });
const RecentlyAddedUpdateSchema = z
.object({
sidebarWeb: z.boolean().optional().describe('Whether the recently added page appears in the web sidebar'),
})
.optional()
.meta({ id: 'RecentlyAddedUpdate' });
const UserPreferencesUpdateSchema = z
.object({
albums: AlbumsUpdateSchema,
@ -112,6 +119,7 @@ const UserPreferencesUpdateSchema = z
ratings: RatingsUpdateSchema,
sharedLinks: SharedLinksUpdateSchema,
tags: TagsUpdateSchema,
recentlyAdded: RecentlyAddedUpdateSchema,
})
.meta({ id: 'UserPreferencesUpdateDto' });
@ -191,6 +199,12 @@ const CastResponseSchema = z
})
.meta({ id: 'CastResponse' });
const RecentlyAddedResponseSchema = z
.object({
sidebarWeb: z.boolean().describe('Whether the recently added page appears in the web sidebar'),
})
.meta({ id: 'RecentlyAddedResponse' });
const UserPreferencesResponseSchema = z
.object({
albums: AlbumsResponseSchema,
@ -204,6 +218,7 @@ const UserPreferencesResponseSchema = z
download: DownloadResponseSchema,
purchase: PurchaseResponseSchema,
cast: CastResponseSchema,
recentlyAdded: RecentlyAddedResponseSchema,
})
.meta({ id: 'UserPreferencesResponseDto' });

View file

@ -529,6 +529,19 @@ export enum CQMode {
export const CQModeSchema = z.enum(CQMode).describe('CQ mode').meta({ id: 'CQMode' });
export enum HlsVideoResolution {
p480 = 480,
p720 = 720,
p1080 = 1080,
p1440 = 1440,
p2160 = 2160,
}
export const HlsVideoResolutionSchema = z
.enum(HlsVideoResolution)
.describe('HLS video resolution')
.meta({ id: 'HlsVideoResolution', type: 'integer' });
export enum Colorspace {
Srgb = 'srgb',
P3 = 'p3',

View file

@ -68,9 +68,9 @@ class Workers {
const { database } = new ConfigRepository().getEnv();
const kysely = new Kysely<DB>(getKyselyConfig(database.config));
let locked = false;
while (!locked) {
locked = await kysely.connection().execute(async (conn) => {
let isLocked = false;
while (!isLocked) {
isLocked = await kysely.connection().execute(async (conn) => {
const { rows } = await sql<{
pg_try_advisory_lock: boolean;
}>`SELECT pg_try_advisory_lock(${DatabaseLock.MaintenanceOperation})`.execute(conn);
@ -110,6 +110,7 @@ class Workers {
});
kill = (signal) => void worker.kill(signal);
// eslint-disable-next-line unicorn/prefer-hoisting-branch-code
anyWorker = worker;
} else {
const worker = new Worker(workerFile);
@ -151,9 +152,9 @@ class Workers {
if (exitCode !== 0) {
console.error(`${name} worker exited with code ${exitCode}`);
if (this.workers[ImmichWorker.Api] && name !== ImmichWorker.Api) {
if (Object.hasOwn(this.workers, ImmichWorker.Api) && name !== ImmichWorker.Api) {
console.error('Killing api process');
void this.workers[ImmichWorker.Api].kill('SIGTERM');
void this.workers[ImmichWorker.Api]!.kill('SIGTERM');
}
}

View file

@ -42,10 +42,12 @@ export class MaintenanceHealthRepository {
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');
if (worker.exitCode !== null) {
return;
}
reject(new Error('Server health check failed, took too long to start.'));
worker.kill('SIGTERM');
}, 180_000);
});
}

View file

@ -275,8 +275,8 @@ export class MaintenanceWorkerService {
}
async runRestoreDatabase(action: SetMaintenanceModeDto) {
const lock = await this.databaseRepository.tryLock(DatabaseLock.MaintenanceOperation);
if (!lock) {
const isLock = await this.databaseRepository.tryLock(DatabaseLock.MaintenanceOperation);
if (!isLock) {
return;
}

View file

@ -1,4 +1,4 @@
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { BadRequestException, CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { PATH_METADATA } from '@nestjs/common/constants';
import { Reflector } from '@nestjs/core';
import { transformException } from '@nestjs/platform-express/multer/multer/multer.utils';
@ -129,6 +129,9 @@ export class FileUploadInterceptor implements NestInterceptor {
hash?.destroy();
return callback(error);
}
if (size === 0) {
return callback(new BadRequestException('File is empty'));
}
callback(null, {
path,
size,

View file

@ -86,7 +86,7 @@ select
from
"asset_file"
-- IntegrityRepository.streamAssetPaths
-- IntegrityRepository.streamAssetPathsForMissingFiles
select
"allPaths"."path" as "path",
"allPaths"."assetId",
@ -130,10 +130,9 @@ from
where
"asset"."deletedAt" is null
and "asset"."isExternal" = false
and "integrity_report"."createdAt" >= $2
and "integrity_report"."createdAt" <= $3
and "asset"."createdAt" >= $2
order by
"integrity_report"."createdAt" asc
"asset"."createdAt" asc
-- IntegrityRepository.streamIntegrityReports
select

View file

@ -48,6 +48,7 @@ select
"plugin_method"."types",
"plugin_method"."schema",
"plugin_method"."hostFunctions",
"plugin_method"."allowedHosts",
"plugin_method"."uiHints",
"plugin"."name" as "pluginName"
from
@ -84,6 +85,7 @@ select
"plugin_method"."types",
"plugin_method"."schema",
"plugin_method"."hostFunctions",
"plugin_method"."allowedHosts",
"plugin_method"."uiHints",
"plugin"."name" as "pluginName"
from
@ -120,6 +122,7 @@ select
"plugin_method"."types",
"plugin_method"."schema",
"plugin_method"."hostFunctions",
"plugin_method"."allowedHosts",
"plugin_method"."uiHints",
"plugin"."name" as "pluginName"
from
@ -156,6 +159,7 @@ select
"plugin_method"."types",
"plugin_method"."schema",
"plugin_method"."hostFunctions",
"plugin_method"."allowedHosts",
"plugin_method"."uiHints",
"plugin"."name" as "pluginName"
from
@ -190,6 +194,7 @@ select
"plugin_method"."types",
"plugin_method"."schema",
"plugin_method"."hostFunctions",
"plugin_method"."allowedHosts",
"plugin_method"."uiHints"
from
"plugin_method"

View file

@ -7,18 +7,17 @@ from
"asset"
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
where
"asset"."visibility" = $1
and "asset"."fileCreatedAt" >= $2
and "asset_exif"."lensModel" = $3
and "asset"."ownerId" = any ($4::uuid[])
and "asset"."isFavorite" = $5
"asset"."fileCreatedAt" >= $1
and "asset_exif"."lensModel" = $2
and "asset"."ownerId" = any ($3::uuid[])
and "asset"."isFavorite" = $4
and "asset"."deletedAt" is null
order by
"asset"."fileCreatedAt" desc
limit
$6
$5
offset
$7
$6
-- SearchRepository.searchStatistics
select
@ -27,11 +26,10 @@ from
"asset"
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
where
"asset"."visibility" = $1
and "asset"."fileCreatedAt" >= $2
and "asset_exif"."lensModel" = $3
and "asset"."ownerId" = any ($4::uuid[])
and "asset"."isFavorite" = $5
"asset"."fileCreatedAt" >= $1
and "asset_exif"."lensModel" = $2
and "asset"."ownerId" = any ($3::uuid[])
and "asset"."isFavorite" = $4
and "asset"."deletedAt" is null
-- SearchRepository.searchRandom
@ -41,16 +39,15 @@ from
"asset"
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
where
"asset"."visibility" = $1
and "asset"."fileCreatedAt" >= $2
and "asset_exif"."lensModel" = $3
and "asset"."ownerId" = any ($4::uuid[])
and "asset"."isFavorite" = $5
"asset"."fileCreatedAt" >= $1
and "asset_exif"."lensModel" = $2
and "asset"."ownerId" = any ($3::uuid[])
and "asset"."isFavorite" = $4
and "asset"."deletedAt" is null
order by
random()
limit
$6
$5
-- SearchRepository.searchLargeAssets
select
@ -60,17 +57,16 @@ from
"asset"
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
where
"asset"."visibility" = $1
and "asset"."fileCreatedAt" >= $2
and "asset_exif"."lensModel" = $3
and "asset"."ownerId" = any ($4::uuid[])
and "asset"."isFavorite" = $5
"asset"."fileCreatedAt" >= $1
and "asset_exif"."lensModel" = $2
and "asset"."ownerId" = any ($3::uuid[])
and "asset"."isFavorite" = $4
and "asset"."deletedAt" is null
and "asset_exif"."fileSizeInByte" > $6
and "asset_exif"."fileSizeInByte" > $5
order by
"asset_exif"."fileSizeInByte" desc
limit
$7
$6
-- SearchRepository.searchSmart
begin
@ -83,18 +79,17 @@ from
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
inner join "smart_search" on "asset"."id" = "smart_search"."assetId"
where
"asset"."visibility" = $1
and "asset"."fileCreatedAt" >= $2
and "asset_exif"."lensModel" = $3
and "asset"."ownerId" = any ($4::uuid[])
and "asset"."isFavorite" = $5
"asset"."fileCreatedAt" >= $1
and "asset_exif"."lensModel" = $2
and "asset"."ownerId" = any ($3::uuid[])
and "asset"."isFavorite" = $4
and "asset"."deletedAt" is null
order by
smart_search.embedding <=> $6
smart_search.embedding <=> $5
limit
$7
$6
offset
$8
$7
commit
-- SearchRepository.getEmbedding

View file

@ -4,7 +4,8 @@
select
"id",
"expiresAt",
"pinExpiresAt"
"pinExpiresAt",
"oauthBearerToken"
from
"session"
where

View file

@ -25,6 +25,8 @@ select
inner join "plugin" on "plugin"."id" = "plugin_method"."pluginId"
where
"workflow"."id" = "workflow_step"."workflowId"
order by
"workflow_step"."order" asc
) as agg
) as "steps"
from
@ -57,6 +59,8 @@ select
inner join "plugin" on "plugin"."id" = "plugin_method"."pluginId"
where
"workflow"."id" = "workflow_step"."workflowId"
order by
"workflow_step"."order" asc
) as agg
) as "steps"
from
@ -80,7 +84,8 @@ select
"plugin_method"."pluginId" as "pluginId",
"plugin_method"."name" as "methodName",
"plugin_method"."types" as "types",
"plugin_method"."hostFunctions"
"plugin_method"."hostFunctions",
"plugin_method"."allowedHosts"
from
"workflow_step"
inner join "plugin_method" on "plugin_method"."id" = "workflow_step"."pluginMethodId"

View file

@ -130,7 +130,10 @@ class AlbumAccess {
.where('shared_link.albumId', 'in', [...albumIds])
.execute()
.then(
(sharedLinks) => new Set(sharedLinks.flatMap((sharedLink) => (sharedLink.albumId ? [sharedLink.albumId] : []))),
(sharedLinks) =>
new Set(
sharedLinks.filter((sharedLink) => sharedLink.albumId).map((sharedLink) => sharedLink.albumId),
) as Set<string>,
);
}
}

View file

@ -314,7 +314,7 @@ export class AlbumRepository {
albumUsers: AlbumUserCreateDto[],
authUserId: string,
) {
if (!albumUsers.some((u) => u.role === AlbumUserRole.Owner)) {
if (albumUsers.every((u) => u.role !== AlbumUserRole.Owner)) {
throw new Error('Album must have an owner');
}

View file

@ -192,9 +192,8 @@ export class DatabaseRepository {
) {
probes[indexName] = this.targetProbeCount(targetLists);
return this.reindexVectors(indexName, { lists: targetLists });
} else {
probes[indexName] = this.targetProbeCount(lists);
}
probes[indexName] = this.targetProbeCount(lists);
}),
);
break;
@ -228,7 +227,7 @@ export class DatabaseRepository {
if (table === 'smart_search') {
await sql`ALTER TABLE ${sql.raw(table)} DROP CONSTRAINT IF EXISTS dim_size_constraint`.execute(tx);
}
if (!rows.some((row) => row.columnName === 'embedding')) {
if (rows.every((row) => row.columnName !== 'embedding')) {
this.logger.warn(`Column 'embedding' does not exist in table '${table}', truncating and adding column.`);
await sql`TRUNCATE TABLE ${sql.raw(table)}`.execute(tx);
await sql`ALTER TABLE ${sql.raw(table)} ADD COLUMN embedding real[] NOT NULL`.execute(tx);
@ -349,11 +348,9 @@ export class DatabaseRepository {
private targetListCount(count: number) {
if (count < 128_000) {
return 1;
} else if (count < 2_048_000) {
return 1 << (32 - Math.clz32(count / 1000));
} else {
return 1 << (33 - Math.clz32(Math.sqrt(count)));
}
// eslint-disable-next-line unicorn/prefer-minimal-ternary
return count < 2_048_000 ? 1 << (32 - Math.clz32(count / 1000)) : 1 << (33 - Math.clz32(Math.sqrt(count)));
}
private targetProbeCount(lists: number) {
@ -378,9 +375,7 @@ export class DatabaseRepository {
for (const result of results ?? []) {
if (result.status === 'Success') {
this.logger.log(`Migration "${result.migrationName}" succeeded`);
}
if (result.status === 'Error') {
} else if (result.status === 'Error') {
this.logger.warn(`Migration "${result.migrationName}" failed`);
}
}
@ -485,9 +480,7 @@ export class DatabaseRepository {
for (const result of results ?? []) {
if (result.status === 'Success') {
this.logger.log(`Reverted migration "${result.migrationName}"`);
}
if (result.status === 'Error') {
} else if (result.status === 'Error') {
this.logger.warn(`Failed to revert migration "${result.migrationName}"`);
}
}

View file

@ -39,11 +39,11 @@ type EventMap = {
ConfigValidate: [{ newConfig: SystemConfig; oldConfig: SystemConfig }];
// album events
AlbumUpdate: [{ id: string; recipientId: string }];
AlbumUpdate: [{ id: string; userIds: string[]; recipientIds: string[] }];
AlbumInvite: [{ id: string; userId: string; senderName: string }];
// asset events
AssetCreate: [{ asset: Asset; file: UploadFile }];
AssetCreate: [{ asset: Pick<Asset, 'id' | 'ownerId'>; file?: UploadFile }];
AssetTag: [{ assetId: string }];
AssetUntag: [{ assetId: string }];
AssetHide: [{ assetId: string; userId: string }];
@ -220,11 +220,11 @@ export class EventRepository {
private addHandler<T extends EmitEvent>(item: Item<T>): void {
const event = item.event;
if (!this.emitHandlers[event]) {
if (!Object.hasOwn(this.emitHandlers, event)) {
this.emitHandlers[event] = [];
}
this.emitHandlers[event].push(item);
this.emitHandlers[event]!.push(item);
}
emit<T extends EmitEvent>(event: T, ...args: ArgsOf<T>): Promise<void> {

View file

@ -119,7 +119,7 @@ export class IntegrityRepository {
}
@GenerateSql({ params: [], stream: true })
streamAssetPaths() {
streamAssetPathsForMissingFiles() {
return this.db
.selectFrom((eb) =>
eb
@ -143,7 +143,7 @@ export class IntegrityRepository {
)
.leftJoin('integrity_report', (join) =>
join
.on('integrity_report.type', '=', IntegrityReport.UntrackedFile)
.on('integrity_report.type', '=', IntegrityReport.MissingFile)
.on((eb) =>
eb.or([
eb('integrity_report.assetId', '=', eb.ref('allPaths.assetId')),
@ -154,14 +154,13 @@ export class IntegrityRepository {
.select(['allPaths.path as path', 'allPaths.assetId', 'allPaths.fileAssetId', 'integrity_report.id as reportId'])
.stream() as AsyncIterableIterator<
{ path: string; reportId: string | null } & (
| { assetId: string; fileAssetId: null }
| { assetId: null; fileAssetId: string }
{ assetId: string; fileAssetId: null } | { assetId: null; fileAssetId: string }
)
>;
}
@GenerateSql({ params: [DummyValue.DATE, DummyValue.DATE], stream: true })
streamAssetChecksums(startMarker?: Date, endMarker?: Date) {
@GenerateSql({ params: [DummyValue.DATE], stream: true })
streamAssetChecksums(startMarker?: Date) {
return this.db
.selectFrom('asset')
.where('asset.deletedAt', 'is', null)
@ -178,9 +177,8 @@ export class IntegrityRepository {
'integrity_report.id as reportId',
])
.where('asset.isExternal', '=', sql.lit(false))
.$if(startMarker !== undefined, (qb) => qb.where('integrity_report.createdAt', '>=', startMarker!))
.$if(endMarker !== undefined, (qb) => qb.where('integrity_report.createdAt', '<=', endMarker!))
.orderBy('integrity_report.createdAt', 'asc')
.$if(startMarker !== undefined, (qb) => qb.where('asset.createdAt', '>=', startMarker!))
.orderBy('asset.createdAt', 'asc')
.stream();
}

View file

@ -54,11 +54,11 @@ export class JobRepository {
const label = `${Service.name}.${handler.name}`;
// one handler per job
if (this.handlers[jobName]) {
if (Object.hasOwn(this.handlers, jobName)) {
const jobKey = getKeyByValue(JobName, jobName);
const errorMessage = `Failed to add job handler for ${label}`;
this.logger.error(
`${errorMessage}. JobName.${jobKey} is already handled by ${this.handlers[jobName].label}.`,
`${errorMessage}. JobName.${jobKey} is already handled by ${this.handlers[jobName]!.label}.`,
);
throw new ImmichStartupError(errorMessage);
}
@ -104,24 +104,26 @@ export class JobRepository {
}
teardown() {
if (this.workerWatcher) {
clearInterval(this.workerWatcher);
this.workerWatcher = undefined;
if (!this.workerWatcher) {
return;
}
clearInterval(this.workerWatcher);
this.workerWatcher = undefined;
}
private async checkWorkers() {
let present: boolean;
let isPresent: boolean;
try {
const suffix = `:w:${ImmichWorker.Microservices}`;
const workers = await this.getQueue(QueueName.BackgroundTask).getWorkers();
present = workers.some((worker) => worker.rawname?.endsWith(suffix));
isPresent = workers.some((worker) => worker.rawname?.endsWith(suffix));
} catch {
return;
}
if (this.microservicesPresent !== present) {
if (present) {
if (this.microservicesPresent !== isPresent) {
if (isPresent) {
this.logger.log('Microservices worker connected.');
} else {
this.logger.warn(
@ -129,7 +131,7 @@ export class JobRepository {
);
}
}
this.microservicesPresent = present;
this.microservicesPresent = isPresent;
}
async run({ name, data }: JobItem) {
@ -212,7 +214,7 @@ export class JobRepository {
// need to use add() instead of addBulk() for jobId/deduplication to take effect
promises.push(this.getQueue(queueName).add(item.name, item.data, job.options));
} else {
itemsByQueue[queueName] = itemsByQueue[queueName] || [];
itemsByQueue[queueName] ||= [];
itemsByQueue[queueName].push(job);
}
}

View file

@ -26,7 +26,7 @@ describe(LoggingRepository.name, () => {
const logger = new MyConsoleLogger(clsMock, { color: true });
expect(logger.formatContext('context')).toBe('\u001B[33m[Api:context]\u001B[39m ');
expect(logger.formatContext('context')).toBe('\u{1B}[33m[Api:context]\u{1B}[39m ');
});
it('should not use colors when color is false', () => {

View file

@ -67,7 +67,7 @@ export class MyConsoleLogger extends ConsoleLogger {
};
private withColor(text: string, color: LogColor) {
return this.isColorEnabled ? `\u001B[${color}m${text}\u001B[39m` : text;
return this.isColorEnabled ? `\u{1B}[${color}m${text}\u{1B}[39m` : text;
}
}
@ -80,17 +80,17 @@ export class LoggingRepository {
@Inject(ClsService) cls: ClsService | undefined,
@Inject(ConfigRepository) configRepository: ConfigRepository | undefined,
) {
let noColor = false;
let isNoColor = false;
let logFormat = LogFormat.Console;
if (configRepository) {
const env = configRepository.getEnv();
noColor = env.noColor;
isNoColor = env.noColor;
logFormat = env.logFormat ?? logFormat;
}
this.logger = new MyConsoleLogger(cls, {
context: LoggingRepository.name,
json: logFormat === LogFormat.Json,
color: !noColor,
color: !isNoColor,
});
}

View file

@ -130,19 +130,19 @@ export class MachineLearningRepository {
}
private async check(url: string) {
let healthy = false;
let isHealthy = false;
try {
const response = await fetch(new URL('ping', url), {
signal: AbortSignal.timeout(this.config.availabilityChecks.timeout),
});
if (response.ok) {
healthy = true;
isHealthy = true;
}
} catch {
// nothing to do here
}
this.setHealthy(url, healthy);
this.setHealthy(url, isHealthy);
}
private setHealthy(url: string, healthy: boolean) {

View file

@ -291,8 +291,8 @@ export class MapRepository {
id: Number.parseInt(lineSplit[0]),
name: lineSplit[1],
alternateNames: lineSplit[3],
latitude: Number.parseFloat(lineSplit[4]),
longitude: Number.parseFloat(lineSplit[5]),
latitude: Number(lineSplit[4]),
longitude: Number(lineSplit[5]),
countryCode: lineSplit[8],
admin1Code: lineSplit[10],
admin2Code: lineSplit[11],
@ -308,6 +308,7 @@ export class MapRepository {
.insertInto('geodata_places')
.values(bufferGeodata)
.execute()
.then(() => {
count += curLength;
if (count % 10_000 === 0) {

View file

@ -3,10 +3,9 @@ import { ExifDateTime, exiftool, WriteTags } from 'exiftool-vendored';
import ffmpeg, { FfprobeData, FfprobeStream } from 'fluent-ffmpeg';
import _ from 'lodash';
import { Duration } from 'luxon';
import { execFile as execFileCb } from 'node:child_process';
import { spawn } from 'node:child_process';
import fs from 'node:fs/promises';
import { Writable } from 'node:stream';
import { promisify } from 'node:util';
import sharp from 'sharp';
import { ORIENTATION_TO_SHARP_ROTATION } from 'src/constants';
import { Exif } from 'src/database';
@ -44,11 +43,6 @@ const probe = (input: string, options: string[]): Promise<FfprobeData> =>
ffmpeg.ffprobe(input, options, (error, data) => (error ? reject(error) : resolve(data))),
);
const execFile = promisify(execFileCb);
sharp.concurrency(0);
sharp.cache({ files: 0 });
const pascalCase = (str: string) => _.upperFirst(_.camelCase(str.toLowerCase()));
type ProgressEvent = {
@ -69,6 +63,8 @@ export type ExtractResult = {
export class MediaRepository {
constructor(private logger: LoggingRepository) {
this.logger.setContext(MediaRepository.name);
sharp.concurrency(0);
sharp.cache({ files: 0 });
}
/**
@ -291,33 +287,37 @@ export class MediaRepository {
* Needed for accurate segments, especially when remuxing, seeking and/or VFR is involved.
* Scanning packets for keyframes in JS is much faster than -skip_frame nokey since it avoids decoding the video.
*/
async probePackets(input: string, streamIndex: number): Promise<VideoPacketInfo | null> {
const { stdout } = await execFile('ffprobe', [
'-v',
'error',
'-select_streams',
String(streamIndex),
'-show_entries',
'packet=pts,duration,flags',
'-of',
'csv=p=0',
input,
]);
probePackets(input: string, streamIndex: number): Promise<VideoPacketInfo | null> {
const ffprobe = spawn(
'ffprobe',
[
'-v',
'error',
'-select_streams',
String(streamIndex),
'-show_entries',
'packet=pts,duration,flags',
'-of',
'csv=p=0',
input,
],
{ stdio: ['ignore', 'pipe', 'pipe'] },
);
let totalDuration = 0;
const keyframePts: number[] = [];
const keyframeAccDuration: number[] = [];
const keyframeOwnDuration: number[] = [];
const postDiscard: { pts: number; duration: number }[] = [];
for (const line of stdout.split('\n')) {
const parseLine = (line: string) => {
if (!line) {
continue;
return;
}
const [ptsStr, durationStr, flags] = line.split(',');
const [ptsStr, durationStr, flags] = line.split(',', 3);
const pts = Number.parseInt(ptsStr);
const duration = Number.parseInt(durationStr);
if (Number.isNaN(pts) || Number.isNaN(duration)) {
continue;
if (Number.isNaN(pts) || Number.isNaN(duration) || !flags) {
return;
}
// Discarded packets don't contribute to packet count, but still contribute to video duration
totalDuration += duration;
@ -332,20 +332,43 @@ export class MediaRepository {
// Non-keyframes are accounted for in totalDuration.
keyframeOwnDuration.push(duration);
}
}
if (postDiscard.length === 0) {
return null;
}
return {
totalDuration,
packetCount: postDiscard.length,
outputFrames: this.cfrOutputFrames(postDiscard, postDiscard.length / totalDuration),
keyframePts,
keyframeAccDuration,
keyframeOwnDuration,
};
let stderr = '';
let remainder = '';
ffprobe.stderr.setEncoding('utf8');
ffprobe.stderr.on('data', (chunk: string) => (stderr += chunk));
ffprobe.stdout.setEncoding('utf8');
ffprobe.stdout.on('data', (chunk: string) => {
const lines = chunk.split('\n');
lines[0] = remainder + lines[0];
remainder = lines.pop() as string;
for (const line of lines) {
parseLine(line);
}
});
return new Promise<VideoPacketInfo | null>((resolve, reject) => {
ffprobe.on('error', reject);
ffprobe.on('close', (code) => {
if (code !== 0) {
return reject(new Error(`ffprobe exited with code ${code}: ${stderr.trim()}`));
}
parseLine(remainder);
if (postDiscard.length === 0) {
return resolve(null);
}
resolve({
totalDuration,
packetCount: postDiscard.length,
outputFrames: this.cfrOutputFrames(postDiscard, postDiscard.length / totalDuration),
keyframePts,
keyframeAccDuration,
keyframeOwnDuration,
});
});
});
}
transcode(input: string, output: string | Writable, options: TranscodeCommand): Promise<void> {
@ -387,7 +410,7 @@ export class MediaRepository {
}
async getImageMetadata(input: string | Buffer): Promise<ImageDimensions & { isTransparent: boolean }> {
const { width = 0, height = 0, hasAlpha = false } = await sharp(input).metadata();
const { width = 0, height = 0, hasAlpha = false } = await sharp(input, { unlimited: true }).metadata();
return { width, height, isTransparent: hasAlpha };
}
@ -427,6 +450,7 @@ export class MediaRepository {
}
private parseFloat(value: string | number | undefined): number {
// eslint-disable-next-line unicorn/prefer-number-coercion
return Number.parseFloat(value as string) || 0;
}

View file

@ -53,10 +53,10 @@ export interface ImmichTags extends Omit<Tags, TagsWithWrongTypes> {
RegionList: {
Area: {
// (X,Y) // center of the rectangle
X: number;
Y: number;
W: number;
H: number;
X: number | string;
Y: number | string;
W: number | string;
H: number | string;
Unit: string;
};
Rotation?: number;
@ -108,6 +108,7 @@ export class MetadataRepository {
readTags(path: string): Promise<ImmichTags> {
const options: ReadTaskOptions | undefined = mimeTypes.isVideo(path) ? { readArgs: ['-ee'] } : undefined;
return this.exiftool.read(path, options).catch((error) => {
this.logger.warn(`Error reading exif data (${path}): ${error}\n${error?.stack}`);
return {};

View file

@ -68,7 +68,7 @@ export class OAuthRepository {
params.code_challenge_method = 'S256';
}
const url = buildAuthorizationUrl(client, params).toString();
const url = buildAuthorizationUrl(client, params).href;
return { url, state, codeVerifier };
}
@ -83,7 +83,7 @@ export class OAuthRepository {
url: string,
expectedState: string,
codeVerifier: string,
): Promise<{ profile: OAuthProfile; sid?: string }> {
): Promise<{ profile: OAuthProfile; sid?: string; idToken?: string }> {
const client = await this.getClient(config);
const pkceCodeVerifier = client.serverMetadata().supportsPKCE() ? codeVerifier : undefined;
@ -111,7 +111,7 @@ export class OAuthRepository {
}
}
return { profile, sid };
return { profile, sid, idToken: tokens.id_token };
} catch (error: Error | any) {
if (error.message.includes('unexpected JWT alg received')) {
this.logger.warn(
@ -173,6 +173,7 @@ export class OAuthRepository {
// Validate specific Logout Token claims (RFC 8963):
// "events" claim must exist and contain the backchannel-logout event
const events = payload.events as Record<string, any> | undefined;
// eslint-disable-next-line unicorn/prefer-https
if (!events || !events['http://schemas.openid.net/event/backchannel-logout']) {
throw new Error('Missing backchannel-logout event claim');
}

View file

@ -190,6 +190,7 @@ export class PluginRepository {
description: ref('excluded.description'),
types: ref('excluded.types'),
hostFunctions: ref('excluded.hostFunctions'),
allowedHosts: ref('excluded.allowedHosts'),
uiHints: ref('excluded.uiHints'),
schema: ref('excluded.schema'),
})),
@ -240,7 +241,7 @@ export class PluginRepository {
}
}
async callMethod<T>({ pluginKey, methodName }: PluginMethod, input: unknown) {
async callMethod<T>({ pluginKey, methodName }: PluginMethod, input: unknown, context?: unknown) {
const item = this.pluginMap.get(pluginKey);
if (!item) {
throw new Error(`No loaded plugin found for ${pluginKey}`);
@ -251,7 +252,7 @@ export class PluginRepository {
try {
const plugin = await pool.acquire();
try {
const result = await plugin.call(methodName, JSON.stringify(input));
const result = await plugin.call(methodName, JSON.stringify(input), context);
return (result ? result.json() : result) as T;
} finally {
await pool.release(plugin);

View file

@ -7,7 +7,7 @@ export class ProcessRepository {
spawn = spawn;
spawnDuplexStream(command: string, args?: readonly string[], options?: SpawnOptionsWithoutStdio): Duplex {
let stdinClosed = false;
let isStdinClosed = false;
let drainCallback: undefined | (() => void);
const process = this.spawn(command, args, options);
@ -15,7 +15,7 @@ export class ProcessRepository {
// duplex -> stdin
write(chunk, encoding, callback) {
// drain the input if process dies
if (stdinClosed) {
if (isStdinClosed) {
return callback();
}
@ -36,7 +36,7 @@ export class ProcessRepository {
},
final(callback) {
if (stdinClosed) {
if (isStdinClosed) {
callback();
} else {
process.stdin.end(callback);
@ -55,19 +55,19 @@ export class ProcessRepository {
duplex.on('resume', () => process.stdout.resume());
// end handling
let stdoutClosed = false;
let isStdoutClosed = false;
function close(error?: Error) {
stdinClosed = true;
isStdinClosed = true;
if (error) {
duplex.destroy(error);
} else if (stdoutClosed && typeof process.exitCode === 'number') {
} else if (isStdoutClosed && typeof process.exitCode === 'number') {
duplex.push(null);
}
}
process.stdout.on('close', () => {
stdoutClosed = true;
isStdoutClosed = true;
close();
});

View file

@ -117,7 +117,8 @@ type BaseAssetSearchOptions = SearchDateOptions &
SearchAlbumOptions &
SearchOcrOptions;
export type AssetSearchOptions = BaseAssetSearchOptions & SearchRelationOptions;
export type AssetSearchOptions = Omit<BaseAssetSearchOptions, 'visibility'> &
SearchRelationOptions & { visibility?: AssetVisibility | 'not-locked' };
export type AssetSearchBuilderOptions = Omit<AssetSearchOptions, 'orderDirection'>;
@ -125,11 +126,11 @@ export type SmartSearchOptions = SearchDateOptions &
SearchEmbeddingOptions &
SearchExifOptions &
SearchOneToOneRelationOptions &
SearchStatusOptions &
Omit<SearchStatusOptions, 'visibility'> &
SearchUserIdOptions &
SearchPeopleOptions &
SearchTagOptions &
SearchOcrOptions;
SearchOcrOptions & { visibility?: AssetVisibility | 'not-locked' };
export type OcrSearchOptions = SearchDateOptions & SearchOcrOptions;

View file

@ -35,7 +35,7 @@ const exec = promisify(execCallback);
const maybeFirstLine = async (command: string): Promise<string> => {
try {
const { stdout } = await exec(command);
return stdout.trim().split('\n')[0] || '';
return stdout.trim().split('\n', 1)[0] || '';
} catch {
return '';
}
@ -111,6 +111,7 @@ export class ServerInfoRepository {
const lockfile: BuildLockfile | undefined = await readFile(resourcePaths.lockFile)
.then((buffer) => JSON.parse(buffer.toString()))
.catch(() => this.logger.warn(`Failed to read ${resourcePaths.lockFile}`));
const [nodejsVersion, ffmpegVersion, magickVersion, exiftoolVersion] = await Promise.all([

View file

@ -32,7 +32,7 @@ export class SessionRepository {
get(id: string) {
return this.db
.selectFrom('session')
.select(['id', 'expiresAt', 'pinExpiresAt'])
.select(['id', 'expiresAt', 'pinExpiresAt', 'oauthBearerToken'])
.where('id', '=', id)
.executeTakeFirst();
}

View file

@ -95,10 +95,12 @@ export const bootstrapTelemetry = (port: number) => {
};
export const teardownTelemetry = async () => {
if (instance) {
await instance.shutdown();
instance = undefined;
if (!instance) {
return;
}
await instance.shutdown();
instance = undefined;
};
@Injectable()
@ -153,7 +155,7 @@ export class TelemetryRepository {
}
const method = descriptor.value;
const propertyName = snakeCase(String(propName));
const propertyName = snakeCase(propName);
const metricName = `${snakeCase(className).replaceAll(/_(?=(repository)|(controller)|(provider)|(service)|(module))/g, '.')}.${propertyName}.duration`;
const histogram = this.metricService.getHistogram(metricName, {
@ -165,6 +167,7 @@ export class TelemetryRepository {
descriptor.value = function (...args: any[]) {
const start = performance.now();
// eslint-disable-next-line unicorn/no-this-outside-of-class
const result = method.apply(this, args);
void Promise.resolve(result)

View file

@ -46,7 +46,7 @@ export class UserRepository {
@GenerateSql({ params: [DummyValue.UUID, DummyValue.BOOLEAN] })
get(userId: string, options: UserFindOptions) {
options = options || {};
options ||= {};
return this.db
.selectFrom('user')

View file

@ -38,6 +38,7 @@ export interface ClientEventMap {
on_asset_hidden: [string];
on_asset_restore: [string[]];
on_asset_stack_update: string[];
on_album_update: [string];
on_person_thumbnail: [string];
on_server_version: [ServerVersionResponseDto];
on_config_update: [];

View file

@ -39,7 +39,8 @@ export class WorkflowRepository {
'plugin_method.name as methodName',
'workflow_step.config',
'workflow_step.enabled',
]),
])
.orderBy('workflow_step.order', 'asc'),
).as('steps'),
]);
}
@ -79,6 +80,7 @@ export class WorkflowRepository {
'plugin_method.name as methodName',
'plugin_method.types as types',
'plugin_method.hostFunctions',
'plugin_method.allowedHosts',
]),
).as('steps'),
])

View file

@ -0,0 +1,9 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "plugin_method" ADD "allowedHosts" character varying[] NOT NULL DEFAULT '{}';`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "plugin_method" DROP COLUMN "allowedHosts";`.execute(db);
}

View file

@ -0,0 +1,16 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
// (#29836) Reset the visibility of any still images that are hidden and have a motion part
await sql`
UPDATE "asset"
SET "visibility" = 'timeline'
WHERE "type" = 'IMAGE'
AND "visibility" = 'hidden'
AND "livePhotoVideoId" IS NOT NULL
`.execute(db);
}
export async function down(): Promise<void> {
// Not implemented: the previous 'hidden' value was itself the bug.
}

View file

@ -0,0 +1,9 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "session" ADD "oauthBearerToken" character varying;`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "session" DROP COLUMN "oauthBearerToken";`.execute(db);
}

View file

@ -27,6 +27,9 @@ export class PluginMethodTable {
@Column({ type: 'boolean', default: false })
hostFunctions!: Generated<boolean>;
@Column({ type: 'character varying', default: [], array: true })
allowedHosts!: Generated<string[]>;
@Column({ type: 'jsonb', nullable: true })
schema!: JsonSchemaDto | null;

View file

@ -55,4 +55,7 @@ export class SessionTable {
@Column({ nullable: true, index: true })
oauthSid!: string | null;
@Column({ nullable: true })
oauthBearerToken!: string | null;
}

View file

@ -44,7 +44,7 @@ export class ActivityService extends BaseService {
};
let activity: Activity | undefined;
let duplicate = false;
let isDuplicate = false;
if (dto.type === ReactionType.LIKE) {
delete dto.comment;
@ -54,7 +54,7 @@ export class ActivityService extends BaseService {
assetId: dto.assetId ?? null,
isLiked: true,
});
duplicate = !!activity;
isDuplicate = !!activity;
}
if (!activity) {
@ -65,7 +65,7 @@ export class ActivityService extends BaseService {
});
}
return { duplicate, value: mapActivity(activity) };
return { duplicate: isDuplicate, value: mapActivity(activity) };
}
async delete(auth: AuthDto, id: string): Promise<void> {

View file

@ -663,6 +663,7 @@ describe(AlbumService.name, () => {
const album = AlbumFactory.from().albumUser({ userId: user.id }).build();
const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
mocks.album.getById.mockResolvedValue(getForAlbum(album));
mocks.albumUser.update.mockResolvedValue();
await sut.updateUser(AuthFactory.create(owner), album.id, user.id, { role: AlbumUserRole.Viewer });
@ -840,7 +841,8 @@ describe(AlbumService.name, () => {
expect(mocks.album.addAssetIds).toHaveBeenCalledWith(album.id, [asset1.id, asset2.id, asset3.id]);
expect(mocks.event.emit).toHaveBeenCalledWith('AlbumUpdate', {
id: album.id,
recipientId: owner.id,
userIds: album.albumUsers.map(({ user }) => user.id),
recipientIds: [owner.id],
});
});
@ -1090,11 +1092,13 @@ describe(AlbumService.name, () => {
]);
expect(mocks.event.emit).toHaveBeenCalledWith('AlbumUpdate', {
id: album1.id,
recipientId: owner1.id,
userIds: album1.albumUsers.map(({ user }) => user.id),
recipientIds: [owner1.id],
});
expect(mocks.event.emit).toHaveBeenCalledWith('AlbumUpdate', {
id: album2.id,
recipientId: owner2.id,
userIds: album2.albumUsers.map(({ user }) => user.id),
recipientIds: [owner2.id],
});
});

View file

@ -190,11 +190,9 @@ export class AlbumService extends BaseService {
auth.user.id,
);
const allUsersExceptUs = album.albumUsers.map(({ user }) => user.id).filter((userId) => userId !== auth.user.id);
for (const recipientId of allUsersExceptUs) {
await this.eventRepository.emit('AlbumUpdate', { id, recipientId });
}
const userIds = album.albumUsers.map(({ user }) => user.id);
const recipientIds = userIds.filter((userId) => userId !== auth.user.id);
await this.eventRepository.emit('AlbumUpdate', { id, userIds, recipientIds });
}
return results;
@ -223,10 +221,10 @@ export class AlbumService extends BaseService {
}
const albumAssetValues: { albumId: string; assetId: string }[] = [];
const events: { id: string; recipients: string[] }[] = [];
const events: { id: string; userIds: string[]; recipientIds: string[] }[] = [];
for (const albumId of allowedAlbumIds) {
const existingAssetIds = await this.albumRepository.getAssetIds(albumId, [...allowedAssetIds]);
const notPresentAssetIds = [...allowedAssetIds].filter((id) => !existingAssetIds.has(id));
const notPresentAssetIds = [...allowedAssetIds.difference(existingAssetIds)];
if (notPresentAssetIds.length === 0) {
continue;
}
@ -246,15 +244,14 @@ export class AlbumService extends BaseService {
},
auth.user.id,
);
const allUsersExceptUs = album.albumUsers.map(({ user }) => user.id).filter((userId) => userId !== auth.user.id);
events.push({ id: albumId, recipients: allUsersExceptUs });
const userIds = album.albumUsers.map(({ user }) => user.id);
const recipientIds = userIds.filter((userId) => userId !== auth.user.id);
events.push({ id: albumId, userIds, recipientIds });
}
await this.albumRepository.addAssetIdsToAlbums(albumAssetValues);
for (const event of events) {
for (const recipientId of event.recipients) {
await this.eventRepository.emit('AlbumUpdate', { id: event.id, recipientId });
}
await this.eventRepository.emit('AlbumUpdate', event);
}
return results;
@ -271,8 +268,16 @@ export class AlbumService extends BaseService {
);
const removedIds = results.filter(({ success }) => success).map(({ id }) => id);
if (removedIds.length > 0 && album.albumThumbnailAssetId && removedIds.includes(album.albumThumbnailAssetId)) {
await this.albumRepository.updateThumbnails();
if (removedIds.length > 0) {
if (album.albumThumbnailAssetId && removedIds.includes(album.albumThumbnailAssetId)) {
await this.albumRepository.updateThumbnails();
}
await this.eventRepository.emit('AlbumUpdate', {
id,
userIds: album.albumUsers.map(({ user }) => user.id),
recipientIds: [],
});
}
return results;
@ -288,7 +293,7 @@ export class AlbumService extends BaseService {
throw new BadRequestException('Cannot add another owner');
}
const exists = album.albumUsers.find(({ user: { id } }) => id === userId);
const exists = album.albumUsers.some(({ user: { id } }) => id === userId);
if (exists) {
continue;
}
@ -303,7 +308,7 @@ export class AlbumService extends BaseService {
await this.eventRepository.emit('AlbumInvite', { id, userId, senderName: auth.user.name });
}
return this.findOrFail(id, auth.user.id, { withAssets: true }).then(mapAlbum);
return mapAlbum(await this.findOrFail(id, auth.user.id, { withAssets: true }));
}
async removeUser(auth: AuthDto, id: string, userId: string | 'me'): Promise<void> {
@ -335,6 +340,14 @@ export class AlbumService extends BaseService {
async updateUser(auth: AuthDto, id: string, userId: string, dto: UpdateAlbumUserDto): Promise<void> {
await this.requireAccess({ auth, permission: Permission.AlbumShare, ids: [id] });
const album = await this.findOrFail(id, userId, { withAssets: false });
const owner = album.albumUsers[0];
if (owner.user.id === userId) {
throw new BadRequestException('User is owner');
}
await this.albumUserRepository.update({ albumId: id, userId }, { role: dto.role });
}

View file

@ -20,16 +20,9 @@ export const render = (index: string, meta: OpenGraphTags) => {
<meta property="og:type" content="website" />
<meta property="og:title" content="${title}" />
<meta property="og:description" content="${description}" />
${imageUrl ? `<meta property="og:image" content="${imageUrl}" />` : ''}
${imageUrl ? `<meta property="og:image" content="${imageUrl}" />` : ''}`;
<!-- Twitter Meta Tags -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="${title}" />
<meta name="twitter:description" content="${description}" />
${imageUrl ? `<meta name="twitter:image" content="${imageUrl}" />` : ''}`;
return index.replace('<!-- metadata:tags -->', tags);
return index.replace('<!-- metadata:tags -->', () => tags);
};
@Injectable()

View file

@ -163,8 +163,8 @@ const assetEntity = Object.freeze({
duration: null,
files: [] as AssetFile[],
exifInfo: {
latitude: 49.533_547,
longitude: 10.703_075,
latitude: 49.533547,
longitude: 10.703075,
},
livePhotoVideoId: null,
} as MapAsset);
@ -269,6 +269,10 @@ describe(AssetMediaService.name, () => {
'random-uuid.jpg',
);
});
it('should accept filenames with just an extension', () => {
expect(sut.getUploadFilename(uploadFile.filename(UploadFieldName.ASSET_DATA, '.jpg'))).toEqual('random-uuid.jpg');
});
});
describe('getUploadFolder', () => {

View file

@ -1,5 +1,4 @@
import { BadRequestException, Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common';
import { extname } from 'node:path';
import sanitize from 'sanitize-filename';
import { StorageCore } from 'src/cores/storage.core';
import { AuthSharedLink } from 'src/database';
@ -92,8 +91,7 @@ export class AssetMediaService extends BaseService {
getUploadFilename({ auth, fieldName, file, body }: UploadRequest): string {
requireUploadAccess(auth);
const extension = extname(body.filename || file.originalName);
const extension = getFilenameExtension(body.filename || file.originalName);
const lookup = {
[UploadFieldName.ASSET_DATA]: extension,
[UploadFieldName.SIDECAR_DATA]: '.xmp',
@ -342,9 +340,23 @@ export class AssetMediaService extends BaseService {
}
private async addToSharedLink(sharedLink: AuthSharedLink, assetId: string) {
await (sharedLink.albumId
? this.albumRepository.addAssetIds(sharedLink.albumId, [assetId])
: this.sharedLinkRepository.addAssets(sharedLink.id, [assetId]));
if (!sharedLink.albumId) {
await this.sharedLinkRepository.addAssets(sharedLink.id, [assetId]);
return;
}
const album = await this.albumRepository.getById(sharedLink.albumId, { withAssets: false });
if (!album) {
return;
}
await this.albumRepository.addAssetIds(album.id, [assetId]);
const userIds = album.albumUsers.map(({ user }) => user.id);
await this.eventRepository.emit('AlbumUpdate', {
id: album.id,
userIds,
recipientIds: userIds,
});
}
private requireQuota(auth: AuthDto, size: number) {

View file

@ -280,15 +280,17 @@ export class AssetService extends BaseService {
let chunk: Array<{ id: string; isOffline: boolean }> = [];
const queueChunk = async () => {
if (chunk.length > 0) {
await this.jobRepository.queueAll(
chunk.map(({ id, isOffline }) => ({
name: JobName.AssetDelete,
data: { id, deleteOnDisk: !isOffline },
})),
);
chunk = [];
if (chunk.length === 0) {
return;
}
await this.jobRepository.queueAll(
chunk.map(({ id, isOffline }) => ({
name: JobName.AssetDelete,
data: { id, deleteOnDisk: !isOffline },
})),
);
chunk = [];
};
const assets = this.assetJobRepository.streamForDeletedJob(trashedBefore);

View file

@ -160,7 +160,25 @@ describe(AuthService.name, () => {
await expect(sut.logout(auth, AuthType.OAuth)).resolves.toEqual({
successful: true,
redirectUri: 'http://end-session-endpoint',
redirectUri: 'http://end-session-endpoint/',
});
});
it('should include the id token hint for OAuth sessions', async () => {
const auth = AuthFactory.from().session().build();
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
mocks.session.get.mockResolvedValue({
id: auth.session!.id,
expiresAt: null,
oauthBearerToken: 'id-token',
pinExpiresAt: null,
});
mocks.session.delete.mockResolvedValue();
await expect(sut.logout(auth, AuthType.OAuth)).resolves.toEqual({
successful: true,
redirectUri: 'http://end-session-endpoint/?id_token_hint=id-token',
});
});
@ -173,7 +191,7 @@ describe(AuthService.name, () => {
await expect(sut.logout(auth, AuthType.OAuth)).resolves.toEqual({
successful: true,
redirectUri: 'http://custom-logout-url',
redirectUri: 'http://custom-logout-url/',
});
});
@ -186,7 +204,7 @@ describe(AuthService.name, () => {
await expect(sut.logout(auth, AuthType.OAuth)).resolves.toEqual({
successful: true,
redirectUri: 'http://end-session-endpoint',
redirectUri: 'http://end-session-endpoint/',
});
});
@ -201,6 +219,12 @@ describe(AuthService.name, () => {
it('should delete the access token', async () => {
const auth = { user: { id: '123' }, session: { id: 'token123' } } as AuthDto;
mocks.session.get.mockResolvedValue({
id: auth.session!.id,
expiresAt: null,
oauthBearerToken: null,
pinExpiresAt: null,
});
mocks.session.delete.mockResolvedValue();
await expect(sut.logout(auth, AuthType.Password)).resolves.toEqual({
@ -653,13 +677,13 @@ describe(AuthService.name, () => {
describe('getMobileRedirect', () => {
it('should pass along the query params', () => {
expect(sut.getMobileRedirect('http://immich.app?code=123&state=456')).toEqual(
expect(sut.getMobileRedirect('https://immich.app?code=123&state=456')).toEqual(
'app.immich:///oauth-callback?code=123&state=456',
);
});
it('should work if called without query params', () => {
expect(sut.getMobileRedirect('http://immich.app')).toEqual('app.immich:///oauth-callback?');
expect(sut.getMobileRedirect('https://immich.app')).toEqual('app.immich:///oauth-callback?');
});
});
@ -722,6 +746,27 @@ describe(AuthService.name, () => {
expect(mocks.user.update).toHaveBeenCalledWith(user.id, { oauthId: profile.sub });
});
it('should store the OAuth bearer token on the new session', async () => {
const user = UserFactory.create();
const profile = OAuthProfileFactory.create();
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile, sid: 'oauth-sid', idToken: 'oauth-bearer-token' });
mocks.user.getByEmail.mockResolvedValue(user);
mocks.user.update.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(SessionFactory.create());
await sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foobar' },
{},
loginDetails,
);
expect(mocks.session.create).toHaveBeenCalledWith(
expect.objectContaining({ oauthSid: 'oauth-sid', oauthBearerToken: 'oauth-bearer-token' }),
);
});
it('should normalize the email from the OAuth profile before linking', async () => {
const user = UserFactory.create();
const profile = OAuthProfileFactory.create({ email: ' TEST@IMMICH.CLOUD ' });
@ -979,7 +1024,7 @@ describe(AuthService.name, () => {
});
expect(mocks.oauth.getProfilePicture).toHaveBeenCalledWith(profile.picture);
expect(mocks.media.generateThumbnail).toHaveBeenCalledWith(
Buffer.from(pictureBytes.buffer),
Buffer.from(pictureBytes.buffer, pictureBytes.byteOffset, pictureBytes.byteLength),
expect.objectContaining({ format: 'webp', processInvalidImages: false }),
expect.stringContaining(`/data/profile/${user.id}/${fileId}.webp`),
);
@ -1095,6 +1140,125 @@ describe(AuthService.name, () => {
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ isAdmin: true }));
});
it('should create an admin user if the role claim is an array containing admin', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
profile: OAuthProfileFactory.create({ immich_role: ['user', 'admin'] }),
});
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getByOAuthId.mockResolvedValue(void 0);
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
mocks.session.create.mockResolvedValue(SessionFactory.create());
await sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
);
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ isAdmin: true }));
});
it('should create a standard user if the role claim is an array containing only user', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
profile: OAuthProfileFactory.create({ immich_role: ['user'] }),
});
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getByOAuthId.mockResolvedValue(void 0);
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
mocks.session.create.mockResolvedValue(SessionFactory.create());
await sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
);
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ isAdmin: false }));
});
it('should promote an existing user to admin if the role claim contains admin on login', async () => {
const user = UserFactory.create({ isAdmin: false, oauthId: 'oauth-id' });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
profile: OAuthProfileFactory.create({ sub: user.oauthId, immich_role: 'admin' }),
});
mocks.user.getByOAuthId.mockResolvedValue(user);
mocks.user.update.mockResolvedValue({ ...user, isAdmin: true });
mocks.session.create.mockResolvedValue(SessionFactory.create());
await sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
);
expect(mocks.user.update).toHaveBeenCalledWith(user.id, { isAdmin: true });
});
it('should demote an existing admin if the role claim only contains user on login', async () => {
const user = UserFactory.create({ isAdmin: true, oauthId: 'oauth-id' });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
profile: OAuthProfileFactory.create({ sub: user.oauthId, immich_role: ['user'] }),
});
mocks.user.getByOAuthId.mockResolvedValue(user);
mocks.user.update.mockResolvedValue({ ...user, isAdmin: false });
mocks.session.create.mockResolvedValue(SessionFactory.create());
await sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
);
expect(mocks.user.update).toHaveBeenCalledWith(user.id, { isAdmin: false });
});
it('should not change isAdmin for an existing user if the role claim is blank', async () => {
const user = UserFactory.create({ isAdmin: true, oauthId: 'oauth-id' });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
profile: OAuthProfileFactory.create({ sub: user.oauthId }),
});
mocks.user.getByOAuthId.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(SessionFactory.create());
await sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
);
expect(mocks.user.update).not.toHaveBeenCalled();
});
it('should re-evaluate the role claim for a user linked by email', async () => {
const user = UserFactory.create({ isAdmin: false });
const profile = OAuthProfileFactory.create({ immich_role: 'admin' });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile });
mocks.user.getByEmail.mockResolvedValue(user);
mocks.user.update.mockResolvedValueOnce({ ...user, oauthId: profile.sub });
mocks.user.update.mockResolvedValueOnce({ ...user, oauthId: profile.sub, isAdmin: true });
mocks.session.create.mockResolvedValue(SessionFactory.create());
await sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foobar' },
{},
loginDetails,
);
expect(mocks.user.update).toHaveBeenCalledWith(user.id, { oauthId: profile.sub });
expect(mocks.user.update).toHaveBeenCalledWith(user.id, { isAdmin: true });
});
});
describe('link', () => {
@ -1125,6 +1289,7 @@ describe(AuthService.name, () => {
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
profile: { sub: 'sub' },
sid: session.oauthSid ?? undefined,
idToken: session.oauthBearerToken ?? undefined,
});
mocks.user.update.mockResolvedValue(user);
mocks.session.update.mockResolvedValue(session);
@ -1135,7 +1300,10 @@ describe(AuthService.name, () => {
{},
);
expect(mocks.session.update).toHaveBeenCalledWith(session.id, { oauthSid: session.oauthSid });
expect(mocks.session.update).toHaveBeenCalledWith(session.id, {
oauthSid: session.oauthSid,
oauthBearerToken: session.oauthBearerToken,
});
expect(mocks.user.update).toHaveBeenCalledWith(auth.user.id, { oauthId: 'sub' });
});
@ -1169,7 +1337,7 @@ describe(AuthService.name, () => {
expect(mocks.user.update).toHaveBeenCalledWith(auth.user.id, { oauthId: '' });
});
it('should unlink an account and remove the oauthSid from the session', async () => {
it('should unlink an account and remove the OAuth data from the session', async () => {
const user = UserFactory.create();
const session = SessionFactory.create();
const auth = AuthFactory.from(user).session(session).build();
@ -1180,7 +1348,7 @@ describe(AuthService.name, () => {
await sut.unlink(auth);
expect(mocks.session.update).toHaveBeenCalledWith(session.id, { oauthSid: null });
expect(mocks.session.update).toHaveBeenCalledWith(session.id, { oauthSid: null, oauthBearerToken: null });
expect(mocks.user.update).toHaveBeenCalledWith(auth.user.id, { oauthId: '' });
});
});

View file

@ -65,9 +65,9 @@ export class AuthService extends BaseService {
const user = await this.userRepository.getByEmail(dto.email, { withPassword: true });
// Always run bcrypt so response time is constant regardless of whether the email
// is registered, preventing timing-based user enumeration.
const authenticated = this.cryptoRepository.compareBcrypt(dto.password, user?.password ?? LOGIN_DUMMY_HASH);
const isAuthenticated = this.cryptoRepository.compareBcrypt(dto.password, user?.password ?? LOGIN_DUMMY_HASH);
if (!user || !user.password || !authenticated) {
if (!user || !user.password || !isAuthenticated) {
this.logger.warn(`Failed login attempt for user ${dto.email} from ip address ${details.clientIp}`);
throw new UnauthorizedException('Incorrect email or password');
}
@ -76,14 +76,17 @@ export class AuthService extends BaseService {
}
async logout(auth: AuthDto, authType: AuthType): Promise<LogoutResponseDto> {
let oauthBearerToken: string | undefined;
if (auth.session) {
const session = await this.sessionRepository.get(auth.session.id);
oauthBearerToken = session?.oauthBearerToken ?? undefined;
await this.sessionRepository.delete(auth.session.id);
await this.eventRepository.emit('SessionDelete', { sessionId: auth.session.id });
}
return {
successful: true,
redirectUri: await this.getLogoutEndpoint(authType),
redirectUri: await this.getLogoutEndpoint(authType, oauthBearerToken),
};
}
@ -124,8 +127,8 @@ export class AuthService extends BaseService {
async changePassword(auth: AuthDto, dto: ChangePasswordDto): Promise<UserAdminResponseDto> {
const { password, newPassword } = dto;
const user = await this.userRepository.getForChangePassword(auth.user.id);
const valid = this.validateSecret(password, user.password);
if (!valid) {
const isValid = this.validateSecret(password, user.password);
if (!isValid) {
throw new BadRequestException('Wrong password');
}
@ -271,7 +274,7 @@ export class AuthService extends BaseService {
}
getMobileRedirect(url: string) {
return `${MOBILE_REDIRECT}?${url.split('?')[1] || ''}`;
return `${MOBILE_REDIRECT}?${url.split('?', 2)[1] || ''}`;
}
async authorize(dto: OAuthConfigDto) {
@ -306,12 +309,11 @@ export class AuthService extends BaseService {
}
const url = this.resolveRedirectUri(oauth, dto.url);
const { profile, sid: oauthSid } = await this.oauthRepository.getProfileAndOAuthSid(
oauth,
url,
expectedState,
codeVerifier,
);
const {
profile,
sid: oauthSid,
idToken: oauthBearerToken,
} = await this.oauthRepository.getProfileAndOAuthSid(oauth, url, expectedState, codeVerifier);
const normalizedEmail = profile.email ? profile.email.trim().toLowerCase() : undefined;
const { autoRegister, defaultStorageQuota, storageLabelClaim, storageQuotaClaim, roleClaim } = oauth;
this.logger.debug(`Logging in with OAuth: ${JSON.stringify(profile)}`);
@ -329,6 +331,13 @@ export class AuthService extends BaseService {
}
}
const role = this.getRoleClaim(profile, roleClaim);
const isAdmin = role === 'admin';
if (user && role && isAdmin !== user.isAdmin) {
user = await this.userRepository.update(user.id, { isAdmin });
}
// register new user
if (!user) {
if (!autoRegister) {
@ -354,11 +363,6 @@ export class AuthService extends BaseService {
default: defaultStorageQuota,
isValid: (value: unknown) => Number(value) >= 0,
});
const role = this.getClaim<'admin' | 'user'>(profile, {
key: roleClaim,
default: 'user',
isValid: (value: unknown) => typeof value === 'string' && ['admin', 'user'].includes(value),
});
user = await this.createUser({
name:
@ -370,7 +374,7 @@ export class AuthService extends BaseService {
oauthId: profile.sub,
quotaSizeInBytes: storageQuota === null ? null : storageQuota * HumanReadableSize.GiB,
storageLabel: storageLabel || null,
isAdmin: role === 'admin',
isAdmin,
});
}
@ -378,7 +382,7 @@ export class AuthService extends BaseService {
await this.syncProfilePicture(user, profile.picture);
}
return this.createLoginResponse(user, loginDetails, oauthSid);
return this.createLoginResponse(user, loginDetails, oauthSid, oauthBearerToken);
}
private async syncProfilePicture(user: UserAdmin, url: string) {
@ -419,6 +423,7 @@ export class AuthService extends BaseService {
const {
profile: { sub: oauthId },
sid,
idToken,
} = await this.oauthRepository.getProfileAndOAuthSid(oauth, dto.url, expectedState, codeVerifier);
const duplicate = await this.userRepository.getByOAuthId(oauthId);
if (duplicate && duplicate.id !== auth.user.id) {
@ -426,8 +431,11 @@ export class AuthService extends BaseService {
throw new BadRequestException('This OAuth account has already been linked to another user.');
}
if (auth.session) {
await this.sessionRepository.update(auth.session.id, { oauthSid: sid });
if (auth.session && (sid || idToken)) {
await this.sessionRepository.update(auth.session.id, {
oauthSid: sid,
oauthBearerToken: idToken,
});
}
const user = await this.userRepository.update(auth.user.id, { oauthId });
@ -436,14 +444,14 @@ export class AuthService extends BaseService {
async unlink(auth: AuthDto): Promise<UserAdminResponseDto> {
if (auth.session) {
await this.sessionRepository.update(auth.session.id, { oauthSid: null });
await this.sessionRepository.update(auth.session.id, { oauthSid: null, oauthBearerToken: null });
}
const user = await this.userRepository.update(auth.user.id, { oauthId: '' });
return mapUserAdmin(user);
}
private async getLogoutEndpoint(authType: AuthType): Promise<string> {
private async getLogoutEndpoint(authType: AuthType, oauthBearerToken?: string | null): Promise<string> {
if (authType !== AuthType.OAuth) {
return LOGIN_URL;
}
@ -453,15 +461,24 @@ export class AuthService extends BaseService {
return LOGIN_URL;
}
if (config.oauth.endSessionEndpoint) {
return config.oauth.endSessionEndpoint;
const endSessionEndpoint =
config.oauth.endSessionEndpoint || (await this.oauthRepository.getLogoutEndpoint(config.oauth));
if (!endSessionEndpoint) {
return LOGIN_URL;
}
return (await this.oauthRepository.getLogoutEndpoint(config.oauth)) || LOGIN_URL;
const url = new URL(endSessionEndpoint);
if (oauthBearerToken) {
url.searchParams.set('id_token_hint', oauthBearerToken);
}
return url.href;
}
private getBearerToken(headers: IncomingHttpHeaders): string | null {
const [type, token] = (headers.authorization || '').split(' ');
const [type, token] = (headers.authorization || '').split(' ', 2);
if (type.toLowerCase() === 'bearer') {
return token;
}
@ -599,7 +616,12 @@ export class AuthService extends BaseService {
await this.sessionRepository.update(auth.session.id, { pinExpiresAt: null });
}
private async createLoginResponse(user: UserAdmin, loginDetails: LoginDetails, oauthSid?: string) {
private async createLoginResponse(
user: UserAdmin,
loginDetails: LoginDetails,
oauthSid?: string,
oauthBearerToken?: string,
) {
const token = this.cryptoRepository.randomBytesAsText(32);
const hashed = this.cryptoRepository.hashSha256(token);
@ -610,6 +632,7 @@ export class AuthService extends BaseService {
appVersion: loginDetails.appVersion,
userId: user.id,
oauthSid: oauthSid ?? null,
oauthBearerToken: oauthBearerToken ?? null,
});
return mapLoginResponse(user, token);
@ -620,12 +643,25 @@ export class AuthService extends BaseService {
return options.isValid(value) ? (value as T) : options.default;
}
private getRoleClaim(profile: OAuthProfile, roleClaim: string): 'admin' | 'user' | undefined {
const value = profile[roleClaim as keyof OAuthProfile];
const roles = Array.isArray(value) ? value : [value];
const isRole = (role: string) => roles.includes(role);
if (isRole('admin')) {
return 'admin';
}
if (isRole('user')) {
return 'user';
}
}
private resolveRedirectUri(
{ mobileRedirectUri, mobileOverrideEnabled }: { mobileRedirectUri: string; mobileOverrideEnabled: boolean },
url: string,
) {
if (mobileOverrideEnabled && mobileRedirectUri) {
return url.replace(/app\.immich:\/+oauth-callback/, mobileRedirectUri);
return url.replace(/app\.immich:\/+oauth-callback/, () => mobileRedirectUri);
}
return url;
}

View file

@ -247,7 +247,7 @@ export class BaseService {
ctx.workflowRepository,
);
service.logger.setContext(this.name);
service.logger.setContext(BaseService.name);
return service as T;
}

View file

@ -45,7 +45,6 @@ export class CliService extends BaseService {
if (!filesSet.has(name) && rowsSet.has(name)) {
migrations.push({ name, status: 'deleted' });
continue;
}
}
@ -87,9 +86,9 @@ export class CliService extends BaseService {
}
async disableMaintenanceMode(): Promise<{ alreadyDisabled: boolean }> {
const currentState = await this.systemMetadataRepository
.get(SystemMetadataKey.MaintenanceMode)
.then((state) => state ?? { isMaintenanceMode: false as const });
const currentState = (await this.systemMetadataRepository.get(SystemMetadataKey.MaintenanceMode)) ?? {
isMaintenanceMode: false as const,
};
if (!currentState.isMaintenanceMode) {
return {
@ -114,9 +113,9 @@ export class CliService extends BaseService {
username: 'cli-admin',
};
const state = await this.systemMetadataRepository
.get(SystemMetadataKey.MaintenanceMode)
.then((state) => state ?? { isMaintenanceMode: false as const });
const state = (await this.systemMetadataRepository.get(SystemMetadataKey.MaintenanceMode)) ?? {
isMaintenanceMode: false as const,
};
if (state.isMaintenanceMode) {
return {
@ -182,11 +181,7 @@ export class CliService extends BaseService {
this.userRepository.getFileSamples(),
]);
const paths = [];
for (const person of people) {
paths.push(person.thumbnailPath);
}
const paths = Array.from(people, (person) => person.thumbnailPath);
for (const user of users) {
paths.push(user.profileImagePath);

View file

@ -143,7 +143,7 @@ export class DatabaseBackupService {
databaseUsername = parsedUrl.username || parsedUrl.searchParams.get('user');
url = parsedUrl.toString();
url = parsedUrl.href;
}
// assume typical values if we can't parse URL or not present
@ -228,7 +228,7 @@ export class DatabaseBackupService {
this.logger.log(`Database Backup Starting. Database Version: ${databaseMajorVersion}`);
const filename = `${filenamePrefix}immich-db-backup-${DateTime.now().toFormat("yyyyLLdd'T'HHmmss")}-v${serverVersion.toString()}-pg${databaseVersion.split(' ')[0]}.sql.gz`;
const filename = `${filenamePrefix}immich-db-backup-${DateTime.now().toFormat("yyyyLLdd'T'HHmmss")}-v${serverVersion.toString()}-pg${databaseVersion.split(' ', 1)[0]}.sql.gz`;
const backupFilePath = path.join(StorageCore.getBaseFolder(StorageFolder.Backups), filename);
const temporaryFilePath = `${backupFilePath}.tmp`;
@ -249,6 +249,7 @@ export class DatabaseBackupService {
this.logger.error(`Database Backup Failure: ${error}`);
await this.storageRepository
.unlink(temporaryFilePath)
.catch((error) => this.logger.error(`Failed to delete failed backup file: ${error}`));
throw error;
}
@ -354,7 +355,7 @@ export class DatabaseBackupService {
): Promise<void> {
this.logger.debug(`Database Restore Started`);
let complete = false;
let isComplete = false;
try {
if (!isValidDatabaseBackupName(filename)) {
throw new Error('Invalid backup file format!');
@ -399,7 +400,7 @@ export class DatabaseBackupService {
});
const [progressSource, progressSink] = createSqlProgressStreams((progress) => {
if (complete) {
if (isComplete) {
return;
}
@ -407,7 +408,7 @@ export class DatabaseBackupService {
progressCb?.('restore', progress);
});
await pipeline(sqlStream, progressSource, psql, progressSink);
await pipeline(sqlStream, createSqlOwnerTransformStream(databaseUsername), progressSource, psql, progressSink);
try {
progressCb?.('migrations', 0.9);
@ -437,7 +438,7 @@ export class DatabaseBackupService {
});
const [progressSource, progressSink] = createSqlProgressStreams((progress) => {
if (complete) {
if (isComplete) {
return;
}
@ -453,7 +454,7 @@ export class DatabaseBackupService {
this.logger.error(`Database Restore Failure: ${error}`);
throw error;
} finally {
complete = true;
isComplete = true;
}
this.logger.log(`Database Restore Success`);
@ -507,7 +508,7 @@ function createSqlProgressStreams(cb: (progress: number) => void) {
const STDIN_START_MARKER = new TextEncoder().encode('FROM stdin');
const STDIN_END_MARKER = new TextEncoder().encode(String.raw`\.`);
let readingStdin = false;
let isReadingStdin = false;
let sequenceIdx = 0;
let linesSent = 0;
@ -532,19 +533,19 @@ function createSqlProgressStreams(cb: (progress: number) => void) {
const source = new PassThrough({
transform(chunk, _encoding, callback) {
for (const byte of chunk) {
if (!readingStdin && byte === 10 && lastByte !== 10) {
if (!isReadingStdin && byte === 10 && lastByte !== 10) {
linesSent += 1;
}
lastByte = byte;
const sequence = readingStdin ? STDIN_END_MARKER : STDIN_START_MARKER;
const sequence = isReadingStdin ? STDIN_END_MARKER : STDIN_START_MARKER;
if (sequence[sequenceIdx] === byte) {
sequenceIdx += 1;
if (sequence.length === sequenceIdx) {
sequenceIdx = 0;
readingStdin = !readingStdin;
isReadingStdin = !isReadingStdin;
}
} else {
sequenceIdx = 0;
@ -552,6 +553,7 @@ function createSqlProgressStreams(cb: (progress: number) => void) {
}
cbDebounced();
// eslint-disable-next-line unicorn/no-this-outside-of-class
this.push(chunk);
callback();
},
@ -572,3 +574,70 @@ function createSqlProgressStreams(cb: (progress: number) => void) {
return [source, sink];
}
function createSqlOwnerTransformStream(databaseUsername: string) {
const OWNER_MARKER_START = new TextEncoder().encode('OWNER TO ');
const DATA_MARKER_START = new TextEncoder().encode('FROM stdin');
const LINE_END = new TextEncoder().encode(';');
const owner = new TextEncoder().encode(databaseUsername);
let ownerSequenceIndex = 0;
let replacingOwnerIndex = 0;
let replacingOwner = false;
let readingDataIndex = 0;
let dataPart = false;
return new PassThrough({
transform(chunk, _encoding, callback) {
let result = chunk;
if (!dataPart) {
for (let index = 0; index < result.length; index++) {
if (replacingOwner) {
if (result[index] === LINE_END[0]) {
result = Buffer.concat([result.slice(0, index), owner.slice(replacingOwnerIndex), result.slice(index)]);
replacingOwnerIndex = owner.length;
} else {
result[index] = owner[replacingOwnerIndex];
replacingOwnerIndex++;
}
}
if (replacingOwnerIndex === owner.length) {
replacingOwner = false;
}
if (result[index] === OWNER_MARKER_START[ownerSequenceIndex]) {
ownerSequenceIndex++;
} else {
ownerSequenceIndex = 0;
}
if (ownerSequenceIndex === OWNER_MARKER_START.length) {
ownerSequenceIndex = 0;
replacingOwner = true;
replacingOwnerIndex = 0;
}
if (result[index] === DATA_MARKER_START[readingDataIndex]) {
readingDataIndex++;
} else {
readingDataIndex = 0;
}
if (readingDataIndex === DATA_MARKER_START.length) {
dataPart = true;
break;
}
}
}
// eslint-disable-next-line unicorn/no-this-outside-of-class
this.push(result);
callback();
},
});
}

View file

@ -220,15 +220,15 @@ export class DuplicateService extends BaseService {
if (idsToTrash.length > 0) {
// TODO: this is duplicated with AssetService.deleteAssets
const { trash } = await this.getConfig({ withCache: true });
const force = !trash.enabled;
const isForce = !trash.enabled;
await this.assetRepository.updateAll(idsToTrash, {
deletedAt: new Date(),
status: force ? AssetStatus.Deleted : AssetStatus.Trashed,
status: isForce ? AssetStatus.Deleted : AssetStatus.Trashed,
duplicateId: null,
});
await this.eventRepository.emit(force ? 'AssetDeleteAll' : 'AssetTrashAll', {
await this.eventRepository.emit(isForce ? 'AssetDeleteAll' : 'AssetTrashAll', {
assetIds: idsToTrash,
userId: auth.user.id,
});

View file

@ -1,5 +1,5 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { TranscodeHardwareAcceleration } from 'src/enum';
import { HlsVideoResolution, VideoCodec } from 'src/enum';
import { HlsService } from 'src/services/hls.service';
import { eiffelTower, train, waterfall } from 'test/fixtures/media.stub';
import { factory } from 'test/small.factory';
@ -96,67 +96,79 @@ seg_10.m4s
const sessionId = '00000000-0000-0000-0000-000000000000';
const eiffelExpectedMasterDisabled = `#EXTM3U
const eiffelExpectedMasterAv1 = `#EXTM3U
#EXT-X-VERSION:7
#EXT-X-INDEPENDENT-SEGMENTS
#EXT-X-STREAM-INF:BANDWIDTH=1000000,RESOLUTION=480x852,CODECS="av01.0.04M.08,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
#EXT-X-STREAM-INF:BANDWIDTH=1350000,RESOLUTION=480x852,CODECS="av01.0.04M.08,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
${sessionId}/0/playlist.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=1200000,RESOLUTION=480x852,CODECS="hvc1.1.6.L90.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
#EXT-X-STREAM-INF:BANDWIDTH=1620000,RESOLUTION=480x852,CODECS="hvc1.1.6.L90.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
${sessionId}/1/playlist.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=2500000,RESOLUTION=480x852,CODECS="avc1.64001e,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
#EXT-X-STREAM-INF:BANDWIDTH=3375000,RESOLUTION=480x852,CODECS="avc1.64001e,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
${sessionId}/2/playlist.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=2000000,RESOLUTION=720x1280,CODECS="av01.0.08M.08,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
#EXT-X-STREAM-INF:BANDWIDTH=2700000,RESOLUTION=720x1280,CODECS="av01.0.05M.08,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
${sessionId}/3/playlist.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=2500000,RESOLUTION=720x1280,CODECS="hvc1.1.6.L93.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
#EXT-X-STREAM-INF:BANDWIDTH=3375000,RESOLUTION=720x1280,CODECS="hvc1.1.6.L93.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
${sessionId}/4/playlist.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=5000000,RESOLUTION=720x1280,CODECS="avc1.64001f,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
#EXT-X-STREAM-INF:BANDWIDTH=6750000,RESOLUTION=720x1280,CODECS="avc1.64001f,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
${sessionId}/5/playlist.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=4000000,RESOLUTION=1080x1920,CODECS="av01.0.09M.08,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
#EXT-X-STREAM-INF:BANDWIDTH=5400000,RESOLUTION=1080x1920,CODECS="av01.0.08M.08,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
${sessionId}/6/playlist.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=4500000,RESOLUTION=1080x1920,CODECS="hvc1.1.6.L120.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
#EXT-X-STREAM-INF:BANDWIDTH=6075000,RESOLUTION=1080x1920,CODECS="hvc1.1.6.L120.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
${sessionId}/7/playlist.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=8000000,RESOLUTION=1080x1920,CODECS="avc1.640028,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
#EXT-X-STREAM-INF:BANDWIDTH=10800000,RESOLUTION=1080x1920,CODECS="avc1.640028,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
${sessionId}/8/playlist.m3u8
`;
const eiffelExpectedMasterRkmpp = `#EXTM3U
const eiffelExpectedMasterNoAv1 = `#EXTM3U
#EXT-X-VERSION:7
#EXT-X-INDEPENDENT-SEGMENTS
#EXT-X-STREAM-INF:BANDWIDTH=1200000,RESOLUTION=480x852,CODECS="hvc1.1.6.L90.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
#EXT-X-STREAM-INF:BANDWIDTH=1620000,RESOLUTION=480x852,CODECS="hvc1.1.6.L90.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
${sessionId}/1/playlist.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=2500000,RESOLUTION=480x852,CODECS="avc1.64001e,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
#EXT-X-STREAM-INF:BANDWIDTH=3375000,RESOLUTION=480x852,CODECS="avc1.64001e,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
${sessionId}/2/playlist.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=2500000,RESOLUTION=720x1280,CODECS="hvc1.1.6.L93.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
#EXT-X-STREAM-INF:BANDWIDTH=3375000,RESOLUTION=720x1280,CODECS="hvc1.1.6.L93.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
${sessionId}/4/playlist.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=5000000,RESOLUTION=720x1280,CODECS="avc1.64001f,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
#EXT-X-STREAM-INF:BANDWIDTH=6750000,RESOLUTION=720x1280,CODECS="avc1.64001f,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
${sessionId}/5/playlist.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=4500000,RESOLUTION=1080x1920,CODECS="hvc1.1.6.L120.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
#EXT-X-STREAM-INF:BANDWIDTH=6075000,RESOLUTION=1080x1920,CODECS="hvc1.1.6.L120.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
${sessionId}/7/playlist.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=8000000,RESOLUTION=1080x1920,CODECS="avc1.640028,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
#EXT-X-STREAM-INF:BANDWIDTH=10800000,RESOLUTION=1080x1920,CODECS="avc1.640028,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
${sessionId}/8/playlist.m3u8
`;
const waterfallExpectedMasterDisabled = `#EXTM3U
const waterfallExpectedMasterAv1 = `#EXTM3U
#EXT-X-VERSION:7
#EXT-X-INDEPENDENT-SEGMENTS
#EXT-X-STREAM-INF:BANDWIDTH=1000000,RESOLUTION=480x852,CODECS="av01.0.04M.08,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
#EXT-X-STREAM-INF:BANDWIDTH=1350000,RESOLUTION=480x852,CODECS="av01.0.04M.08,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
${sessionId}/0/playlist.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=1200000,RESOLUTION=480x852,CODECS="hvc1.1.6.L90.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
#EXT-X-STREAM-INF:BANDWIDTH=1620000,RESOLUTION=480x852,CODECS="hvc1.1.6.L90.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
${sessionId}/1/playlist.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=2500000,RESOLUTION=480x852,CODECS="avc1.64001e,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
#EXT-X-STREAM-INF:BANDWIDTH=3375000,RESOLUTION=480x852,CODECS="avc1.64001f,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
${sessionId}/2/playlist.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=2000000,RESOLUTION=720x1280,CODECS="av01.0.08M.08,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
#EXT-X-STREAM-INF:BANDWIDTH=2700000,RESOLUTION=720x1280,CODECS="av01.0.05M.08,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
${sessionId}/3/playlist.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=2500000,RESOLUTION=720x1280,CODECS="hvc1.1.6.L93.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
#EXT-X-STREAM-INF:BANDWIDTH=3375000,RESOLUTION=720x1280,CODECS="hvc1.1.6.L93.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
${sessionId}/4/playlist.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=5000000,RESOLUTION=720x1280,CODECS="avc1.64001f,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
#EXT-X-STREAM-INF:BANDWIDTH=6750000,RESOLUTION=720x1280,CODECS="avc1.64001f,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
${sessionId}/5/playlist.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=4000000,RESOLUTION=1080x1920,CODECS="av01.0.09M.08,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
#EXT-X-STREAM-INF:BANDWIDTH=5400000,RESOLUTION=1080x1920,CODECS="av01.0.08M.08,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
${sessionId}/6/playlist.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=4500000,RESOLUTION=1080x1920,CODECS="hvc1.1.6.L120.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
#EXT-X-STREAM-INF:BANDWIDTH=6075000,RESOLUTION=1080x1920,CODECS="hvc1.1.6.L120.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
${sessionId}/7/playlist.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=8000000,RESOLUTION=1080x1920,CODECS="avc1.640028,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
#EXT-X-STREAM-INF:BANDWIDTH=10800000,RESOLUTION=1080x1920,CODECS="avc1.640028,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
${sessionId}/8/playlist.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=9450000,RESOLUTION=1440x2560,CODECS="av01.0.12M.08,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
${sessionId}/9/playlist.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=10800000,RESOLUTION=1440x2560,CODECS="hvc1.1.6.L150.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
${sessionId}/10/playlist.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=18900000,RESOLUTION=1440x2560,CODECS="avc1.640032,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
${sessionId}/11/playlist.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=16200000,RESOLUTION=2160x3840,CODECS="av01.0.12M.08,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
${sessionId}/12/playlist.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=18900000,RESOLUTION=2160x3840,CODECS="hvc1.1.6.L150.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
${sessionId}/13/playlist.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=33750000,RESOLUTION=2160x3840,CODECS="avc1.640033,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
${sessionId}/14/playlist.m3u8
`;
describe(HlsService.name, () => {
@ -171,32 +183,49 @@ describe(HlsService.name, () => {
const auth = factory.auth();
const assetId = 'asset-1';
const setup = (asset: typeof eiffelTower | typeof waterfall, accel: TranscodeHardwareAcceleration) => {
const allCodecs = [VideoCodec.Av1, VideoCodec.Hevc, VideoCodec.H264];
const allResolutions = [
HlsVideoResolution.p480,
HlsVideoResolution.p720,
HlsVideoResolution.p1080,
HlsVideoResolution.p1440,
HlsVideoResolution.p2160,
];
const setup = (
asset: typeof eiffelTower | typeof waterfall,
videoCodecs?: VideoCodec[],
resolutions?: HlsVideoResolution[],
) => {
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetId]));
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { realtime: { enabled: true }, accel } });
mocks.systemMetadata.get.mockResolvedValue({
ffmpeg: { realtime: { enabled: true, videoCodecs, resolutions } },
});
mocks.videoStream.getForMainPlaylist.mockResolvedValue(asset);
mocks.crypto.randomUUID.mockReturnValue(sessionId);
mocks.websocket.serverSend.mockImplementation((event, ...rest) => {
if (event === 'HlsSessionRequest') {
const { sessionId: id } = rest[0] as { sessionId: string };
queueMicrotask(() => sut.onSessionResult({ sessionId: id }));
if (event !== 'HlsSessionRequest') {
return;
}
const { sessionId: id } = rest[0] as { sessionId: string };
queueMicrotask(() => sut.onSessionResult({ sessionId: id }));
});
};
it('returns main playlist for eiffel-tower (1080p portrait, no acceleration)', async () => {
setup(eiffelTower, TranscodeHardwareAcceleration.Disabled);
await expect(sut.getMainPlaylist(auth, assetId)).resolves.toBe(eiffelExpectedMasterDisabled);
it('offers AV1, HEVC, and H.264 when AV1 is configured and the accelerator supports it', async () => {
setup(eiffelTower, allCodecs);
await expect(sut.getMainPlaylist(auth, assetId)).resolves.toBe(eiffelExpectedMasterAv1);
});
it('returns main playlist for eiffel-tower with RKMPP (no AV1 variants)', async () => {
setup(eiffelTower, TranscodeHardwareAcceleration.Rkmpp);
await expect(sut.getMainPlaylist(auth, assetId)).resolves.toBe(eiffelExpectedMasterRkmpp);
it('omits AV1 when it is not in the configured codecs', async () => {
setup(eiffelTower);
await expect(sut.getMainPlaylist(auth, assetId)).resolves.toBe(eiffelExpectedMasterNoAv1);
});
it('returns main playlist for waterfall (4K landscape) with no acceleration', async () => {
setup(waterfall, TranscodeHardwareAcceleration.Disabled);
await expect(sut.getMainPlaylist(auth, assetId)).resolves.toBe(waterfallExpectedMasterDisabled);
it('offers every resolution up to the source and derives 4K codec levels (waterfall, 4K, 29.83fps)', async () => {
setup(waterfall, allCodecs, allResolutions);
await expect(sut.getMainPlaylist(auth, assetId)).resolves.toBe(waterfallExpectedMasterAv1);
});
it('throws BadRequestException when realtime transcoding is disabled', async () => {

View file

@ -1,13 +1,7 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { constants } from 'node:fs';
import { join } from 'node:path';
import {
HLS_SEGMENT_DURATION,
HLS_SEGMENT_FILENAME_REGEX,
HLS_VARIANTS,
HLS_VERSION,
SUPPORTED_HWA_CODECS,
} from 'src/constants';
import { HLS_SEGMENT_DURATION, HLS_SEGMENT_FILENAME_REGEX, HLS_VARIANTS, HLS_VERSION } from 'src/constants';
import { StorageCore } from 'src/cores/storage.core';
import { OnEvent } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto';
@ -18,7 +12,7 @@ import { BaseService } from 'src/services/base.service';
import { VideoPacketInfo, VideoStreamInfo } from 'src/types';
import { PendingEvents } from 'src/utils/event';
import { ImmichFileResponse } from 'src/utils/file';
import { getOutputSize } from 'src/utils/media';
import { getCodecString, getOutputSize } from 'src/utils/media';
type AssetWithStreamInfo = { videoStream: VideoStreamInfo & { timeBase: number }; packets: VideoPacketInfo };
type Segmentation = { fps: number; framesPerSegment: number; segmentCount: number; segmentDuration: number };
@ -131,18 +125,21 @@ export class HlsService extends BaseService {
}
private generateMainPlaylist(sessionId: string, ffmpeg: SystemConfigFFmpegDto, asset: AssetWithStreamInfo) {
const fps = ((asset.packets.packetCount * asset.videoStream.timeBase) / asset.packets.totalDuration).toFixed(3);
const fps = (asset.packets.packetCount * asset.videoStream.timeBase) / asset.packets.totalDuration;
const roundedFps = fps.toFixed(3);
const sourceResolution = Math.min(asset.videoStream.height, asset.videoStream.width);
const targetResolution = Math.max(sourceResolution, HLS_VARIANTS[0].resolution);
const lines = ['#EXTM3U', `#EXT-X-VERSION:${HLS_VERSION}`, '#EXT-X-INDEPENDENT-SEGMENTS'];
const { videoCodecs, resolutions } = ffmpeg.realtime;
for (let i = 0; i < HLS_VARIANTS.length; i++) {
const { resolution, bitrate, codec, codecString } = HLS_VARIANTS[i];
if (resolution > targetResolution || !SUPPORTED_HWA_CODECS[ffmpeg.accel].includes(codec)) {
const { resolution, bitrate, codec } = HLS_VARIANTS[i];
if (resolution > targetResolution || !videoCodecs.includes(codec) || !resolutions.includes(resolution)) {
continue;
}
const { width, height } = getOutputSize(asset.videoStream, resolution);
const codecString = getCodecString(codec, width, height, fps);
lines.push(
`#EXT-X-STREAM-INF:BANDWIDTH=${bitrate},RESOLUTION=${width}x${height},CODECS="${codecString},mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=${fps}`,
`#EXT-X-STREAM-INF:BANDWIDTH=${Math.round(bitrate * 1.35)},RESOLUTION=${width}x${height},CODECS="${codecString},mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=${roundedFps}`,
`${sessionId}/${i}/playlist.m3u8`,
);
}

View file

@ -312,12 +312,14 @@ export class IntegrityService extends BaseService {
this.logger.log(`Processing batch of ${items.length} reports to check if they are out of date.`);
const results = await Promise.all(
items.map(({ reportId, path }) =>
this.storageRepository
.stat(path)
.then(() => void 0)
.catch(() => reportId),
),
items.map(async ({ reportId, path }) => {
try {
await this.storageRepository.stat(path);
return;
} catch {
return reportId;
}
}),
);
const reportIds = results.filter(Boolean) as string[];
@ -360,7 +362,7 @@ export class IntegrityService extends BaseService {
this.logger.log(`Scanning for missing files...`);
const assetPaths = this.integrityRepository.streamAssetPaths();
const assetPaths = this.integrityRepository.streamAssetPathsForMissingFiles();
let total = 0;
for await (const batchPaths of chunk(assetPaths, JOBS_LIBRARY_PAGINATION_SIZE)) {
@ -383,12 +385,14 @@ export class IntegrityService extends BaseService {
this.logger.log(`Processing batch of ${items.length} files to check if they are missing.`);
const results = await Promise.all(
items.map((item) =>
this.storageRepository
.stat(item.path)
.then(() => ({ ...item, exists: true }))
.catch(() => ({ ...item, exists: false })),
),
items.map(async (item) => {
try {
await this.storageRepository.stat(item.path);
return { ...item, exists: true };
} catch {
return { ...item, exists: false };
}
}),
);
const outdatedReports = results
@ -420,12 +424,14 @@ export class IntegrityService extends BaseService {
this.logger.log(`Processing batch of ${paths.length} reports to check if they are out of date.`);
const results = await Promise.all(
paths.map(({ reportId, path }) =>
this.storageRepository
.stat(path)
.then(() => reportId)
.catch(() => void 0),
),
paths.map(async ({ reportId, path }) => {
try {
await this.storageRepository.stat(path);
return reportId;
} catch {
return;
}
}),
);
const reportIds = results.filter(Boolean) as string[];
@ -532,8 +538,7 @@ export class IntegrityService extends BaseService {
const { count } = await this.integrityRepository.getAssetCount();
const checkpoint = await this.systemMetadataRepository.get(SystemMetadataKey.IntegrityChecksumCheckpoint);
let startMarker: Date | undefined = checkpoint?.date ? new Date(checkpoint.date) : undefined;
let endMarker: Date | undefined;
const startMarker = checkpoint?.date ? new Date(checkpoint.date) : undefined;
const printStats = () => {
const averageTime = ((Date.now() - startedAt) / processed).toFixed(2);
@ -546,31 +551,25 @@ export class IntegrityService extends BaseService {
let lastCreatedAt: Date | undefined;
finishEarly: do {
this.logger.log(
`Processing assets in range [${startMarker?.toISOString() ?? 'beginning'}, ${endMarker?.toISOString() ?? 'end'}]`,
);
this.logger.log(`Processing assets from ${startMarker?.toISOString() ?? 'beginning'}`);
const assets = this.integrityRepository.streamAssetChecksums(startMarker, endMarker);
endMarker = startMarker;
startMarker = undefined;
const assets = this.integrityRepository.streamAssetChecksums(startMarker);
for await (const { originalPath, checksum, createdAt, assetId, reportId } of assets) {
await this.checkAssetChecksum(originalPath, checksum, assetId, reportId);
for await (const { originalPath, checksum, createdAt, assetId, reportId } of assets) {
await this.checkAssetChecksum(originalPath, checksum, assetId, reportId);
processed++;
processed++;
if (processed % 100 === 0) {
printStats();
}
if (Date.now() > startedAt + timeLimit || processed > count * percentageLimit) {
this.logger.log('Reached stop criteria.');
lastCreatedAt = createdAt;
break finishEarly;
}
if (processed % 100 === 0) {
printStats();
}
} while (endMarker);
if (Date.now() > startedAt + timeLimit || processed > count * percentageLimit) {
this.logger.log('Reached stop criteria.');
lastCreatedAt = createdAt;
break;
}
}
await this.systemMetadataRepository.set(SystemMetadataKey.IntegrityChecksumCheckpoint, {
date: lastCreatedAt?.toISOString(),

View file

@ -15,6 +15,7 @@ import { makeStream, newTestService, ServiceMocks } from 'test/utils';
import { vitest } from 'vitest';
async function* mockWalk() {
// eslint-disable-next-line unicorn/no-useless-promise-resolve-reject
yield await Promise.resolve(['/data/user1/photo.jpg']);
}
@ -576,6 +577,10 @@ describe(LibraryService.name, () => {
}),
]);
expect(mocks.event.emit).toHaveBeenCalledWith('AssetCreate', {
asset: { id: asset.id, ownerId: library.ownerId },
});
expect(mocks.job.queueAll).toHaveBeenCalledWith([
{
name: JobName.SidecarCheck,

View file

@ -162,10 +162,12 @@ export class LibraryService extends BaseService {
}
async unwatch(id: string) {
if (this.watchers[id]) {
await this.watchers[id]();
delete this.watchers[id];
if (!Object.hasOwn(this.watchers, id)) {
return;
}
await this.watchers[id]();
delete this.watchers[id];
}
@OnEvent({ name: 'AppShutdown' })
@ -254,18 +256,22 @@ export class LibraryService extends BaseService {
if (!library) {
this.logger.debug(`Library ${job.libraryId} not found, skipping file import`);
return JobStatus.Failed;
} else if (library.deletedAt) {
}
if (library.deletedAt) {
this.logger.debug(`Library ${job.libraryId} is deleted, won't import assets into it`);
return JobStatus.Failed;
}
const assetImports: Insertable<AssetTable>[] = [];
await Promise.all(
job.paths.map((path) =>
this.processEntity(path, library.ownerId, job.libraryId)
.then((asset) => assetImports.push(asset))
.catch((error: any) => this.logger.error(`Error processing ${path} for library ${job.libraryId}: ${error}`)),
),
job.paths.map(async (path) => {
try {
const asset = await this.processEntity(path, library.ownerId, job.libraryId);
assetImports.push(asset);
} catch (error) {
this.logger.error(`Error processing ${path} for library ${job.libraryId}: ${error}`);
}
}),
);
const assetIds = await this.assetRepository.createAll(assetImports);
@ -277,6 +283,12 @@ export class LibraryService extends BaseService {
this.logger.log(`Imported ${assetIds.length} ${progressMessage} file(s) into library ${job.libraryId}`);
await Promise.all(
assetIds.map((assetId) =>
this.eventRepository.emit('AssetCreate', { asset: { id: assetId, ownerId: library.ownerId } }),
),
);
await this.queuePostSyncJobs(assetIds);
return JobStatus.Success;
@ -312,9 +324,9 @@ export class LibraryService extends BaseService {
return validation;
}
const access = await this.storageRepository.checkFileExists(importPath, R_OK);
const isAccess = await this.storageRepository.checkFileExists(importPath, R_OK);
if (!access) {
if (!isAccess) {
validation.message = 'Lacking read permission for folder';
return validation;
}
@ -368,18 +380,20 @@ export class LibraryService extends BaseService {
await this.assetRepository.updateByLibraryId(libraryId, { deletedAt: new Date() });
let assetsFound = false;
let isAssetsFound = false;
let chunk: string[] = [];
const queueChunk = async () => {
if (chunk.length > 0) {
assetsFound = true;
this.logger.debug(`Queueing deletion of ${chunk.length} asset(s) in library ${libraryId}`);
await this.jobRepository.queueAll(
chunk.map((id) => ({ name: JobName.AssetDelete, data: { id, deleteOnDisk: false } })),
);
chunk = [];
if (chunk.length === 0) {
return;
}
isAssetsFound = true;
this.logger.debug(`Queueing deletion of ${chunk.length} asset(s) in library ${libraryId}`);
await this.jobRepository.queueAll(
chunk.map((id) => ({ name: JobName.AssetDelete, data: { id, deleteOnDisk: false } })),
);
chunk = [];
};
this.logger.debug(`Will delete all assets in library ${libraryId}`);
@ -394,7 +408,7 @@ export class LibraryService extends BaseService {
await queueChunk();
if (!assetsFound) {
if (!isAssetsFound) {
this.logger.log(`Deleting library ${libraryId}`);
await this.libraryRepository.delete(libraryId);
}
@ -519,7 +533,7 @@ export class LibraryService extends BaseService {
break;
}
case AssetSyncResult.CHECK_OFFLINE: {
const isInImportPath = job.importPaths.find((path) => asset.originalPath.startsWith(path));
const isInImportPath = job.importPaths.some((path) => asset.originalPath.startsWith(path));
if (!isInImportPath) {
this.logger.verbose(
@ -741,28 +755,30 @@ export class LibraryService extends BaseService {
let count = 0;
const queueChunk = async () => {
if (chunk.length > 0) {
count += chunk.length;
await this.jobRepository.queue({
name: JobName.LibrarySyncAssets,
data: {
libraryId: library.id,
importPaths: library.importPaths,
exclusionPatterns: library.exclusionPatterns,
assetIds: chunk.map((id) => id),
progressCounter: count,
totalAssets: assetCount,
},
});
chunk = [];
const completePercentage = ((100 * count) / assetCount).toFixed(1);
this.logger.log(
`Queued check of ${count} of ${assetCount} (${completePercentage} %) existing asset(s) so far in library ${library.id}`,
);
if (chunk.length === 0) {
return;
}
count += chunk.length;
await this.jobRepository.queue({
name: JobName.LibrarySyncAssets,
data: {
libraryId: library.id,
importPaths: library.importPaths,
exclusionPatterns: library.exclusionPatterns,
assetIds: chunk.map((id) => id),
progressCounter: count,
totalAssets: assetCount,
},
});
chunk = [];
const completePercentage = ((100 * count) / assetCount).toFixed(1);
this.logger.log(
`Queued check of ${count} of ${assetCount} (${completePercentage} %) existing asset(s) so far in library ${library.id}`,
);
};
this.logger.log(`Scanning library ${library.id} for assets missing from disk...`);

View file

@ -26,6 +26,7 @@ export class MaintenanceService extends BaseService {
getMaintenanceMode(): Promise<MaintenanceModeState> {
return this.systemMetadataRepository
.get(SystemMetadataKey.MaintenanceMode)
.then((state) => state ?? { isMaintenanceMode: false });
}

View file

@ -2232,6 +2232,26 @@ describe(MediaService.name, () => {
);
});
it('should include hevc tag when target is hevc and using hwa', async () => {
mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.videoStreamHDR10 });
mocks.systemMetadata.get.mockResolvedValue({
ffmpeg: {
targetVideoCodec: VideoCodec.Hevc,
accel: TranscodeHardwareAcceleration.Nvenc,
},
});
await sut.handleVideoConversion({ id: 'video-id' });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
expect.any(String),
expect.objectContaining({
inputOptions: expect.any(Array),
outputOptions: expect.arrayContaining(['-c:v', 'hevc_nvenc', '-tag:v', 'hvc1']),
twoPass: false,
}),
);
});
it('should copy audio stream when audio matches target', async () => {
mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.audioStreamAac });
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.Optimal } });

View file

@ -76,8 +76,11 @@ export class MediaService extends BaseService {
jobs = [];
};
const fullsizeEnabled = config.image.fullsize.enabled;
for await (const asset of this.assetJobRepository.streamForThumbnailJob({ force, fullsizeEnabled })) {
const isFullsizeEnabled = config.image.fullsize.enabled;
for await (const asset of this.assetJobRepository.streamForThumbnailJob({
force,
fullsizeEnabled: isFullsizeEnabled,
})) {
if (force || !asset.isEdited) {
jobs.push({ name: JobName.AssetGenerateThumbnails, data: { id: asset.id } });
}
@ -272,13 +275,14 @@ export class MediaService extends BaseService {
}
private async extractOriginalImage(asset: ThumbnailAsset, image: SystemConfig['image'], useEdits = false) {
const extractEmbedded = image.extractEmbedded && mimeTypes.isRaw(asset.originalFileName);
const extracted = extractEmbedded ? await this.extractImage(asset.originalPath, image.preview.size) : null;
const generateFullsize =
const isExtractEmbedded = image.extractEmbedded && mimeTypes.isRaw(asset.originalFileName);
const extracted = isExtractEmbedded ? await this.extractImage(asset.originalPath, image.preview.size) : null;
const isGenerateFullsize =
((image.fullsize.enabled || asset.exifInfo.projectionType === 'EQUIRECTANGULAR') &&
!mimeTypes.isWebSupportedImage(asset.originalPath)) ||
useEdits;
const convertFullsize = generateFullsize && (!extracted || !mimeTypes.isWebSupportedImage(` .${extracted.format}`));
const isConvertFullsize =
isGenerateFullsize && (!extracted || !mimeTypes.isWebSupportedImage(` .${extracted.format}`));
const thumbSource = extracted ? extracted.buffer : asset.originalPath;
const { data, info, colorspace } = await this.decodeImage(
@ -286,7 +290,7 @@ export class MediaService extends BaseService {
// only specify orientation to extracted images which don't have EXIF orientation data
// or it can double rotate the image
extracted ? asset.exifInfo : { ...asset.exifInfo, orientation: null },
convertFullsize ? undefined : image.preview.size,
isConvertFullsize ? undefined : image.preview.size,
);
let isTransparent = false;
@ -299,8 +303,8 @@ export class MediaService extends BaseService {
data,
info,
colorspace,
convertFullsize,
generateFullsize,
convertFullsize: isConvertFullsize,
generateFullsize: isGenerateFullsize,
isTransparent,
};
}
@ -620,20 +624,20 @@ export class MediaService extends BaseService {
return JobStatus.Failed;
}
let partialFallbackSuccess = false;
let isPartialFallbackSuccess = false;
if (ffmpeg.accelDecode) {
try {
this.logger.error(`Retrying with ${ffmpeg.accel.toUpperCase()}-accelerated encoding and software decoding`);
ffmpeg = { ...ffmpeg, accelDecode: false };
const command = BaseConfig.create(ffmpeg, this.videoInterfaces).getCommand(target, videoStream, audioStream);
await this.mediaRepository.transcode(input, output, command);
partialFallbackSuccess = true;
isPartialFallbackSuccess = true;
} catch (error: any) {
this.logger.error(`Error occurred during transcoding: ${error.message}`);
}
}
if (!partialFallbackSuccess) {
if (!isPartialFallbackSuccess) {
this.logger.error(`Retrying with ${ffmpeg.accel.toUpperCase()} acceleration disabled`);
ffmpeg = { ...ffmpeg, accel: TranscodeHardwareAcceleration.Disabled };
const command = BaseConfig.create(ffmpeg, this.videoInterfaces).getCommand(target, videoStream, audioStream);
@ -700,9 +704,9 @@ export class MediaService extends BaseService {
}
private isVideoTranscodeRequired(ffmpegConfig: SystemConfigFFmpegDto, stream: VideoStreamInfo): boolean {
const scalingEnabled = ffmpegConfig.targetResolution !== 'original';
const isScalingEnabled = ffmpegConfig.targetResolution !== 'original';
const targetRes = Number.parseInt(ffmpegConfig.targetResolution);
const isLargerThanTargetRes = scalingEnabled && Math.min(stream.height, stream.width) > targetRes;
const isLargerThanTargetRes = isScalingEnabled && Math.min(stream.height, stream.width) > targetRes;
const maxBitrate = this.parseBitrateToBps(ffmpegConfig.maxBitrate);
const isLargerThanTargetBitrate = maxBitrate > 0 && stream.bitrate > maxBitrate;
@ -757,13 +761,13 @@ export class MediaService extends BaseService {
}): boolean {
if (colorspace || profileDescription) {
return [colorspace, profileDescription].some((s) => s?.toLowerCase().includes('srgb'));
} else if (bitsPerSample) {
}
if (bitsPerSample) {
// assume sRGB for 8-bit images with no color profile or colorspace metadata
return bitsPerSample === 8;
} else {
// assume sRGB for images with no relevant metadata
return true;
}
// assume sRGB for images with no relevant metadata
return true;
}
private parseBitrateToBps(bitrateString: string) {
@ -776,11 +780,11 @@ export class MediaService extends BaseService {
if (bitrateString.toLowerCase().endsWith('k')) {
return bitrateValue * 1000; // Kilobits per second to bits per second
} else if (bitrateString.toLowerCase().endsWith('m')) {
return bitrateValue * 1_000_000; // Megabits per second to bits per second
} else {
return bitrateValue;
}
if (bitrateString.toLowerCase().endsWith('m')) {
return bitrateValue * 1_000_000; // Megabits per second to bits per second
}
return bitrateValue;
}
private async shouldUseExtractedImage(extractedPathOrBuffer: string | Buffer, targetSize: number) {

View file

@ -136,7 +136,7 @@ export class MemoryService extends BaseService {
const repos = { access: this.accessRepository, bulk: this.memoryRepository };
const results = await addAssets(auth, repos, { parentId: id, assetIds: dto.ids });
const hasSuccess = results.find(({ success }) => success);
const hasSuccess = results.some(({ success }) => success);
if (hasSuccess) {
await this.memoryRepository.update(id, { updatedAt: new Date() });
}
@ -154,7 +154,7 @@ export class MemoryService extends BaseService {
canAlwaysRemove: Permission.MemoryDelete,
});
const hasSuccess = results.find(({ success }) => success);
const hasSuccess = results.some(({ success }) => success);
if (hasSuccess) {
await this.memoryRepository.update(id, { id, updatedAt: new Date() });
}

View file

@ -39,7 +39,10 @@ const forSidecarJob = (
};
};
const makeFaceTags = (face: Partial<{ Name: string }> = {}, orientation?: ImmichTags['Orientation']) => ({
const makeFaceTags = (
face: Partial<{ Name: string }> = {},
orientation?: ImmichTags['Orientation'],
): Partial<ImmichTags> => ({
Orientation: orientation,
RegionInfo: {
AppliedToDimensions: { W: 1000, H: 100, Unit: 'pixel' },
@ -1371,6 +1374,35 @@ describe(MetadataService.name, () => {
expect(mocks.person.updateAll).not.toHaveBeenCalled();
});
it('should handle string coordinates in face region bounding box calculation by limiting to 16 decimal places', async () => {
const asset = AssetFactory.create();
const person = PersonFactory.create();
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } });
const faceTags = makeFaceTags({ Name: person.name });
// Simulating EXIF returning a string with >16 decimal places
faceTags.RegionInfo!.RegionList[0].Area.X = '0.48564814814814824';
faceTags.RegionInfo!.RegionList[0].Area.W = '0.2';
mockReadTags(faceTags);
mocks.person.getDistinctNames.mockResolvedValue([]);
mocks.person.createAll.mockResolvedValue([person.id]);
mocks.person.update.mockResolvedValue(person);
await sut.handleMetadataExtraction({ id: asset.id });
expect(mocks.person.refreshFaces).toHaveBeenCalledWith(
[
expect.objectContaining({
boundingBoxX1: Math.floor((0.4856481481481482 - 0.2 / 2) * 1000),
}),
],
[],
);
});
it('should apply metadata face tags creating new people', async () => {
const asset = AssetFactory.create();
const person = PersonFactory.create();

View file

@ -117,9 +117,7 @@ const validateRange = (value: number | undefined, min: number, max: number): Non
};
const getLensModel = (exifTags: ImmichTags): string | null => {
const lensModel = String(
exifTags.LensID ?? exifTags.LensType ?? exifTags.LensSpec ?? exifTags.LensModel ?? '',
).trim();
const lensModel = (exifTags.LensID ?? exifTags.LensType ?? exifTags.LensSpec ?? exifTags.LensModel ?? '').trim();
if (lensModel === '----') {
return null;
}
@ -286,7 +284,7 @@ export class MetadataService extends BaseService {
exifImageHeight: validate(height),
exifImageWidth: validate(width),
orientation: validate(exifTags.Orientation)?.toString() ?? null,
projectionType: exifTags.ProjectionType ? String(exifTags.ProjectionType).toUpperCase() : null,
projectionType: exifTags.ProjectionType ? exifTags.ProjectionType.toUpperCase() : null,
bitsPerSample: this.getBitsPerSample(exifTags),
colorspace: exifTags.ColorSpace === undefined ? null : String(exifTags.ColorSpace),
@ -295,7 +293,7 @@ export class MetadataService extends BaseService {
exifTags.Make ?? exifTags.Device?.Manufacturer ?? exifTags.AndroidMake ?? (exifTags.DeviceManufacturer || null),
model:
exifTags.Model ?? exifTags.Device?.ModelName ?? exifTags.AndroidModel ?? (exifTags.DeviceModelName || null),
fps: video?.frameRate ?? validate(Number.parseFloat(exifTags.VideoFrameRate!)),
fps: video?.frameRate ?? validate(Number(exifTags.VideoFrameRate!)),
iso: validate(exifTags.ISO) as number,
exposureTime: exifTags.ExposureTime ?? null,
lensModel: getLensModel(exifTags),
@ -326,7 +324,7 @@ export class MetadataService extends BaseService {
: undefined;
const videoData =
format?.formatName && format?.formatLongName && video?.codecName && video?.timeBase
format?.formatName && format.formatLongName && video?.codecName && video?.timeBase
? {
assetId: asset.id,
bitrate: video.bitrate,
@ -362,8 +360,8 @@ export class MetadataService extends BaseService {
: undefined;
const isSidewards = exifTags.Orientation && this.isOrientationSidewards(exifTags.Orientation);
const assetWidth = isSidewards ? validate(height) : validate(width);
const assetHeight = isSidewards ? validate(width) : validate(height);
const assetWidth = validate(isSidewards ? height : width);
const assetHeight = validate(isSidewards ? width : height);
const tasks = new Tasks();
@ -445,8 +443,8 @@ export class MetadataService extends BaseService {
let sidecarPath = null;
for (const candidate of this.getSidecarCandidates(asset)) {
const exists = await this.storageRepository.checkFileExists(candidate, constants.R_OK);
if (!exists) {
const isExists = await this.storageRepository.checkFileExists(candidate, constants.R_OK);
if (!isExists) {
continue;
}
@ -806,8 +804,8 @@ export class MetadataService extends BaseService {
}
// write extracted motion video to disk, especially if the encoded-video folder has been deleted
const existsOnDisk = await this.storageRepository.checkFileExists(motionAsset.originalPath);
if (!existsOnDisk) {
const isExistsOnDisk = await this.storageRepository.checkFileExists(motionAsset.originalPath);
if (!isExistsOnDisk) {
this.storageCore.ensureFolders(motionAsset.originalPath);
await this.storageRepository.createFile(motionAsset.originalPath, video);
this.logger.log(`Wrote motion photo video to ${motionAsset.originalPath}`);
@ -854,6 +852,13 @@ export class MetadataService extends BaseService {
// update area coordinates and dimensions in RegionList assuming "normalized" unit as per MWG guidelines
const adjustedRegionList = regionInfo.RegionList.map((region) => {
let { X, Y, W, H } = region.Area;
// EXIF floats with >16 decimals are serialized as strings. Ensure they are numbers.
X = Number(X);
Y = Number(Y);
W = Number(W);
H = Number(H);
switch (orientation) {
case ExifOrientation.MirrorHorizontal: {
X = 1 - X;
@ -926,16 +931,21 @@ export class MetadataService extends BaseService {
const loweredName = region.Name.toLowerCase();
const personId = existingNameMap.get(loweredName) || this.cryptoRepository.randomUUID();
const X = Number(region.Area.X);
const Y = Number(region.Area.Y);
const W = Number(region.Area.W);
const H = Number(region.Area.H);
const face = {
id: this.cryptoRepository.randomUUID(),
personId,
assetId: asset.id,
imageWidth,
imageHeight,
boundingBoxX1: Math.floor((region.Area.X - region.Area.W / 2) * imageWidth),
boundingBoxY1: Math.floor((region.Area.Y - region.Area.H / 2) * imageHeight),
boundingBoxX2: Math.floor((region.Area.X + region.Area.W / 2) * imageWidth),
boundingBoxY2: Math.floor((region.Area.Y + region.Area.H / 2) * imageHeight),
boundingBoxX1: Math.floor((X - W / 2) * imageWidth),
boundingBoxY1: Math.floor((Y - H / 2) * imageHeight),
boundingBoxX2: Math.floor((X + W / 2) * imageWidth),
boundingBoxY2: Math.floor((Y + H / 2) * imageHeight),
sourceType: SourceType.Exif,
};
@ -1075,6 +1085,7 @@ export class MetadataService extends BaseService {
private getDuration(tags: ImmichTags): number | null {
const duration = tags.Duration;
// eslint-disable-next-line unicorn/prefer-number-coercion
const seconds = typeof duration === 'number' ? duration : Number.parseFloat(duration as string);
return Number.isFinite(seconds) ? Math.round(Duration.fromObject({ seconds }).toMillis()) : null;
}

View file

@ -2,7 +2,6 @@ import { defaults, SystemConfig } from 'src/config';
import { SystemConfigDto } from 'src/dtos/system-config.dto';
import { AssetFileType, JobName, JobStatus, UserMetadataKey } from 'src/enum';
import { NotificationService } from 'src/services/notification.service';
import { INotifyAlbumUpdateJob } from 'src/types';
import { AlbumFactory } from 'test/factories/album.factory';
import { AssetFileFactory } from 'test/factories/asset-file.factory';
import { AssetFactory } from 'test/factories/asset.factory';
@ -157,13 +156,21 @@ describe(NotificationService.name, () => {
});
describe('onAlbumUpdateEvent', () => {
it('should queue notify album update event', async () => {
await sut.onAlbumUpdate({ id: 'album', recipientId: '42' });
expect(mocks.job.queue).toHaveBeenCalledWith({
it('should send a websocket event to every user and queue notify jobs for recipients', async () => {
await sut.onAlbumUpdate({ id: 'album', userIds: ['1', '42'], recipientIds: ['42'] });
expect(mocks.websocket.clientSend).toHaveBeenCalledWith('on_album_update', '1', 'album');
expect(mocks.websocket.clientSend).toHaveBeenCalledWith('on_album_update', '42', 'album');
expect(mocks.job.queue).toHaveBeenCalledExactlyOnceWith({
name: JobName.NotifyAlbumUpdate,
data: { id: 'album', recipientId: '42', delay: 300_000 },
});
});
it('should not queue email jobs when there are no recipients', async () => {
await sut.onAlbumUpdate({ id: 'album', userIds: ['1'], recipientIds: [] });
expect(mocks.websocket.clientSend).toHaveBeenCalledWith('on_album_update', '1', 'album');
expect(mocks.job.queue).not.toHaveBeenCalled();
});
});
describe('onAlbumInviteEvent', () => {
@ -522,7 +529,7 @@ describe(NotificationService.name, () => {
});
it('should add new recipients for new images if job is already queued', async () => {
await sut.onAlbumUpdate({ id: '1', recipientId: '2' } as INotifyAlbumUpdateJob);
await sut.onAlbumUpdate({ id: '1', userIds: ['2'], recipientIds: ['2'] });
expect(mocks.job.removeJob).toHaveBeenCalledWith(JobName.NotifyAlbumUpdate, '1/2');
expect(mocks.job.queue).toHaveBeenCalledWith({
name: JobName.NotifyAlbumUpdate,

View file

@ -217,12 +217,18 @@ export class NotificationService extends BaseService {
}
@OnEvent({ name: 'AlbumUpdate' })
async onAlbumUpdate({ id, recipientId }: ArgOf<'AlbumUpdate'>) {
await this.jobRepository.removeJob(JobName.NotifyAlbumUpdate, `${id}/${recipientId}`);
await this.jobRepository.queue({
name: JobName.NotifyAlbumUpdate,
data: { id, recipientId, delay: NotificationService.albumUpdateEmailDelayMs },
});
async onAlbumUpdate({ id, userIds, recipientIds }: ArgOf<'AlbumUpdate'>) {
for (const userId of userIds) {
this.websocketRepository.clientSend('on_album_update', userId, id);
}
for (const recipientId of recipientIds) {
await this.jobRepository.removeJob(JobName.NotifyAlbumUpdate, `${id}/${recipientId}`);
await this.jobRepository.queue({
name: JobName.NotifyAlbumUpdate,
data: { id, recipientId, delay: NotificationService.albumUpdateEmailDelayMs },
});
}
}
@OnEvent({ name: 'AlbumInvite' })

View file

@ -120,7 +120,7 @@ export class PersonService extends BaseService {
await this.createNewFeaturePhoto([face.person.id]);
}
return await this.findOrFail(personId).then(mapPerson);
return mapPerson(await this.findOrFail(personId));
}
async getFacesById(auth: AuthDto, dto: FaceDto): Promise<AssetFaceResponseDto[]> {
@ -152,7 +152,7 @@ export class PersonService extends BaseService {
async getById(auth: AuthDto, id: string): Promise<PersonResponseDto> {
await this.requireAccess({ auth, permission: Permission.PersonRead, ids: [id] });
return this.findOrFail(id).then(mapPerson);
return mapPerson(await this.findOrFail(id));
}
async getStatistics(auth: AuthDto, id: string): Promise<PersonStatisticsResponseDto> {
@ -192,7 +192,7 @@ export class PersonService extends BaseService {
const { name, birthDate, isHidden, featureFaceAssetId: assetId, isFavorite, color } = dto;
// TODO: set by faceId directly
let faceId: string | undefined = undefined;
let faceId: string | undefined;
if (assetId) {
await this.requireAccess({ auth, permission: Permission.AssetRead, ids: [assetId] });
const face = await this.personRepository.getForFeatureFaceUpdate({ personId: id, assetId });
@ -655,14 +655,12 @@ export class PersonService extends BaseService {
topLeft = { x: topLeft.x * scaleFactor, y: topLeft.y * scaleFactor };
bottomRight = { x: bottomRight.x * scaleFactor, y: bottomRight.y * scaleFactor };
const {
points: [invertedTopLeft, invertedBottomRight],
} = transformPoints(
const [invertedTopLeft, invertedBottomRight] = transformPoints(
[topLeft, bottomRight],
edits,
{ width: asset.width, height: asset.height },
{ inverse: true },
);
).points;
// make sure topLeft is top-left and bottomRight is bottom-right
topLeft = {

View file

@ -93,10 +93,7 @@ export class QueueService extends BaseService {
private updateConcurrency(config: SystemConfig) {
this.logger.debug(`Updating queue concurrency settings`);
for (const queueName of Object.values(QueueName)) {
let concurrency = 1;
if (this.isConcurrentQueue(queueName)) {
concurrency = config.job[queueName].concurrency;
}
const concurrency = this.isConcurrentQueue(queueName) ? config.job[queueName].concurrency : 1;
this.logger.debug(`Setting ${queueName} concurrency to ${concurrency}`);
this.jobRepository.setConcurrency(queueName, concurrency);
}
@ -161,9 +158,7 @@ export class QueueService extends BaseService {
throw new BadRequestException(`The BackgroundTask queue cannot be paused`);
}
await this.jobRepository.pause(name);
}
if (dto.isPaused === false) {
} else if (dto.isPaused === false) {
await this.jobRepository.resume(name);
}

View file

@ -250,7 +250,7 @@ describe(SearchService.name, () => {
);
expect(mocks.search.searchSmart).toHaveBeenCalledWith(
{ page: 1, size: 100 },
{ query: 'test', embedding: '[1, 2, 3]', userIds: [authStub.user1.user.id] },
{ query: 'test', embedding: '[1, 2, 3]', userIds: [authStub.user1.user.id], visibility: 'not-locked' },
);
});

View file

@ -73,14 +73,24 @@ export class SearchService extends BaseService {
checksum = Buffer.from(dto.checksum, encoding);
}
let userIds: string[] | undefined;
if (dto.albumIds && dto.albumIds.length > 0) {
await this.requireAccess({ auth, ids: dto.albumIds, permission: Permission.AlbumRead });
} else if (auth.sharedLink) {
throw new BadRequestException('Shared link access is only allowed in combination with an albumIds filter');
} else {
userIds = await this.getUserIdsToSearch(auth, dto.visibility);
}
const page = dto.page ?? 1;
const size = dto.size || 250;
const userIds = await this.getUserIdsToSearch(auth, dto.visibility);
const { hasNextPage, items } = await this.searchRepository.searchMetadata(
{ page, size },
{
...dto,
checksum,
visibility: dto.visibility ?? (auth.session?.hasElevatedPermission ? undefined : 'not-locked'),
userIds,
orderDirection: dto.order ?? AssetOrder.Desc,
},
@ -90,10 +100,14 @@ export class SearchService extends BaseService {
}
async searchStatistics(auth: AuthDto, dto: StatisticsSearchDto): Promise<SearchStatisticsResponseDto> {
const userIds = await this.getUserIdsToSearch(auth);
const userIds = await this.getUserIdsToSearch(auth, dto.visibility);
if (dto.visibility === AssetVisibility.Locked) {
requireElevatedPermission(auth);
}
return await this.searchRepository.searchStatistics({
...dto,
visibility: dto.visibility ?? (auth.session?.hasElevatedPermission ? undefined : 'not-locked'),
userIds,
});
}
@ -104,7 +118,11 @@ export class SearchService extends BaseService {
}
const userIds = await this.getUserIdsToSearch(auth, dto.visibility);
const items = await this.searchRepository.searchRandom(dto.size || 250, { ...dto, userIds });
const items = await this.searchRepository.searchRandom(dto.size || 250, {
...dto,
visibility: dto.visibility ?? (auth.session?.hasElevatedPermission ? undefined : 'not-locked'),
userIds,
});
return items.map((item) => mapAsset(item, { auth }));
}
@ -114,7 +132,11 @@ export class SearchService extends BaseService {
}
const userIds = await this.getUserIdsToSearch(auth, dto.visibility);
const items = await this.searchRepository.searchLargeAssets(dto.size || 250, { ...dto, userIds });
const items = await this.searchRepository.searchLargeAssets(dto.size || 250, {
...dto,
visibility: dto.visibility ?? (auth.session?.hasElevatedPermission ? undefined : 'not-locked'),
userIds,
});
return items.map((item) => mapAsset(item, { auth }));
}
@ -155,7 +177,12 @@ export class SearchService extends BaseService {
const size = dto.size || 100;
const { hasNextPage, items } = await this.searchRepository.searchSmart(
{ page, size },
{ ...dto, userIds: await userIds, embedding },
{
...dto,
userIds: await userIds,
embedding,
visibility: dto.visibility ?? (auth.session?.hasElevatedPermission ? undefined : 'not-locked'),
},
);
return this.mapResponse(items, hasNextPage ? (page + 1).toString() : null, { auth });

View file

@ -77,7 +77,7 @@ export class ServerService extends BaseService {
serverInfo.diskAvailableRaw = diskInfo.available;
serverInfo.diskSizeRaw = diskInfo.total;
serverInfo.diskUseRaw = diskInfo.total - diskInfo.free;
serverInfo.diskUsagePercentage = Number.parseFloat(usagePercentage);
serverInfo.diskUsagePercentage = Number(usagePercentage);
return serverInfo;
}
@ -201,8 +201,12 @@ export class ServerService extends BaseService {
throw new BadRequestException('Invalid license key');
}
const { licensePublicKey } = this.configRepository.getEnv();
const licenseValid = this.cryptoRepository.verifySha256(dto.licenseKey, dto.activationKey, licensePublicKey.server);
if (!licenseValid) {
const isLicenseValid = this.cryptoRepository.verifySha256(
dto.licenseKey,
dto.activationKey,
licensePublicKey.server,
);
if (!isLicenseValid) {
throw new BadRequestException('Invalid license key');
}

View file

@ -20,6 +20,7 @@ export class SharedLinkService extends BaseService {
async getAll(auth: AuthDto, { id, albumId }: SharedLinkSearchDto): Promise<SharedLinkResponseDto[]> {
return this.sharedLinkRepository
.getAll({ userId: auth.user.id, id, albumId })
.then((links) => links.map((link) => mapSharedLink(link, { stripAssetMetadata: false })));
}
@ -200,7 +201,7 @@ export class SharedLinkService extends BaseService {
const results: AssetIdsResponseDto[] = [];
for (const assetId of dto.assetIds) {
const wasRemoved = removedAssetIds.find((id) => id === assetId);
const wasRemoved = removedAssetIds.includes(assetId);
if (!wasRemoved) {
results.push({ assetId, success: false, error: AssetIdErrorReason.NOT_FOUND });
continue;

View file

@ -43,12 +43,12 @@ export class SmartInfoService extends BaseService {
const modelChange =
oldConfig && oldConfig.machineLearning.clip.modelName !== newConfig.machineLearning.clip.modelName;
const dimSizeChange = dbDimSize !== dimSize;
if (!modelChange && !dimSizeChange) {
const isDimSizeChange = dbDimSize !== dimSize;
if (!modelChange && !isDimSizeChange) {
return;
}
if (dimSizeChange) {
if (isDimSizeChange) {
this.logger.log(
`Dimension size of model ${newConfig.machineLearning.clip.modelName} is ${dimSize}, but database expects ${dbDimSize}.`,
);

View file

@ -36,7 +36,7 @@ export class StackService extends BaseService {
async update(auth: AuthDto, id: string, dto: StackUpdateDto): Promise<StackResponseDto> {
await this.requireAccess({ auth, permission: Permission.StackUpdate, ids: [id] });
const stack = await this.findOrFail(id);
if (dto.primaryAssetId && !stack.assets.some(({ id }) => id === dto.primaryAssetId)) {
if (dto.primaryAssetId && stack.assets.every(({ id }) => id !== dto.primaryAssetId)) {
throw new BadRequestException('Primary asset must be in the stack');
}

View file

@ -20,7 +20,7 @@ import { ArgOf } from 'src/repositories/event.repository';
import { BaseService } from 'src/services/base.service';
import { JobOf, StorageAsset } from 'src/types';
import { getAssetFile } from 'src/utils/asset.util';
import { getLivePhotoMotionFilename } from 'src/utils/file';
import { getFilenameExtension, getLivePhotoMotionFilename } from 'src/utils/file';
const storageTokens = {
secondOptions: ['s', 'ss', 'SSS'],
@ -141,8 +141,8 @@ export class StorageTemplateService extends BaseService {
@OnJob({ name: JobName.StorageTemplateMigrationSingle, queue: QueueName.StorageTemplateMigration })
async handleMigrationSingle({ id }: JobOf<JobName.StorageTemplateMigrationSingle>): Promise<JobStatus> {
const config = await this.getConfig({ withCache: true });
const storageTemplateEnabled = config.storageTemplate.enabled;
if (!storageTemplateEnabled) {
const isStorageTemplateEnabled = config.storageTemplate.enabled;
if (!isStorageTemplateEnabled) {
return JobStatus.Skipped;
}
@ -267,10 +267,10 @@ export class StorageTemplateService extends BaseService {
const { storageLabel, filename } = metadata;
try {
const filenameWithoutExtension = path.basename(filename, path.extname(filename));
const filenameWithoutExtension = path.basename(filename, getFilenameExtension(filename));
const source = asset.originalPath;
let extension = path.extname(source).split('.').pop() as string;
let extension = getFilenameExtension(source).split('.').pop() as string;
const sanitized = sanitize(path.basename(filenameWithoutExtension, `.${extension}`));
extension = extension?.toLowerCase();
const rootPath = StorageCore.getLibraryFolder({ id: asset.ownerId, storageLabel });
@ -372,8 +372,8 @@ export class StorageTemplateService extends BaseService {
let duplicateCount = 0;
while (true) {
const exists = await this.storageRepository.checkFileExists(destination);
if (!exists) {
const isExists = await this.storageRepository.checkFileExists(destination);
if (!isExists) {
break;
}

Some files were not shown because too many files have changed in this diff Show more