mirror of
https://github.com/immich-app/immich
synced 2026-08-29 13:15:45 +00:00
Merge main
This commit is contained in:
commit
5ab95df4c0
787 changed files with 33462 additions and 25302 deletions
|
|
@ -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 \
|
||||
|
|
|
|||
|
|
@ -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 && \
|
||||
|
|
|
|||
|
|
@ -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 }],
|
||||
|
|
|
|||
|
|
@ -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:",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 }],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -368,6 +368,7 @@ export const columns = {
|
|||
'plugin_method.types',
|
||||
'plugin_method.schema',
|
||||
'plugin_method.hostFunctions',
|
||||
'plugin_method.allowedHosts',
|
||||
'plugin_method.uiHints',
|
||||
],
|
||||
syncAsset: [
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {}
|
||||
|
|
|
|||
|
|
@ -175,7 +175,7 @@ const peopleFromFaces = (faces?: MaybeDehydrated<AssetFace>[]): PersonResponseDt
|
|||
}
|
||||
}
|
||||
|
||||
return [...peopleMap.values()];
|
||||
return peopleMap.values().toArray();
|
||||
};
|
||||
|
||||
const mapStack = (entity: { stack?: Stack | null }) => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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"'),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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' }),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@
|
|||
select
|
||||
"id",
|
||||
"expiresAt",
|
||||
"pinExpiresAt"
|
||||
"pinExpiresAt",
|
||||
"oauthBearerToken"
|
||||
from
|
||||
"session"
|
||||
where
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}"`);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {};
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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: [];
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
])
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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.
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -55,4 +55,7 @@ export class SessionTable {
|
|||
|
||||
@Column({ nullable: true, index: true })
|
||||
oauthSid!: string | null;
|
||||
|
||||
@Column({ nullable: true })
|
||||
oauthBearerToken!: string | null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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: '' });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -247,7 +247,7 @@ export class BaseService {
|
|||
ctx.workflowRepository,
|
||||
);
|
||||
|
||||
service.logger.setContext(this.name);
|
||||
service.logger.setContext(BaseService.name);
|
||||
|
||||
return service as T;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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...`);
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ export class MaintenanceService extends BaseService {
|
|||
getMaintenanceMode(): Promise<MaintenanceModeState> {
|
||||
return this.systemMetadataRepository
|
||||
.get(SystemMetadataKey.MaintenanceMode)
|
||||
|
||||
.then((state) => state ?? { isMaintenanceMode: false });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 } });
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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() });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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' })
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}.`,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue