feat!: implement local media libraries (#1406)

Initial implementation of local media libraries. Includes local scanners
for movie and TV library types. Saves extracted metadata locally.

Some things are missing, including:
* Saving all metadata locally, including genres, actors, etc.
* blurhash extraction - this is computationally expensive at scale and
  should be done async
* Hooking up subtitle extraction to new subtitle DB tables
This commit is contained in:
Christian Benincasa
2025-10-14 16:41:58 -04:00
committed by GitHub
parent 9a791467df
commit a748408fcc
255 changed files with 33768 additions and 3379 deletions

View File

@@ -1,20 +1,9 @@
import { FfmpegStreamFactory } from '@/ffmpeg/FfmpegStreamFactory.js';
import { MpegTsOutputFormat } from '@/ffmpeg/builder/constants.js';
import { FfprobeStreamDetails } from '@/stream/FfprobeStreamDetails.js';
import type { RouterPluginAsyncCallback } from '@/types/serverType.js';
import { tag } from '@tunarr/types';
import dayjs from 'dayjs';
import { z } from 'zod/v4';
import { container } from '../../container.ts';
import type { ContentBackedStreamLineupItem } from '../../db/derived_types/StreamLineup.ts';
import { isContentBackedLineupItem } from '../../db/derived_types/StreamLineup.ts';
import type { FFmpegFactory } from '../../ffmpeg/FFmpegModule.ts';
import type { FfmpegEncoder } from '../../ffmpeg/ffmpegInfo.ts';
import { FfmpegInfo } from '../../ffmpeg/ffmpegInfo.ts';
import { ExternalStreamDetailsFetcherFactory } from '../../stream/StreamDetailsFetcher.ts';
import type { ProgramStreamResult } from '../../stream/types.ts';
import { KEYS } from '../../types/inject.ts';
import type { Nullable } from '../../types/util.ts';
export const debugFfmpegApiRouter: RouterPluginAsyncCallback = async (
fastify,
@@ -56,118 +45,4 @@ export const debugFfmpegApiRouter: RouterPluginAsyncCallback = async (
),
});
});
fastify.get(
'/ffmpeg/pipeline',
{
schema: {
tags: ['Debug'],
querystring: z.object({
channel: z.coerce.number().or(z.string()),
path: z.string().optional(),
}),
},
},
async (req, res) => {
const channel = await req.serverCtx.channelDB.getChannel(
req.query.channel,
);
if (!channel) {
return res.status(404).send();
}
const transcodeConfig =
await req.serverCtx.transcodeConfigDB.getChannelConfig(channel.uuid);
let streamDetails: Nullable<ProgramStreamResult>;
let lineupItem: ContentBackedStreamLineupItem;
if (req.query.path) {
streamDetails = await container
.get<FfprobeStreamDetails>(FfprobeStreamDetails)
.getStream({ path: req.query.path });
lineupItem = {
duration: +dayjs.duration({ seconds: 30 }),
contentDuration: +dayjs.duration({ seconds: 30 }),
infiniteLoop: false,
streamDuration: +dayjs.duration({ seconds: 30 }),
externalKey: 'none',
externalSource: 'emby',
externalSourceId: tag('none'),
programBeginMs: 0,
programId: '',
programType: 'movie',
type: 'program',
title: req.query.path,
};
} else {
const lineupItemResult =
await req.serverCtx.streamProgramCalculator.getCurrentLineupItem({
allowSkip: false,
channelId: channel.uuid,
startTime: +dayjs(),
});
if (lineupItemResult.isFailure()) {
return res.status(500).send();
}
const item = lineupItemResult.get().lineupItem;
if (!isContentBackedLineupItem(item)) {
return res.status(500).send();
}
const server = await req.serverCtx.mediaSourceDB.getById(
item.externalSourceId,
);
if (!server) {
return res
.status(500)
.send('No server id = ' + item.externalSourceId);
}
lineupItem = item;
streamDetails = await container
.get<ExternalStreamDetailsFetcherFactory>(
ExternalStreamDetailsFetcherFactory,
)
.getStream({
lineupItem: {
...item,
externalFilePath: item.plexFilePath ?? undefined,
},
server,
});
}
if (!streamDetails) {
return res.status(500).send();
}
const ffmpeg = container.getNamed<FFmpegFactory>(
KEYS.FFmpegFactory,
FfmpegStreamFactory.name,
)(transcodeConfig, channel, channel.streamMode);
const session = await ffmpeg.createStreamSession({
stream: {
details: streamDetails.streamDetails,
source: streamDetails.streamSource,
},
lineupItem,
options: {
duration: dayjs.duration({ seconds: 30 }),
outputFormat: MpegTsOutputFormat,
realtime: false,
startTime: dayjs.duration(0),
watermark: channel.watermark ?? undefined,
streamMode: channel.streamMode,
},
});
return res.send({
args: session?.process.args.join(' '),
});
},
);
};