Merge branch 'main' into feat/memories-view

This commit is contained in:
Timon
2026-06-03 09:46:49 +02:00
committed by GitHub
350 changed files with 21700 additions and 5738 deletions
+2 -5
View File
@@ -37,15 +37,12 @@ export class AlbumService extends BaseService {
};
}
async getAll(
{ user: { id: ownerId } }: AuthDto,
{ assetId, isOwned, isShared }: GetAlbumsDto,
): Promise<AlbumResponseDto[]> {
async getAll({ user: { id: ownerId } }: AuthDto, { assetId, ...rest }: GetAlbumsDto): Promise<AlbumResponseDto[]> {
await this.albumRepository.updateThumbnails();
const albums = assetId
? await this.albumRepository.getByAssetId(ownerId, assetId)
: await this.albumRepository.getAll(ownerId, { isOwned, isShared });
: await this.albumRepository.getAll(ownerId, rest);
if (albums.length === 0) {
return [];
+327
View File
@@ -0,0 +1,327 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { TranscodeHardwareAcceleration } 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';
import { newTestService, ServiceMocks } from 'test/utils';
// EXTINF values come from FFmpeg's playlist to enforce an exact match
const eiffelExpectedMediaPlaylist = `#EXTM3U
#EXT-X-VERSION:7
#EXT-X-TARGETDURATION:2
#EXT-X-MEDIA-SEQUENCE:0
#EXT-X-PLAYLIST-TYPE:VOD
#EXT-X-MAP:URI="init.mp4"
#EXTINF:2.007222,
seg_0.m4s
#EXTINF:2.007222,
seg_1.m4s
#EXTINF:2.007222,
seg_2.m4s
#EXTINF:2.007222,
seg_3.m4s
#EXTINF:2.007222,
seg_4.m4s
#EXTINF:2.007222,
seg_5.m4s
#EXTINF:2.007222,
seg_6.m4s
#EXTINF:2.007222,
seg_7.m4s
#EXTINF:2.007222,
seg_8.m4s
#EXTINF:2.007222,
seg_9.m4s
#EXTINF:2.007222,
seg_10.m4s
#EXTINF:0.281011,
seg_11.m4s
#EXT-X-ENDLIST
`;
const waterfallExpectedMediaPlaylist = `#EXTM3U
#EXT-X-VERSION:7
#EXT-X-TARGETDURATION:2
#EXT-X-MEDIA-SEQUENCE:0
#EXT-X-PLAYLIST-TYPE:VOD
#EXT-X-MAP:URI="init.mp4"
#EXTINF:2.011405,
seg_0.m4s
#EXTINF:2.011405,
seg_1.m4s
#EXTINF:2.011405,
seg_2.m4s
#EXTINF:2.011405,
seg_3.m4s
#EXTINF:2.011405,
seg_4.m4s
#EXTINF:0.301711,
seg_5.m4s
#EXT-X-ENDLIST
`;
const trainExpectedMediaPlaylist = `#EXTM3U
#EXT-X-VERSION:7
#EXT-X-TARGETDURATION:2
#EXT-X-MEDIA-SEQUENCE:0
#EXT-X-PLAYLIST-TYPE:VOD
#EXT-X-MAP:URI="init.mp4"
#EXTINF:2.000000,
seg_0.m4s
#EXTINF:2.000000,
seg_1.m4s
#EXTINF:2.000000,
seg_2.m4s
#EXTINF:2.000000,
seg_3.m4s
#EXTINF:2.000000,
seg_4.m4s
#EXTINF:2.000000,
seg_5.m4s
#EXTINF:2.000000,
seg_6.m4s
#EXTINF:2.000000,
seg_7.m4s
#EXTINF:2.000000,
seg_8.m4s
#EXTINF:2.000000,
seg_9.m4s
#EXTINF:1.733333,
seg_10.m4s
#EXT-X-ENDLIST
`;
const sessionId = '00000000-0000-0000-0000-000000000000';
const eiffelExpectedMasterDisabled = `#EXTM3U
#EXT-X-VERSION:7
#EXT-X-STREAM-INF:BANDWIDTH=1000000,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
${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
${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
${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
${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
${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
${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
${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
${sessionId}/8/playlist.m3u8
`;
const eiffelExpectedMasterRkmpp = `#EXTM3U
#EXT-X-VERSION:7
#EXT-X-STREAM-INF:BANDWIDTH=1200000,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
${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
${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
${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
${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
${sessionId}/8/playlist.m3u8
`;
const waterfallExpectedMasterDisabled = `#EXTM3U
#EXT-X-VERSION:7
#EXT-X-STREAM-INF:BANDWIDTH=1000000,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
${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
${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
${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
${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
${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
${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
${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
${sessionId}/8/playlist.m3u8
`;
describe(HlsService.name, () => {
let sut: HlsService;
let mocks: ServiceMocks;
beforeEach(() => {
({ sut, mocks } = newTestService(HlsService));
});
describe('getMainPlaylist', () => {
const auth = factory.auth();
const assetId = 'asset-1';
const setup = (asset: typeof eiffelTower | typeof waterfall, accel: TranscodeHardwareAcceleration) => {
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetId]));
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { realtime: { enabled: true }, accel } });
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 }));
}
});
};
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('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('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('throws BadRequestException when realtime transcoding is disabled', async () => {
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetId]));
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { realtime: { enabled: false } } });
await expect(sut.getMainPlaylist(auth, assetId)).rejects.toBeInstanceOf(BadRequestException);
});
it('throws NotFoundException when asset is not yet ready for streaming', async () => {
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetId]));
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { realtime: { enabled: true } } });
await expect(sut.getMainPlaylist(auth, assetId)).rejects.toBeInstanceOf(NotFoundException);
});
});
describe('getMediaPlaylist', () => {
const auth = factory.auth();
const assetId = 'asset-1';
const fixtures = [
{ data: eiffelTower, playlist: eiffelExpectedMediaPlaylist },
{ data: waterfall, playlist: waterfallExpectedMediaPlaylist },
{ data: train, playlist: trainExpectedMediaPlaylist },
];
it.each(fixtures)('matches FFmpeg for $data.originalPath', async ({ data, playlist }) => {
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetId]));
mocks.videoStream.getForMediaPlaylist.mockResolvedValue(data);
await expect(sut.getMediaPlaylist(auth, assetId, sessionId)).resolves.toBe(playlist);
});
it('throws NotFoundException when the session/asset cannot be loaded', async () => {
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetId]));
await expect(sut.getMediaPlaylist(auth, assetId, sessionId)).rejects.toBeInstanceOf(NotFoundException);
});
});
describe('getSegment', () => {
const auth = factory.auth();
const assetId = 'asset-1';
const variantIndex = 0;
beforeEach(() => {
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetId]));
mocks.videoStream.getSession.mockResolvedValue({ id: sessionId, assetId } as never);
mocks.storage.checkFileExists.mockResolvedValue(true);
});
it('emits HlsHeartbeat with segmentIndex 0 for the first init.mp4 request', async () => {
await sut.getSegment(auth, assetId, sessionId, variantIndex, 'init.mp4');
expect(mocks.websocket.serverSend).toHaveBeenCalledWith('HlsHeartbeat', {
sessionId,
variantIndex,
segmentIndex: 0,
});
});
it('emits HlsHeartbeat with the parsed segment number for seg_K.m4s', async () => {
await sut.getSegment(auth, assetId, sessionId, variantIndex, 'seg_5.m4s');
expect(mocks.websocket.serverSend).toHaveBeenCalledWith('HlsHeartbeat', {
sessionId,
variantIndex,
segmentIndex: 5,
});
});
it('returns lastRequested + 1 for init.mp4 after a segment has been served', async () => {
await sut.getSegment(auth, assetId, sessionId, variantIndex, 'seg_5.m4s');
mocks.websocket.serverSend.mockClear();
await sut.getSegment(auth, assetId, sessionId, variantIndex, 'init.mp4');
expect(mocks.websocket.serverSend).toHaveBeenCalledWith('HlsHeartbeat', {
sessionId,
variantIndex,
segmentIndex: 6,
});
});
it('updates lastRequested on a backward-seek segment request', async () => {
await sut.getSegment(auth, assetId, sessionId, variantIndex, 'seg_5.m4s');
await sut.getSegment(auth, assetId, sessionId, variantIndex, 'seg_3.m4s');
mocks.websocket.serverSend.mockClear();
await sut.getSegment(auth, assetId, sessionId, variantIndex, 'init.mp4');
expect(mocks.websocket.serverSend).toHaveBeenCalledWith('HlsHeartbeat', {
sessionId,
variantIndex,
segmentIndex: 4,
});
});
it('tracks segment state per session independently', async () => {
await sut.getSegment(auth, assetId, 'session-a', variantIndex, 'seg_5.m4s');
await sut.getSegment(auth, assetId, 'session-b', variantIndex, 'seg_2.m4s');
mocks.websocket.serverSend.mockClear();
await sut.getSegment(auth, assetId, 'session-a', variantIndex, 'init.mp4');
await sut.getSegment(auth, assetId, 'session-b', variantIndex, 'init.mp4');
expect(mocks.websocket.serverSend).toHaveBeenCalledWith('HlsHeartbeat', {
sessionId: 'session-a',
variantIndex,
segmentIndex: 6,
});
expect(mocks.websocket.serverSend).toHaveBeenCalledWith('HlsHeartbeat', {
sessionId: 'session-b',
variantIndex,
segmentIndex: 3,
});
});
it('rejects pending waiters for the previous variant on variant change', async () => {
mocks.storage.checkFileExists.mockResolvedValueOnce(false);
const pending = sut.getSegment(auth, assetId, sessionId, 0, 'seg_1.m4s');
await new Promise((resolve) => setImmediate(resolve));
await sut.getSegment(auth, assetId, sessionId, 1, 'seg_1.m4s');
await expect(pending).rejects.toThrow('Variant changed');
});
it('throws NotFoundException when the session does not exist', async () => {
mocks.videoStream.getSession.mockReset();
await expect(sut.getSegment(auth, assetId, sessionId, variantIndex, 'init.mp4')).rejects.toBeInstanceOf(
NotFoundException,
);
});
});
describe('endSession', () => {
it('emits HlsSessionEnd', async () => {
const auth = factory.auth();
const assetId = 'asset-1';
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetId]));
await sut.endSession(auth, assetId, sessionId);
expect(mocks.websocket.serverSend).toHaveBeenCalledWith('HlsSessionEnd', { sessionId });
});
});
});
+198
View File
@@ -0,0 +1,198 @@
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 { StorageCore } from 'src/cores/storage.core';
import { OnEvent } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto';
import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto';
import { CacheControl, ImmichWorker, Permission } from 'src/enum';
import { ArgOf } from 'src/repositories/event.repository';
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';
type AssetWithStreamInfo = { videoStream: VideoStreamInfo & { timeBase: number }; packets: VideoPacketInfo };
type ApiSession = { lastRequestedSegment: number | null; lastVariantIndex: number | null };
@Injectable()
export class HlsService extends BaseService {
private pendingSegments = new PendingEvents<'HlsSegmentResult'>({ timeoutMs: 15_000 });
private pendingSessions = new PendingEvents<'HlsSessionResult'>({ timeoutMs: 5000 });
private sessions = new Map<string, ApiSession>();
@OnEvent({ name: 'HlsSessionResult', server: true, workers: [ImmichWorker.Api] })
onSessionResult(event: ArgOf<'HlsSessionResult'>) {
this.pendingSessions.complete(event.sessionId, event);
if (event.error) {
this.sessions.delete(event.sessionId);
this.pendingSegments.rejectByPrefix(`${event.sessionId}:`, event.error);
}
}
@OnEvent({ name: 'HlsSessionEnd', server: true, workers: [ImmichWorker.Api] })
onSessionEnd({ sessionId }: ArgOf<'HlsSessionEnd'>) {
this.sessions.delete(sessionId);
this.pendingSegments.rejectByPrefix(`${sessionId}:`, 'Session ended');
}
@OnEvent({ name: 'HlsSegmentResult', server: true, workers: [ImmichWorker.Api] })
onSegmentResult(event: ArgOf<'HlsSegmentResult'>) {
this.pendingSegments.complete(this.getSegmentKey(event), event);
}
async getMainPlaylist(auth: AuthDto, assetId: string) {
await this.requireAccess({ auth, permission: Permission.AssetView, ids: [assetId] });
const { ffmpeg } = await this.getConfig({ withCache: true });
if (!ffmpeg.realtime.enabled) {
throw new BadRequestException('Real-time transcoding is not enabled');
}
const asset = await this.videoStreamRepository.getForMainPlaylist(assetId);
if (!asset) {
throw new NotFoundException('Asset is not yet ready for streaming');
}
// Sharing the sessionId allows only one microservices worker to successfully insert to the session table.
// The microservices worker that creates a session owns the transcoding lifecycle for it.
const sessionId = this.cryptoRepository.randomUUID();
this.websocketRepository.serverSend('HlsSessionRequest', { sessionId, assetId, ownerId: auth.user.id });
await this.pendingSessions.wait(sessionId);
this.trackSession(sessionId);
return this.generateMainPlaylist(sessionId, ffmpeg, asset);
}
async getMediaPlaylist(auth: AuthDto, assetId: string, sessionId: string) {
await this.requireAccess({ auth, permission: Permission.AssetView, ids: [assetId] });
const asset = await this.videoStreamRepository.getForMediaPlaylist(assetId, sessionId);
if (!asset) {
throw new NotFoundException('Asset not found or not yet ready for streaming');
}
return this.generateMediaPlaylist(asset);
}
async getSegment(auth: AuthDto, assetId: string, sessionId: string, variantIndex: number, filename: string) {
await this.requireAccess({ auth, permission: Permission.AssetView, ids: [assetId] });
const session = await this.videoStreamRepository.getSession(sessionId);
if (!session) {
throw new NotFoundException('Session not found');
}
const variantDir = StorageCore.getHlsVariantFolder({ ownerId: auth.user.id, sessionId, variantIndex });
const path = join(variantDir, filename);
const response = new ImmichFileResponse({
path,
contentType: 'video/mp4',
cacheControl: CacheControl.PrivateWithCache,
});
const apiSession = this.trackSession(sessionId, variantIndex);
const segmentIndex = this.getSegmentIndex(apiSession, filename);
this.websocketRepository.serverSend('HlsHeartbeat', { sessionId, variantIndex, segmentIndex });
if (await this.storageRepository.checkFileExists(path, constants.R_OK)) {
return response;
}
this.websocketRepository.serverSend('HlsSegmentRequest', { sessionId, assetId, variantIndex, segmentIndex });
await this.pendingSegments.wait(this.getSegmentKey({ sessionId, variantIndex, segmentIndex }));
return response;
}
async endSession(auth: AuthDto, assetId: string, sessionId: string): Promise<void> {
await this.requireAccess({ auth, permission: Permission.AssetView, ids: [assetId] });
this.websocketRepository.serverSend('HlsSessionEnd', { sessionId });
}
private generateMainPlaylist(sessionId: string, ffmpeg: SystemConfigFFmpegDto, asset: AssetWithStreamInfo) {
const fps = ((asset.packets.packetCount * asset.videoStream.timeBase) / asset.packets.totalDuration).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}`];
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)) {
continue;
}
const { width, height } = getOutputSize(asset.videoStream, resolution);
lines.push(
`#EXT-X-STREAM-INF:BANDWIDTH=${bitrate},RESOLUTION=${width}x${height},CODECS="${codecString},mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=${fps}`,
`${sessionId}/${i}/playlist.m3u8`,
);
}
lines.push('');
if (lines.length === 3) {
throw new NotFoundException('No supported variants for this video');
}
return lines.join('\n');
}
private generateMediaPlaylist({ videoStream, packets }: AssetWithStreamInfo) {
const fps = (packets.packetCount * videoStream.timeBase) / packets.totalDuration;
const framesPerSegment = Math.ceil(HLS_SEGMENT_DURATION * fps);
const fullSegmentDuration = framesPerSegment / fps;
const segmentCount = Math.ceil(packets.outputFrames / framesPerSegment);
const lastSegmentFrames = packets.outputFrames - framesPerSegment * (segmentCount - 1);
const lastSegmentDuration = lastSegmentFrames / fps;
const lines = [
'#EXTM3U',
`#EXT-X-VERSION:${HLS_VERSION}`,
`#EXT-X-TARGETDURATION:${HLS_SEGMENT_DURATION}`,
'#EXT-X-MEDIA-SEQUENCE:0',
'#EXT-X-PLAYLIST-TYPE:VOD',
'#EXT-X-MAP:URI="init.mp4"',
];
for (let i = 0; i < segmentCount - 1; i++) {
lines.push(`#EXTINF:${fullSegmentDuration.toFixed(6)},`, `seg_${i}.m4s`);
}
lines.push(`#EXTINF:${lastSegmentDuration.toFixed(6)},`, `seg_${segmentCount - 1}.m4s`, '#EXT-X-ENDLIST', '');
return lines.join('\n');
}
private getSegmentKey({ sessionId, variantIndex, segmentIndex }: ArgOf<'HlsSegmentResult'>) {
return `${sessionId}:${variantIndex}:${segmentIndex}`;
}
private getSegmentIndex(session: ApiSession, filename: string) {
if (filename.endsWith('.mp4')) {
return (session.lastRequestedSegment ?? -1) + 1;
}
const segmentIndex = Number.parseInt(HLS_SEGMENT_FILENAME_REGEX.exec(filename)![1]);
session.lastRequestedSegment = segmentIndex;
return segmentIndex;
}
private trackSession(id: string, variantIndex: number | null = null) {
const session = this.sessions.get(id);
if (!session) {
const newSession = { lastRequestedSegment: null, lastVariantIndex: variantIndex };
this.sessions.set(id, newSession);
return newSession;
}
if (session.lastVariantIndex !== null && session.lastVariantIndex !== variantIndex) {
this.pendingSegments.rejectByPrefix(`${id}:${session.lastVariantIndex}:`, 'Variant changed');
}
session.lastVariantIndex = variantIndex;
return session;
}
}
+4
View File
@@ -11,6 +11,7 @@ import { DatabaseBackupService } from 'src/services/database-backup.service';
import { DatabaseService } from 'src/services/database.service';
import { DownloadService } from 'src/services/download.service';
import { DuplicateService } from 'src/services/duplicate.service';
import { HlsService } from 'src/services/hls.service';
import { JobService } from 'src/services/job.service';
import { LibraryService } from 'src/services/library.service';
import { MaintenanceService } from 'src/services/maintenance.service';
@@ -39,6 +40,7 @@ import { SystemMetadataService } from 'src/services/system-metadata.service';
import { TagService } from 'src/services/tag.service';
import { TelemetryService } from 'src/services/telemetry.service';
import { TimelineService } from 'src/services/timeline.service';
import { TranscodingService } from 'src/services/transcoding.service';
import { TrashService } from 'src/services/trash.service';
import { UserAdminService } from 'src/services/user-admin.service';
import { UserService } from 'src/services/user.service';
@@ -61,6 +63,7 @@ export const services = [
DatabaseService,
DownloadService,
DuplicateService,
HlsService,
JobService,
LibraryService,
MaintenanceService,
@@ -89,6 +92,7 @@ export const services = [
TagService,
TelemetryService,
TimelineService,
TranscodingService,
TrashService,
UserAdminService,
UserService,
+24 -2
View File
@@ -57,7 +57,6 @@ describe(PersonService.name, () => {
],
});
expect(mocks.person.getAllForUser).toHaveBeenCalledWith({ skip: 0, take: 10 }, auth.user.id, {
minimumFaceCount: 3,
withHidden: true,
});
});
@@ -84,7 +83,6 @@ describe(PersonService.name, () => {
],
});
expect(mocks.person.getAllForUser).toHaveBeenCalledWith({ skip: 0, take: 10 }, auth.user.id, {
minimumFaceCount: 3,
withHidden: false,
});
});
@@ -454,6 +452,30 @@ describe(PersonService.name, () => {
expect(mocks.person.update).not.toHaveBeenCalled();
expect(mocks.job.queueAll).not.toHaveBeenCalled();
});
it('should reject creating a face on an asset the user does not own', async () => {
const auth = AuthFactory.create();
const asset = AssetFactory.create();
const person = PersonFactory.create({ faceAssetId: null });
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set());
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id]));
await expect(
sut.createFace(auth, {
assetId: asset.id,
personId: person.id,
imageHeight: 500,
imageWidth: 400,
x: 10,
y: 20,
width: 100,
height: 110,
}),
).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.person.createAssetFace).not.toHaveBeenCalled();
});
});
describe('createNewFeaturePhoto', () => {
+1 -3
View File
@@ -63,9 +63,7 @@ export class PersonService extends BaseService {
}
closestFaceAssetId = person.faceAssetId;
}
const { machineLearning } = await this.getConfig({ withCache: false });
const { items, hasNextPage } = await this.personRepository.getAllForUser(pagination, auth.user.id, {
minimumFaceCount: machineLearning.facialRecognition.minFaces,
withHidden,
closestFaceAssetId,
});
@@ -627,7 +625,7 @@ export class PersonService extends BaseService {
// TODO return a asset face response
async createFace(auth: AuthDto, dto: AssetFaceCreateDto): Promise<void> {
await Promise.all([
this.requireAccess({ auth, permission: Permission.AssetRead, ids: [dto.assetId] }),
this.requireAccess({ auth, permission: Permission.AssetUpdate, ids: [dto.assetId] }),
this.requireAccess({ auth, permission: Permission.PersonRead, ids: [dto.personId] }),
]);
@@ -41,6 +41,7 @@ describe(QueueService.name, () => {
{ name: JobName.PersonCleanup },
{ name: JobName.MemoryCleanup },
{ name: JobName.SessionCleanup },
{ name: JobName.HlsSessionCleanup },
{ name: JobName.AuditTableCleanup },
{ name: JobName.MemoryGenerate },
{ name: JobName.UserSyncUsage },
+1
View File
@@ -269,6 +269,7 @@ export class QueueService extends BaseService {
{ name: JobName.PersonCleanup },
{ name: JobName.MemoryCleanup },
{ name: JobName.SessionCleanup },
{ name: JobName.HlsSessionCleanup },
{ name: JobName.AuditTableCleanup },
);
}
@@ -148,6 +148,7 @@ describe(ServerService.name, () => {
configFile: false,
trash: true,
email: false,
realtimeTranscoding: false,
});
expect(mocks.systemMetadata.get).toHaveBeenCalled();
});
@@ -167,6 +168,7 @@ describe(ServerService.name, () => {
mapDarkStyleUrl: 'https://tiles.immich.cloud/v1/style/dark.json',
mapLightStyleUrl: 'https://tiles.immich.cloud/v1/style/light.json',
maintenanceMode: false,
minFaces: 3,
});
expect(mocks.systemMetadata.get).toHaveBeenCalled();
});
+3 -1
View File
@@ -86,7 +86,7 @@ export class ServerService extends BaseService {
}
async getFeatures(): Promise<ServerFeaturesDto> {
const { reverseGeocoding, metadata, map, machineLearning, trash, oauth, passwordLogin, notifications } =
const { reverseGeocoding, metadata, map, machineLearning, trash, oauth, passwordLogin, notifications, ffmpeg } =
await this.getConfig({ withCache: false });
const { configFile } = this.configRepository.getEnv();
@@ -106,6 +106,7 @@ export class ServerService extends BaseService {
passwordLogin: passwordLogin.enabled,
configFile: !!configFile,
email: notifications.smtp.enabled,
realtimeTranscoding: ffmpeg.realtime.enabled,
};
}
@@ -127,6 +128,7 @@ export class ServerService extends BaseService {
mapDarkStyleUrl: config.map.darkStyle,
mapLightStyleUrl: config.map.lightStyle,
maintenanceMode: false,
minFaces: config.machineLearning.facialRecognition.minFaces,
};
}
@@ -1,5 +1,6 @@
import { BadRequestException } from '@nestjs/common';
import { defaults, SystemConfig } from 'src/config';
import { ReleaseChannel } from 'src/dtos/system-config.dto';
import {
AudioCodec,
Colorspace,
@@ -72,6 +73,9 @@ const updatedConfig = Object.freeze<SystemConfig>({
accel: TranscodeHardwareAcceleration.Disabled,
accelDecode: true,
tonemap: ToneMapping.Hable,
realtime: {
enabled: false,
},
},
logging: {
enabled: true,
@@ -184,6 +188,7 @@ const updatedConfig = Object.freeze<SystemConfig>({
},
newVersionCheck: {
enabled: true,
channel: ReleaseChannel.Stable,
},
trash: {
enabled: true,
@@ -0,0 +1,539 @@
import {
HLS_BACKPRESSURE_PAUSE_SEGMENTS,
HLS_BACKPRESSURE_RESUME_SEGMENTS,
HLS_CLEANUP_INTERVAL_MS,
HLS_INACTIVITY_TIMEOUT_MS,
HLS_LEASE_DURATION_MS,
} from 'src/constants';
import { TranscodingService } from 'src/services/transcoding.service';
import { VIDEO_STREAM_SESSION_PK_CONSTRAINT } from 'src/utils/database';
import { eiffelTower, train, waterfall } from 'test/fixtures/media.stub';
import { mockSpawn, newTestService, ServiceMocks } from 'test/utils';
import { vi } from 'vitest';
describe(TranscodingService.name, () => {
let sut: TranscodingService;
let mocks: ServiceMocks;
const sessionId = 'session-1';
const assetId = 'asset-1';
const ownerId = 'user-1';
const completeSegment = (index: number) => {
const listener = vi.mocked(mocks.storage.watchDir).mock.lastCall?.[1];
expect(listener).toBeDefined();
listener!('rename', `seg_${index}.m4s`);
};
const completeSegmentsThrough = (start: number, end: number) => {
for (let i = start; i <= end; i++) {
completeSegment(i);
}
};
beforeEach(() => {
({ sut, mocks } = newTestService(TranscodingService));
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { realtime: { enabled: true } } });
mocks.videoStream.getForTranscoding.mockResolvedValue(eiffelTower);
});
describe('onSessionRequest', () => {
it('creates the session row and emits HlsSessionResult on success', async () => {
await sut.onSessionRequest({ sessionId, assetId, ownerId });
expect(mocks.videoStream.createSession).toHaveBeenCalledWith({
id: sessionId,
assetId,
expiresAt: expect.any(Date),
});
expect(mocks.websocket.serverSend).toHaveBeenCalledWith('HlsSessionResult', { sessionId });
});
it('treats a primary-key conflict as a no-op for replay tolerance', async () => {
mocks.videoStream.createSession.mockRejectedValue({ constraint_name: VIDEO_STREAM_SESSION_PK_CONSTRAINT });
await sut.onSessionRequest({ sessionId, assetId, ownerId });
expect(mocks.websocket.serverSend).not.toHaveBeenCalled();
});
it('emits HlsSessionResult with an error on other DB failures', async () => {
mocks.videoStream.createSession.mockRejectedValue(new Error('database is down'));
await sut.onSessionRequest({ sessionId, assetId, ownerId });
expect(mocks.websocket.serverSend).toHaveBeenCalledWith('HlsSessionResult', {
sessionId,
error: 'Failed to create HLS session',
});
});
});
describe('onSessionEnd', () => {
it('removes the session, kills the transcode, and deletes the dir + DB row', async () => {
await sut.onSessionRequest({ sessionId, assetId, ownerId });
const process = mockSpawn(0, '', '');
mocks.process.spawn.mockReturnValue(process);
await sut.onSegmentRequest({ sessionId, assetId, variantIndex: 0, segmentIndex: 0 });
await sut.onSessionEnd({ sessionId });
expect(process.kill).toHaveBeenCalled();
expect(mocks.storage.unlinkDir).toHaveBeenCalled();
expect(mocks.videoStream.deleteSession).toHaveBeenCalledWith(sessionId);
});
it('is a no-op when the session is unknown', async () => {
await sut.onSessionEnd({ sessionId: 'never-created' });
expect(mocks.videoStream.deleteSession).not.toHaveBeenCalled();
expect(mocks.storage.unlinkDir).not.toHaveBeenCalled();
});
});
describe('onHeartbeat', () => {
it('extends the DB lease when remaining time falls below half', async () => {
vi.useFakeTimers();
try {
await sut.onSessionRequest({ sessionId, assetId, ownerId });
vi.setSystemTime(Date.now() + HLS_LEASE_DURATION_MS / 2 + 1);
await sut.onHeartbeat({ sessionId });
expect(mocks.videoStream.extendSession).toHaveBeenCalledWith(sessionId, expect.any(Date));
} finally {
vi.useRealTimers();
}
});
it('does not extend the lease while it is still fresh', async () => {
await sut.onSessionRequest({ sessionId, assetId, ownerId });
await sut.onHeartbeat({ sessionId });
expect(mocks.videoStream.extendSession).not.toHaveBeenCalled();
});
it('is a no-op when the session is unknown', async () => {
await sut.onHeartbeat({ sessionId: 'never-created' });
expect(mocks.videoStream.extendSession).not.toHaveBeenCalled();
});
});
describe('onSegmentRequest', () => {
beforeEach(async () => {
await sut.onSessionRequest({ sessionId, assetId, ownerId });
mocks.websocket.serverSend.mockClear();
});
it('spawns FFmpeg on the first request', async () => {
mocks.process.spawn.mockReturnValue(mockSpawn(0, '', ''));
await sut.onSegmentRequest({ sessionId, assetId, variantIndex: 0, segmentIndex: 0 });
expect(mocks.process.spawn).toHaveBeenCalledTimes(1);
expect(mocks.process.spawn).toHaveBeenCalledWith('ffmpeg', expect.any(Array), expect.any(Object));
});
it('kills and respawns when the variant changes', async () => {
const first = mockSpawn(0, '', '');
const second = mockSpawn(0, '', '');
mocks.process.spawn.mockReturnValueOnce(first).mockReturnValueOnce(second);
await sut.onSegmentRequest({ sessionId, assetId, variantIndex: 0, segmentIndex: 0 });
await sut.onSegmentRequest({ sessionId, assetId, variantIndex: 1, segmentIndex: 0 });
expect(first.kill).toHaveBeenCalled();
expect(mocks.process.spawn).toHaveBeenCalledTimes(2);
});
it('kills and respawns when seeking before the start segment', async () => {
const first = mockSpawn(0, '', '');
const second = mockSpawn(0, '', '');
mocks.process.spawn.mockReturnValueOnce(first).mockReturnValueOnce(second);
await sut.onSegmentRequest({ sessionId, assetId, variantIndex: 0, segmentIndex: 5 });
await sut.onSegmentRequest({ sessionId, assetId, variantIndex: 0, segmentIndex: 2 });
expect(first.kill).toHaveBeenCalled();
expect(mocks.process.spawn).toHaveBeenCalledTimes(2);
});
it('kills and respawns when the requested segment is too far ahead', async () => {
const first = mockSpawn(0, '', '');
const second = mockSpawn(0, '', '');
mocks.process.spawn.mockReturnValueOnce(first).mockReturnValueOnce(second);
await sut.onSegmentRequest({ sessionId, assetId, variantIndex: 0, segmentIndex: 0 });
await sut.onSegmentRequest({ sessionId, assetId, variantIndex: 0, segmentIndex: 5 });
expect(first.kill).toHaveBeenCalled();
expect(mocks.process.spawn).toHaveBeenCalledTimes(2);
});
it('does not spawn when the session is unknown', async () => {
await sut.onSegmentRequest({ sessionId: 'never-created', assetId, variantIndex: 0, segmentIndex: 0 });
expect(mocks.process.spawn).not.toHaveBeenCalled();
});
it('accepts segments from a restart after the previous ffmpeg exited on its own', async () => {
const first = mockSpawn(0, '', '');
const second = mockSpawn(0, '', '');
mocks.process.spawn.mockReturnValueOnce(first).mockReturnValueOnce(second);
await sut.onSegmentRequest({ sessionId, assetId, variantIndex: 0, segmentIndex: 10 });
completeSegment(10);
const onCalls = vi.mocked(first.on).mock.calls as unknown as [string, (code: number) => void][];
const exitHandler = onCalls.find(([event]) => event === 'exit')?.[1];
exitHandler?.(0);
mocks.websocket.serverSend.mockClear();
await sut.onSegmentRequest({ sessionId, assetId, variantIndex: 0, segmentIndex: 2 });
completeSegment(2);
expect(mocks.websocket.serverSend).toHaveBeenCalledWith('HlsSegmentResult', {
sessionId,
variantIndex: 0,
segmentIndex: 2,
});
});
});
describe('backpressure', () => {
let proc: ReturnType<typeof mockSpawn>;
beforeEach(async () => {
proc = mockSpawn(0, '', '');
mocks.process.spawn.mockReturnValue(proc);
await sut.onSessionRequest({ sessionId, assetId, ownerId });
await sut.onSegmentRequest({ sessionId, assetId, variantIndex: 0, segmentIndex: 0 });
});
it('pauses the transcode once the lead exceeds HLS_BACKPRESSURE_PAUSE_SEGMENTS', async () => {
completeSegmentsThrough(0, HLS_BACKPRESSURE_PAUSE_SEGMENTS + 1);
await sut.onHeartbeat({ sessionId, segmentIndex: 0 });
expect(proc.kill).toHaveBeenCalledWith('SIGSTOP');
});
it('does not pause when the lead equals the pause threshold', async () => {
completeSegmentsThrough(0, HLS_BACKPRESSURE_PAUSE_SEGMENTS);
await sut.onHeartbeat({ sessionId, segmentIndex: 0 });
expect(proc.kill).not.toHaveBeenCalled();
});
it('resumes once the lead drops below HLS_BACKPRESSURE_RESUME_SEGMENTS', async () => {
completeSegmentsThrough(0, HLS_BACKPRESSURE_PAUSE_SEGMENTS + 1);
await sut.onHeartbeat({ sessionId, segmentIndex: 0 });
expect(proc.kill).toHaveBeenCalledWith('SIGSTOP');
vi.mocked(proc.kill).mockClear();
const requested = HLS_BACKPRESSURE_PAUSE_SEGMENTS + 1 - (HLS_BACKPRESSURE_RESUME_SEGMENTS - 1);
await sut.onHeartbeat({ sessionId, segmentIndex: requested });
expect(proc.kill).toHaveBeenCalledWith('SIGCONT');
});
it('stays paused while the lead is in the dead-band', async () => {
completeSegmentsThrough(0, HLS_BACKPRESSURE_PAUSE_SEGMENTS + 1);
await sut.onHeartbeat({ sessionId, segmentIndex: 0 });
vi.mocked(proc.kill).mockClear();
const requested = HLS_BACKPRESSURE_PAUSE_SEGMENTS + 1 - HLS_BACKPRESSURE_RESUME_SEGMENTS;
await sut.onHeartbeat({ sessionId, segmentIndex: requested });
expect(proc.kill).not.toHaveBeenCalled();
});
it('is a no-op when no segment has completed yet', async () => {
await sut.onHeartbeat({ sessionId, segmentIndex: 0 });
expect(proc.kill).not.toHaveBeenCalled();
});
it('is a no-op when the heartbeat omits segmentIndex', async () => {
completeSegmentsThrough(0, HLS_BACKPRESSURE_PAUSE_SEGMENTS + 1);
await sut.onHeartbeat({ sessionId });
expect(proc.kill).not.toHaveBeenCalled();
});
it('resumes the paused transcode when the client requests the next in-range segment', async () => {
completeSegmentsThrough(0, HLS_BACKPRESSURE_PAUSE_SEGMENTS + 1);
await sut.onHeartbeat({ sessionId, segmentIndex: 0 });
expect(proc.kill).toHaveBeenCalledWith('SIGSTOP');
vi.mocked(proc.kill).mockClear();
await sut.onSegmentRequest({ sessionId, assetId, variantIndex: 0, segmentIndex: 1 });
expect(proc.kill).toHaveBeenCalledWith('SIGCONT');
expect(mocks.process.spawn).toHaveBeenCalledTimes(1);
});
it('does not re-pause a freshly spawned transcode after a seek-driven restart', async () => {
const newProc = mockSpawn(0, '', '');
mocks.process.spawn.mockReturnValueOnce(newProc);
completeSegmentsThrough(0, HLS_BACKPRESSURE_PAUSE_SEGMENTS + 1);
await sut.onHeartbeat({ sessionId, segmentIndex: 0 });
expect(proc.kill).toHaveBeenCalledWith('SIGSTOP');
await sut.onSegmentRequest({ sessionId, assetId, variantIndex: 1, segmentIndex: 0 });
vi.mocked(newProc.kill).mockClear();
await sut.onHeartbeat({ sessionId, segmentIndex: 0 });
expect(newProc.kill).not.toHaveBeenCalled();
});
it('ignores stale segment events from the prior transcode after a backward seek', async () => {
const newProc = mockSpawn(0, '', '');
mocks.process.spawn.mockReturnValueOnce(newProc);
const completedAhead = HLS_BACKPRESSURE_PAUSE_SEGMENTS + 5;
completeSegmentsThrough(1, completedAhead); // seg_0 was emitted in beforeEach
await sut.onSegmentRequest({ sessionId, assetId, variantIndex: 1, segmentIndex: 0 });
vi.mocked(newProc.kill).mockClear();
mocks.websocket.serverSend.mockClear();
completeSegment(completedAhead + 1);
expect(mocks.websocket.serverSend).not.toHaveBeenCalledWith(
'HlsSegmentResult',
expect.objectContaining({ segmentIndex: completedAhead + 1 }),
);
expect(newProc.kill).not.toHaveBeenCalled();
completeSegment(0);
expect(mocks.websocket.serverSend).toHaveBeenCalledWith(
'HlsSegmentResult',
expect.objectContaining({ segmentIndex: 0 }),
);
});
});
describe('inactivity sweeper', () => {
it('reaps a session whose last activity exceeds the inactivity timeout', async () => {
vi.useFakeTimers();
try {
await sut.onSessionRequest({ sessionId, assetId, ownerId });
mocks.websocket.serverSend.mockClear();
await vi.advanceTimersByTimeAsync(HLS_INACTIVITY_TIMEOUT_MS + HLS_CLEANUP_INTERVAL_MS);
expect(mocks.websocket.serverSend).toHaveBeenCalledWith('HlsSessionEnd', { sessionId });
expect(mocks.videoStream.deleteSession).toHaveBeenCalledWith(sessionId);
} finally {
vi.useRealTimers();
}
});
});
describe('onShutdown', () => {
it('ends every active session', async () => {
await sut.onSessionRequest({ sessionId: 'session-a', assetId, ownerId });
await sut.onSessionRequest({ sessionId: 'session-b', assetId, ownerId });
await sut.onShutdown();
expect(mocks.videoStream.deleteSession).toHaveBeenCalledWith('session-a');
expect(mocks.videoStream.deleteSession).toHaveBeenCalledWith('session-b');
});
});
describe('onHlsSessionCleanup', () => {
it('reaps DB-expired sessions under a database lock', async () => {
mocks.database.withLock.mockImplementation(async (_, fn) => fn());
mocks.videoStream.getExpiredSessions.mockResolvedValue([
{ id: 'expired-1', ownerId: 'user-a' },
{ id: 'expired-2', ownerId: 'user-b' },
]);
await sut.onHlsSessionCleanup();
expect(mocks.videoStream.deleteSession).toHaveBeenCalledWith('expired-1');
expect(mocks.videoStream.deleteSession).toHaveBeenCalledWith('expired-2');
expect(mocks.storage.unlinkDir).toHaveBeenCalledTimes(2);
});
});
describe('FFmpeg full command', () => {
const baseCommand = [
'-nostdin',
'-nostats',
'-i',
'eiffel-tower.mp4',
'-map',
'0:0',
'-map_metadata',
'-1',
'-map',
'0:1',
'-g',
'50',
'-keyint_min',
'50',
'-crf',
'23',
'-copyts',
'-r',
'50130000/2012441',
'-avoid_negative_ts',
'disabled',
'-f',
'hls',
'-hls_time',
'2',
'-hls_list_size',
'0',
'-hls_segment_type',
'fmp4',
'-hls_fmp4_init_filename',
'init.mp4',
'-hls_segment_options',
'movflags=+frag_discont',
'-hls_flags',
'temp_file',
'-start_number',
'0',
];
it.each([
{
variantIndex: 6,
expected: [
...baseCommand,
'-c:v',
'libsvtav1',
'-c:a',
'aac',
'-preset',
'12',
'-svtav1-params',
'hierarchical-levels=3:lookahead=0:enable-tf=0:mbr=4000k',
'-hls_segment_filename',
'/data/encoded-video/user-1/se/ss/session-1/6/seg_%d.m4s',
'/data/encoded-video/user-1/se/ss/session-1/6/playlist.m3u8',
].sort(),
},
{
variantIndex: 4,
expected: [
...baseCommand,
'-c:v',
'hevc',
'-c:a',
'aac',
'-tag:v',
'hvc1',
'-preset',
'ultrafast',
'-maxrate',
'2500k',
'-bufsize',
'5000k',
'-x265-params',
'no-scenecut=1:no-open-gop=1',
'-vf',
'scale=720:-2',
'-hls_segment_filename',
'/data/encoded-video/user-1/se/ss/session-1/4/seg_%d.m4s',
'/data/encoded-video/user-1/se/ss/session-1/4/playlist.m3u8',
].sort(),
},
{
variantIndex: 2,
expected: [
...baseCommand,
'-c:v',
'h264',
'-c:a',
'aac',
'-preset',
'ultrafast',
'-maxrate',
'2500k',
'-bufsize',
'5000k',
'-sc_threshold:v',
'0',
'-vf',
'scale=480:-2',
'-hls_segment_filename',
'/data/encoded-video/user-1/se/ss/session-1/2/seg_%d.m4s',
'/data/encoded-video/user-1/se/ss/session-1/2/playlist.m3u8',
].sort(),
},
])('builds the expected FFmpeg command for $codec (variant $variantIndex)', async ({ variantIndex, expected }) => {
mocks.process.spawn.mockReturnValue(mockSpawn(0, '', ''));
await sut.onSessionRequest({ sessionId, assetId, ownerId });
await sut.onSegmentRequest({ sessionId, assetId, variantIndex, segmentIndex: 0 });
expect(mocks.process.spawn.mock.calls[0][1].toSorted()).toEqual(expected);
});
});
describe('FFmpeg seek per segment', () => {
const eiffelSeeks = [
0, 1.987_15, 3.994_372_222_222_222, 6.001_594_444_444_444, 8.008_816_666_666_666, 10.016_038_888_888_888,
12.023_261_111_111_111, 14.030_483_333_333_333, 16.037_705_555_555_554, 18.044_927_777_777_776,
20.052_149_999_999_997, 22.059_372_222_222_223,
];
const waterfallSeeks = [
0, 1.994_642_826_321_467, 4.006_047_357_065_803, 6.017_451_887_810_139_5, 8.028_856_418_554_476,
10.040_260_949_298_812,
];
const trainSeeks = [
0, 1.991_666_666_666_666_7, 3.991_666_666_666_666_7, 5.991_666_666_666_666, 7.991_666_666_666_666,
9.991_666_666_666_667, 11.991_666_666_666_667, 13.991_666_666_666_667, 15.991_666_666_666_667,
17.991_666_666_666_667, 19.991_666_666_666_667,
];
const cases = [
...eiffelSeeks.map((expected, segmentIndex) => ({
name: `${eiffelTower.originalPath} K=${segmentIndex}`,
fixture: eiffelTower,
segmentIndex,
expected,
})),
...waterfallSeeks.map((expected, segmentIndex) => ({
name: `${waterfall.originalPath} K=${segmentIndex}`,
fixture: waterfall,
segmentIndex,
expected,
})),
...trainSeeks.map((expected, segmentIndex) => ({
name: `${train.originalPath} K=${segmentIndex}`,
fixture: train,
segmentIndex,
expected,
})),
];
it.each(cases)('$name', async ({ fixture, segmentIndex, expected }) => {
mocks.videoStream.getForTranscoding.mockResolvedValue(fixture);
mocks.process.spawn.mockReturnValue(mockSpawn(0, '', ''));
await sut.onSessionRequest({ sessionId, assetId, ownerId });
await sut.onSegmentRequest({ sessionId, assetId, variantIndex: 0, segmentIndex });
const args = mocks.process.spawn.mock.calls[0][1] as string[];
if (expected === 0) {
expect(args).toEqual(expect.arrayContaining(['-copyts', '-avoid_negative_ts', 'disabled']));
expect(args).not.toContain('-ss');
} else {
expect(args).toEqual(
expect.arrayContaining(['-ss', String(expected), '-copyts', '-avoid_negative_ts', 'disabled']),
);
}
});
});
});
+387
View File
@@ -0,0 +1,387 @@
import { Injectable } from '@nestjs/common';
import { ChildProcess } from 'node:child_process';
import { join } from 'node:path';
import {
HLS_BACKPRESSURE_PAUSE_SEGMENTS,
HLS_BACKPRESSURE_RESUME_SEGMENTS,
HLS_CLEANUP_INTERVAL_MS,
HLS_INACTIVITY_TIMEOUT_MS,
HLS_LEASE_DURATION_MS,
HLS_SEGMENT_DURATION,
HLS_SEGMENT_FILENAME_REGEX,
HLS_VARIANTS,
} from 'src/constants';
import { StorageCore } from 'src/cores/storage.core';
import { OnEvent, OnJob } from 'src/decorators';
import { DatabaseLock, ImmichWorker, JobName, QueueName, TranscodeTarget } from 'src/enum';
import { ArgOf } from 'src/repositories/event.repository';
import { BaseService } from 'src/services/base.service';
import { VideoInterfaces } from 'src/types';
import { isVideoStreamSessionPkConstraint } from 'src/utils/database';
import { BaseConfig } from 'src/utils/media';
type Session = {
assetId: string;
expiresAt: Date;
id: string;
lastActivityTime: Date;
lastClientRequestedSegment: number | null;
lastCompletedSegment: number | null;
ownerId: string;
paused: boolean;
process: ChildProcess | null;
startSegment: number | null;
variantIndex: number | null;
};
@Injectable()
export class TranscodingService extends BaseService {
private sessions = new Map<string, Session>();
private videoInterfaces: VideoInterfaces = { dri: [], mali: false };
private cleanupInterval: NodeJS.Timeout | null = null;
@OnEvent({ name: 'AppBootstrap', workers: [ImmichWorker.Microservices] })
async onBootstrap() {
const [videoInterfaces] = await Promise.all([this.storageCore.getVideoInterfaces(), this.removeExpiredSessions()]);
this.videoInterfaces = videoInterfaces;
}
@OnEvent({ name: 'AppShutdown', workers: [ImmichWorker.Microservices] })
onShutdown() {
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval);
this.cleanupInterval = null;
}
return Promise.all([...this.sessions.values()].map(({ id }) => this.onSessionEnd({ sessionId: id })));
}
@OnJob({ name: JobName.HlsSessionCleanup, queue: QueueName.BackgroundTask })
onHlsSessionCleanup() {
return this.removeExpiredSessions();
}
@OnEvent({ name: 'HlsSessionRequest', server: true, workers: [ImmichWorker.Microservices] })
async onSessionRequest({ assetId, sessionId, ownerId }: ArgOf<'HlsSessionRequest'>) {
try {
const expiresAt = new Date(Date.now() + HLS_LEASE_DURATION_MS);
await this.videoStreamRepository.createSession({ id: sessionId, assetId, expiresAt });
this.sessions.set(sessionId, {
assetId,
expiresAt,
id: sessionId,
lastActivityTime: new Date(),
lastClientRequestedSegment: null,
lastCompletedSegment: null,
ownerId,
paused: false,
process: null,
startSegment: null,
variantIndex: null,
});
this.cleanupInterval ??= setInterval(() => void this.removeInactiveSessions(), HLS_CLEANUP_INTERVAL_MS);
this.websocketRepository.serverSend('HlsSessionResult', { sessionId });
} catch (error) {
// If insertion failed due to a PK constraint, another worker has already created a session for this ID.
if (!isVideoStreamSessionPkConstraint(error)) {
this.logger.error(`Failed to create HLS session ${sessionId}: ${error}`);
this.websocketRepository.serverSend('HlsSessionResult', { sessionId, error: 'Failed to create HLS session' });
}
}
}
@OnEvent({ name: 'HlsSessionEnd', server: true, workers: [ImmichWorker.Microservices] })
async onSessionEnd({ sessionId }: ArgOf<'HlsSessionEnd'>) {
const session = this.sessions.get(sessionId);
if (!session) {
return;
}
this.sessions.delete(sessionId);
if (this.cleanupInterval && this.sessions.size === 0) {
clearInterval(this.cleanupInterval);
this.cleanupInterval = null;
}
this.stopTranscode(session);
await this.removeSessionDir(session);
await this.videoStreamRepository.deleteSession(sessionId);
}
@OnEvent({ name: 'HlsHeartbeat', server: true, workers: [ImmichWorker.Microservices] })
async onHeartbeat({ sessionId, segmentIndex }: ArgOf<'HlsHeartbeat'>) {
const session = this.sessions.get(sessionId);
if (!session) {
return;
}
session.lastActivityTime = new Date();
if (segmentIndex !== undefined) {
session.lastClientRequestedSegment = segmentIndex;
this.applyBackpressure(session);
}
const remaining = session.expiresAt.getTime() - Date.now();
if (remaining < HLS_LEASE_DURATION_MS / 2) {
session.expiresAt = new Date(Date.now() + HLS_LEASE_DURATION_MS);
await this.videoStreamRepository.extendSession(sessionId, session.expiresAt);
}
}
@OnEvent({ name: 'HlsSegmentRequest', server: true, workers: [ImmichWorker.Microservices] })
async onSegmentRequest({ sessionId, variantIndex, segmentIndex }: ArgOf<'HlsSegmentRequest'>) {
const session = this.sessions.get(sessionId);
if (!session) {
return;
}
session.variantIndex ??= variantIndex;
session.startSegment ??= segmentIndex;
const curSegment = session.lastCompletedSegment === null ? session.startSegment : session.lastCompletedSegment + 1;
const needsRestart =
session.variantIndex !== variantIndex || segmentIndex < session.startSegment || segmentIndex > curSegment + 1;
if (needsRestart) {
this.stopTranscode(session);
session.variantIndex = variantIndex;
session.startSegment = segmentIndex;
} else if (session.process) {
this.resumeTranscode(session);
return;
}
const process = await this.startTranscode(session, variantIndex, segmentIndex);
if (process) {
session.process = process;
}
}
private applyBackpressure(session: Session) {
if (session.lastCompletedSegment === null || session.lastClientRequestedSegment === null) {
return;
}
const lead = session.lastCompletedSegment - session.lastClientRequestedSegment;
this.logger.debug(`Session ${session.id} lead is ${lead} segments`);
if (!session.paused && lead > HLS_BACKPRESSURE_PAUSE_SEGMENTS) {
this.pauseTranscode(session);
} else if (session.paused && lead < HLS_BACKPRESSURE_RESUME_SEGMENTS) {
this.resumeTranscode(session);
}
}
private async startTranscode(session: Session, variantIndex: number, startSegment: number) {
const { ffmpeg } = await this.getConfig({ withCache: true });
const asset = await this.videoStreamRepository.getForTranscoding(session.assetId);
if (!asset) {
this.logger.error(`Asset ${session.assetId} not found for HLS transcoding`);
return;
}
if (session.variantIndex !== variantIndex || session.startSegment !== startSegment) {
return;
}
const variant = HLS_VARIANTS[variantIndex];
if (!variant) {
this.logger.error(`Variant ${variantIndex} out of range for asset ${session.assetId}`);
await this.failSession(session, `Invalid variant index ${variantIndex}`);
return;
}
const variantDir = StorageCore.getHlsVariantFolder({
ownerId: session.ownerId,
sessionId: session.id,
variantIndex,
});
this.storageRepository.mkdirSync(variantDir);
// Encoder runs at fps = packetCount × timeBase / totalDuration with
// gop = ceil(SEGMENT_DURATION × fps). To start segment K's content at
// exactly cfr slot K × gop, seek to the midpoint between slots K×gop1 and
// K×gop. accurate_seek's "discard < target" then keeps the source frame
// that quantizes to slot K×gop and discards the one quantizing to K×gop1.
const fps = (asset.packets.packetCount * asset.videoStream.timeBase) / asset.packets.totalDuration;
const gop = Math.ceil(HLS_SEGMENT_DURATION * fps);
const seekSeconds = startSegment > 0 ? (startSegment * gop - 0.5) / fps : 0;
let config;
try {
config = BaseConfig.create(
{
...ffmpeg,
targetVideoCodec: variant.codec,
targetResolution: String(variant.resolution),
maxBitrate: `${Math.round(variant.bitrate / 1000)}k`,
gopSize: gop,
},
this.videoInterfaces,
{ strictGop: true, lowLatency: true },
);
} catch (error: any) {
this.logger.error(
`Failed to create transcode config for variant ${variantIndex} asset ${session.assetId}: ${error?.message ?? error}`,
);
await this.failSession(session, `Failed to start transcode: ${error?.message ?? 'unknown error'}`);
return;
}
const args = config.getHlsCommand(
{
initFilename: 'init.mp4',
inputPath: asset.originalPath,
packetCount: asset.packets.packetCount,
playlistFilename: join(variantDir, 'playlist.m3u8'),
seekSeconds,
segmentDuration: HLS_SEGMENT_DURATION,
segmentFilename: join(variantDir, 'seg_%d.m4s'),
startSegment,
target: TranscodeTarget.All,
timeBase: asset.videoStream.timeBase,
totalDuration: asset.packets.totalDuration,
},
asset.videoStream,
asset.audioStream ?? undefined,
);
this.logger.log(
`Starting HLS transcode for asset ${session.assetId} variant ${variantIndex} with command: ffmpeg ${args.join(' ')}`,
);
const process = this.processRepository.spawn('ffmpeg', args, { stdio: ['ignore', 'ignore', 'pipe'] });
this.attachProcessHandlers(process, session, variantIndex);
return process;
}
private failSession(session: Session, error: string) {
this.websocketRepository.serverSend('HlsSessionResult', { sessionId: session.id, error });
return this.onSessionEnd({ sessionId: session.id });
}
private attachProcessHandlers(process: ChildProcess, session: Session, variantIndex: number) {
let stderr = '';
const variantDir = StorageCore.getHlsVariantFolder({
ownerId: session.ownerId,
sessionId: session.id,
variantIndex,
});
// hlsenc writes each segment as `seg_K.m4s.tmp` then renames to
// `seg_K.m4s`. The rename event fires the moment the renamed file is
// observable — the only signal we need to tell the API worker the
// segment is ready to serve.
const watcher = this.storageRepository.watchDir(variantDir, (eventType, filename) => {
if (eventType !== 'rename' || !filename || session.process !== process) {
return;
}
const match = HLS_SEGMENT_FILENAME_REGEX.exec(filename);
if (!match) {
return;
}
const segmentIndex = Number.parseInt(match[1]);
const expected = session.lastCompletedSegment === null ? session.startSegment : session.lastCompletedSegment + 1;
// Ignore stale events from old process after seek
if (expected === null || segmentIndex !== expected) {
return;
}
session.lastCompletedSegment = segmentIndex;
this.websocketRepository.serverSend('HlsSegmentResult', {
sessionId: session.id,
variantIndex,
segmentIndex,
});
this.applyBackpressure(session);
});
watcher.on('error', (error) => {
this.logger.error(`watcher error for ${variantDir}: ${error}`);
});
process.stderr!.on('data', (chunk: Buffer) => {
if (session.process !== process) {
return;
}
stderr += chunk.toString();
});
process.on('exit', (code) => {
watcher.close();
if (session.process !== process || session.variantIndex !== variantIndex) {
return;
}
session.paused = false;
session.process = null;
session.lastCompletedSegment = null;
if (code) {
this.logger.error(
`FFmpeg exited with code ${code} for variant ${variantIndex} asset ${session.assetId}\n${stderr}`,
);
void this.failSession(session, `Transcoding process exited unexpectedly with code ${code}`).catch((error) =>
this.logger.error(`Failed to end session ${session.id} after ffmpeg exit: ${error}`),
);
}
});
}
private stopTranscode(session: Session) {
if (!session.process) {
return;
}
// SIGTERM makes it rename .tmp segments to .m4s even if they're still incomplete
session.process.kill('SIGKILL');
session.process = null;
session.lastCompletedSegment = null;
session.paused = false;
this.logger.debug(`Stopped transcoding for session ${session.id}`);
}
private pauseTranscode(session: Session) {
if (session.paused || !session.process) {
return;
}
session.process.kill('SIGSTOP');
session.paused = true;
this.logger.debug(`Paused transcoding for session ${session.id}`);
}
private resumeTranscode(session: Session) {
if (!session.paused || !session.process) {
return;
}
session.process.kill('SIGCONT');
session.paused = false;
this.logger.debug(`Resumed transcoding for session ${session.id}`);
}
private async removeSessionDir(session: { ownerId: string; id: string }) {
const dir = StorageCore.getHlsSessionFolder({ ownerId: session.ownerId, sessionId: session.id });
try {
await this.storageRepository.unlinkDir(dir, { recursive: true, force: true });
} catch (error) {
if ((error as NodeJS.ErrnoException)?.code !== 'ENOENT') {
throw error;
}
this.logger.warn(`Session dir ${dir} does not exist.`);
}
}
private removeInactiveSessions() {
const cutoff = Date.now() - HLS_INACTIVITY_TIMEOUT_MS;
const inactiveSessions = [...this.sessions.values()].filter((s) => s.lastActivityTime.getTime() < cutoff);
return Promise.all(
inactiveSessions.map(async (session) => {
try {
this.websocketRepository.serverSend('HlsSessionEnd', { sessionId: session.id });
await this.onSessionEnd({ sessionId: session.id });
} catch (error) {
this.logger.error(`Failed to sweep inactive HLS session ${session.id}: ${error}`);
}
}),
);
}
private removeExpiredSessions() {
return this.databaseRepository.withLock(DatabaseLock.HlsSessionCleanup, async () => {
const expiredSessions = await this.videoStreamRepository.getExpiredSessions();
await Promise.all(
expiredSessions.map(async (session) => {
await this.removeSessionDir(session);
await this.videoStreamRepository.deleteSession(session.id);
}),
);
});
}
}
+40 -12
View File
@@ -2,6 +2,7 @@ import { DateTime } from 'luxon';
import { SemVer } from 'semver';
import { defaults } from 'src/config';
import { serverVersion } from 'src/constants';
import { ReleaseChannel } from 'src/dtos/system-config.dto';
import { CronJob, JobName, JobStatus, SystemMetadataKey } from 'src/enum';
import { VersionService } from 'src/services/version.service';
import { factory } from 'test/small.factory';
@@ -22,6 +23,17 @@ describe(VersionService.name, () => {
mocks.cron.update.mockResolvedValue();
});
beforeAll(() => {
vitest.mock(import('src/constants.js'), async () => ({
...(await vitest.importActual<typeof import('src/constants.js')>('src/constants.js')),
serverVersion: new SemVer('v3.0.0'),
}));
});
afterAll(() => {
vitest.unmock(import('src/constants.js'));
});
it('should work', () => {
expect(sut).toBeDefined();
});
@@ -66,9 +78,10 @@ describe(VersionService.name, () => {
describe('getVersion', () => {
it('should respond the server version', () => {
expect(sut.getVersion()).toEqual({
major: serverVersion.major,
minor: serverVersion.minor,
patch: serverVersion.patch,
major: 3,
minor: 0,
patch: 0,
prerelease: null,
});
});
});
@@ -143,24 +156,24 @@ describe(VersionService.name, () => {
describe('onConfigUpdate', () => {
it('should queue a version check job when newVersionCheck is enabled', async () => {
await sut.onConfigUpdate({
oldConfig: { ...defaults, newVersionCheck: { enabled: false } },
newConfig: { ...defaults, newVersionCheck: { enabled: true } },
oldConfig: { ...defaults, newVersionCheck: { enabled: false, channel: ReleaseChannel.Stable } },
newConfig: { ...defaults, newVersionCheck: { enabled: true, channel: ReleaseChannel.Stable } },
});
expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.VersionCheck, data: {} });
});
it('should not queue a version check job when newVersionCheck is disabled', async () => {
await sut.onConfigUpdate({
oldConfig: { ...defaults, newVersionCheck: { enabled: true } },
newConfig: { ...defaults, newVersionCheck: { enabled: false } },
oldConfig: { ...defaults, newVersionCheck: { enabled: true, channel: ReleaseChannel.Stable } },
newConfig: { ...defaults, newVersionCheck: { enabled: false, channel: ReleaseChannel.Stable } },
});
expect(mocks.job.queue).not.toHaveBeenCalled();
});
it('should not queue a version check job when newVersionCheck was already enabled', async () => {
await sut.onConfigUpdate({
oldConfig: { ...defaults, newVersionCheck: { enabled: true } },
newConfig: { ...defaults, newVersionCheck: { enabled: true } },
oldConfig: { ...defaults, newVersionCheck: { enabled: true, channel: ReleaseChannel.Stable } },
newConfig: { ...defaults, newVersionCheck: { enabled: true, channel: ReleaseChannel.Stable } },
});
expect(mocks.job.queue).not.toHaveBeenCalled();
});
@@ -169,21 +182,36 @@ describe(VersionService.name, () => {
describe('onWebsocketConnection', () => {
it('should send on_server_version client event', async () => {
await sut.onWebsocketConnection({ userId: '42' });
expect(mocks.websocket.clientSend).toHaveBeenCalledWith('on_server_version', '42', expect.any(SemVer));
expect(mocks.websocket.clientSend).toHaveBeenCalledWith('on_server_version', '42', {
major: 3,
minor: 0,
patch: 0,
prerelease: null,
});
expect(mocks.websocket.clientSend).toHaveBeenCalledTimes(1);
});
it('should also send a new release notification', async () => {
mocks.systemMetadata.get.mockResolvedValue({ checkedAt: '2024-01-01', releaseVersion: 'v1.42.0' });
await sut.onWebsocketConnection({ userId: '42' });
expect(mocks.websocket.clientSend).toHaveBeenCalledWith('on_server_version', '42', expect.any(SemVer));
expect(mocks.websocket.clientSend).toHaveBeenCalledWith('on_server_version', '42', {
major: 3,
minor: 0,
patch: 0,
prerelease: null,
});
expect(mocks.websocket.clientSend).toHaveBeenCalledWith('on_new_release', '42', expect.any(Object));
});
it('should not send a release notification when the version check is disabled', async () => {
mocks.systemMetadata.get.mockResolvedValueOnce({ newVersionCheck: { enabled: false } });
await sut.onWebsocketConnection({ userId: '42' });
expect(mocks.websocket.clientSend).toHaveBeenCalledWith('on_server_version', '42', expect.any(SemVer));
expect(mocks.websocket.clientSend).toHaveBeenCalledWith('on_server_version', '42', {
major: 3,
minor: 0,
patch: 0,
prerelease: null,
});
expect(mocks.websocket.clientSend).not.toHaveBeenCalledWith('on_new_release', '42', expect.any(Object));
});
});
+27 -8
View File
@@ -3,19 +3,27 @@ import { DateTime } from 'luxon';
import semver, { SemVer } from 'semver';
import { serverVersion } from 'src/constants';
import { OnEvent, OnJob } from 'src/decorators';
import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.dto';
import { ReleaseEventV1, ReleaseType, ServerVersionResponseDto } from 'src/dtos/server.dto';
import { ReleaseChannel } from 'src/dtos/system-config.dto';
import { CronJob, DatabaseLock, ImmichWorker, JobName, JobStatus, QueueName, SystemMetadataKey } from 'src/enum';
import { ArgOf } from 'src/repositories/event.repository';
import { BaseService } from 'src/services/base.service';
import { VersionCheckMetadata } from 'src/types';
import { handlePromiseError } from 'src/utils/misc';
const asNotification = ({ checkedAt, releaseVersion }: VersionCheckMetadata): ReleaseNotification => {
const asNotification = (
channel: ReleaseChannel,
{ checkedAt, releaseVersion }: VersionCheckMetadata,
): ReleaseEventV1 => {
return {
isAvailable: semver.gt(releaseVersion, serverVersion),
// can't use gt because it's broken for release candidates F https://github.com/npm/node-semver/issues/483
isAvailable: semver.intersects(`>${serverVersion}`, releaseVersion.toString(), {
includePrerelease: channel === ReleaseChannel.ReleaseCandidate,
}),
checkedAt,
serverVersion: ServerVersionResponseDto.fromSemVer(serverVersion),
releaseVersion: ServerVersionResponseDto.fromSemVer(new SemVer(releaseVersion)),
type: semver.diff(serverVersion, releaseVersion) as ReleaseType,
};
};
@@ -98,14 +106,21 @@ export class VersionService extends BaseService {
}
}
const { version: releaseVersion, published_at: publishedAt } = await this.serverInfoRepository.getLatestRelease();
const { version: releaseVersion, published_at: publishedAt } = await this.serverInfoRepository.getLatestRelease(
newVersionCheck.channel,
);
const metadata: VersionCheckMetadata = { checkedAt: DateTime.utc().toISO(), releaseVersion };
await this.systemMetadataRepository.set(SystemMetadataKey.VersionCheckState, metadata);
if (semver.gt(releaseVersion, serverVersion)) {
// can't use gt because it's broken for release candidates F https://github.com/npm/node-semver/issues/483
if (
semver.intersects(`>${serverVersion}`, releaseVersion.toString(), {
includePrerelease: newVersionCheck.channel === ReleaseChannel.ReleaseCandidate,
})
) {
this.logger.log(`Found ${releaseVersion}, released at ${new Date(publishedAt).toLocaleString()}`);
this.websocketRepository.clientBroadcast('on_new_release', asNotification(metadata));
this.websocketRepository.clientBroadcast('on_new_release', asNotification(newVersionCheck.channel, metadata));
}
} catch (error: Error | any) {
this.logger.warn(`Unable to run version check: ${error}\n${error?.stack}`);
@@ -117,7 +132,11 @@ export class VersionService extends BaseService {
@OnEvent({ name: 'WebsocketConnect' })
async onWebsocketConnection({ userId }: ArgOf<'WebsocketConnect'>) {
this.websocketRepository.clientSend('on_server_version', userId, serverVersion);
this.websocketRepository.clientSend(
'on_server_version',
userId,
ServerVersionResponseDto.fromSemVer(serverVersion),
);
const { newVersionCheck } = await this.getConfig({ withCache: true });
if (!newVersionCheck.enabled) {
@@ -126,7 +145,7 @@ export class VersionService extends BaseService {
const metadata = await this.systemMetadataRepository.get(SystemMetadataKey.VersionCheckState);
if (metadata) {
this.websocketRepository.clientSend('on_new_release', userId, asNotification(metadata));
this.websocketRepository.clientSend('on_new_release', userId, asNotification(newVersionCheck.channel, metadata));
}
}
}
+103 -32
View File
@@ -1,10 +1,15 @@
import { CurrentPlugin } from '@extism/extism';
import { WorkflowChanges, WorkflowEventData, WorkflowEventPayload, WorkflowResponse } from '@immich/plugin-sdk';
import {
WorkflowChanges,
WorkflowEventData,
WorkflowEventPayload,
WorkflowResponse,
WorkflowTrigger,
} from '@immich/plugin-sdk';
import { HttpException, UnauthorizedException } from '@nestjs/common';
import _ from 'lodash';
import { join } from 'node:path';
import { OnEvent, OnJob } from 'src/decorators';
import { AlbumsAddAssetsDto } from 'src/dtos/album.dto';
import { DummyValue, OnEvent, OnJob } from 'src/decorators';
import { AlbumsAddAssetsDto, CreateAlbumDto, GetAlbumsDto } from 'src/dtos/album.dto';
import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { PluginManifestDto } from 'src/dtos/plugin-manifest.dto';
@@ -16,11 +21,11 @@ import {
JobName,
JobStatus,
QueueName,
WorkflowTrigger,
WorkflowType,
} from 'src/enum';
import { ArgOf } from 'src/repositories/event.repository';
import { AlbumService } from 'src/services/album.service';
import { AssetService } from 'src/services/asset.service';
import { BaseService } from 'src/services/base.service';
import { JobOf } from 'src/types';
@@ -32,9 +37,11 @@ const dummy = () => {
type ExecuteOptions<T extends WorkflowType> = {
read: (type: T) => Promise<{ authUserId: string; data: WorkflowEventData<T> }>;
write: (changes: WorkflowChanges<T>) => Promise<void>;
write: (auth: AuthDto, changes: WorkflowChanges<T>) => Promise<void>;
};
type AssetTrigger = { userId: string; assetId: string; trigger: WorkflowTrigger };
export class WorkflowExecutionService extends BaseService {
private jwtSecret!: string;
@@ -59,21 +66,26 @@ export class WorkflowExecutionService extends BaseService {
const albumService = BaseService.create(AlbumService, this);
const albumAddAssets = this.wrap<[id: string, dto: BulkIdsDto]>((authDto, args) =>
const searchAlbums = this.wrap<[dto: GetAlbumsDto]>((authDto, args) => albumService.getAll(authDto, ...args));
const createAlbum = this.wrap<[dto: CreateAlbumDto]>((authDto, args) => albumService.create(authDto, ...args));
const addAssetsToAlbum = this.wrap<[id: string, dto: BulkIdsDto]>((authDto, args) =>
albumService.addAssets(authDto, ...args),
);
const addAssetsToAlbums = this.wrap<[dto: AlbumsAddAssetsDto]>((authDto, args) =>
albumService.addAssetsToAlbums(authDto, ...args),
);
const functions = {
albumAddAssets,
searchAlbums,
createAlbum,
addAssetsToAlbum,
addAssetsToAlbums,
};
const stubs = {
albumAddAssets: dummy,
const stubs: typeof functions = {
searchAlbums: dummy,
createAlbum: dummy,
addAssetsToAlbum: dummy,
addAssetsToAlbums: dummy,
};
@@ -247,20 +259,36 @@ export class WorkflowExecutionService extends BaseService {
}
@OnEvent({ name: 'AssetCreate' })
async onAssetCreate({ asset }: ArgOf<'AssetCreate'>) {
const dto = { ownerId: asset.ownerId, trigger: WorkflowTrigger.AssetCreate };
const items = await this.workflowRepository.search(dto);
onAssetCreate({ asset: { ownerId: userId, id: assetId } }: ArgOf<'AssetCreate'>) {
return this.onAssetTrigger({ userId, assetId, trigger: WorkflowTrigger.AssetCreate });
}
@OnEvent({ name: 'AssetMetadataExtracted' })
onAssetMetadataExtracted({ userId, assetId, source }: ArgOf<'AssetMetadataExtracted'>) {
// prevent loops
// TODO loop detection in job service directly
if (source === 'sidecar-write') {
return;
}
return this.onAssetTrigger({ userId, assetId, trigger: WorkflowTrigger.AssetMetadataExtraction });
}
private async onAssetTrigger({ userId, assetId, trigger }: AssetTrigger) {
const items = await this.workflowRepository.search({ userId, trigger });
await this.jobRepository.queueAll(
items.map((workflow) => ({
name: JobName.WorkflowAssetCreate,
data: { workflowId: workflow.id, assetId: asset.id },
name: JobName.WorkflowAssetTrigger,
data: { workflowId: workflow.id, assetId, trigger },
})),
);
}
@OnJob({ name: JobName.WorkflowAssetCreate, queue: QueueName.Workflow })
handleAssetCreate({ workflowId, assetId }: JobOf<JobName.WorkflowAssetCreate>) {
@OnJob({ name: JobName.WorkflowAssetTrigger, queue: QueueName.Workflow })
handleAssetTrigger({ workflowId, assetId }: JobOf<JobName.WorkflowAssetTrigger>) {
return this.execute(workflowId, (type) => {
const assetService = BaseService.create(AssetService, this);
switch (type) {
case WorkflowType.AssetV1: {
return {
@@ -271,19 +299,35 @@ export class WorkflowExecutionService extends BaseService {
authUserId: asset.ownerId,
};
},
write: async (changes) => {
if (changes.asset) {
await this.assetRepository.update({
id: assetId,
..._.omitBy(
{
isFavorite: changes.asset?.isFavorite,
visibility: changes.asset?.visibility,
},
_.isUndefined,
),
});
write: async (auth, changes) => {
const asset = changes.asset;
if (!asset) {
return;
}
await assetService.update(auth, assetId, {
isFavorite: asset.isFavorite,
visibility: asset.visibility,
dateTimeOriginal: asset.exifInfo?.dateTimeOriginal ?? undefined,
// TODO allow setting to null
longitude: asset.exifInfo?.longitude ?? undefined,
// TODO allow setting to null
latitude: asset.exifInfo?.latitude ?? undefined,
// TODO allow setting to null
description: asset.exifInfo?.description ?? undefined,
rating: asset.exifInfo?.rating,
// TODO add to update dto
// make: asset.exifInfo?.make,
// model: asset.exifInfo?.model,
// city: asset.exifInfo?.city,
// state: asset.exifInfo?.state,
// country: asset.exifInfo?.country,
// lensModel: asset.exifInfo?.lensModel,
// fNumber: asset.exifInfo?.fNumber,
// fps: asset.exifInfo?.fps,
// iso: asset.exifInfo?.iso,
});
},
} satisfies ExecuteOptions<typeof type>;
}
@@ -301,7 +345,19 @@ export class WorkflowExecutionService extends BaseService {
}
// TODO infer from steps
const type = 'AssetV1' as T;
let type: T | undefined;
for (const targetType of Object.values(WorkflowType)) {
const missing = workflow.steps.some((step) => !step.types.includes(targetType));
if (!missing) {
type = targetType as unknown as T;
break;
}
}
if (!type) {
throw new Error('Unable to infer workflow event type from steps');
}
const handler = getHandler(type);
if (!handler) {
this.logger.error(`Misconfigured workflow ${workflowId}: no handler for type ${type}`);
@@ -337,10 +393,25 @@ export class WorkflowExecutionService extends BaseService {
payload,
);
if (result?.changes) {
await write(result.changes);
await write(
{
user: {
id: readResult.authUserId,
},
session: {
id: DummyValue.UUID,
hasElevatedPermission: true,
},
} as AuthDto,
result.changes,
);
({ data } = await read(type));
}
if (result?.config) {
await this.workflowRepository.updateStep(step.id, { config: result.config });
}
const shouldContinue = result?.workflow?.continue ?? true;
if (!shouldContinue) {
break;
+3 -3
View File
@@ -1,4 +1,4 @@
import { WorkflowStepConfig } from '@immich/plugin-sdk';
import { WorkflowStepConfig, WorkflowTrigger } from '@immich/plugin-sdk';
import { BadRequestException, Injectable } from '@nestjs/common';
import { AuthDto } from 'src/dtos/auth.dto';
import {
@@ -11,7 +11,7 @@ import {
WorkflowTriggerResponseDto,
WorkflowUpdateDto,
} from 'src/dtos/workflow.dto';
import { Permission, WorkflowTrigger } from 'src/enum';
import { Permission } from 'src/enum';
import { PluginMethodSearchResponse } from 'src/repositories/plugin.repository';
import { BaseService } from 'src/services/base.service';
import { getWorkflowTriggers, isMethodCompatible, resolveMethod } from 'src/utils/workflow';
@@ -23,7 +23,7 @@ export class WorkflowService extends BaseService {
}
async search(auth: AuthDto, dto: WorkflowSearchDto): Promise<WorkflowResponseDto[]> {
const workflows = await this.workflowRepository.search({ ...dto, ownerId: auth.user.id });
const workflows = await this.workflowRepository.search({ ...dto, userId: auth.user.id });
return workflows.map((workflow) => mapWorkflow(workflow));
}