diff --git a/api/src/core/api.js b/api/src/core/api.js index 248f9357..15a6350f 100644 --- a/api/src/core/api.js +++ b/api/src/core/api.js @@ -276,6 +276,76 @@ export const runAPI = async (express, app, __dirname, isPrimary = true) => { } }); + app.post('/metadata', apiLimiter); + app.post('/metadata', async (req, res) => { + try { + const request = req.body; + + if (!request.url) { + return fail(res, "error.api.link.missing"); + } + + const { success, data: normalizedRequest } = await normalizeRequest(request); + if (!success) { + return fail(res, "error.api.invalid_body"); + } + + const parsed = extract( + normalizedRequest.url, + APIKeys.getAllowedServices(req.rateLimitKey), + ); + + if (!parsed) { + return fail(res, "error.api.link.invalid"); + } + + if ("error" in parsed) { + let context; + if (parsed?.context) { + context = parsed.context; + } + return fail(res, `error.api.${parsed.error}`, context); + } + + if (parsed.host !== "youtube") { + return res.status(501).json({ + status: "error", + code: "not_implemented", + message: "Metadata endpoint is only implemented for YouTube." + }); + } + + const youtube = (await import("../processing/services/youtube.js")).default; + + const fetchInfo = { + id: parsed.patternMatch.id.slice(0, 11), + metadataOnly: true, + }; + + const result = await youtube(fetchInfo); + + if (result.error) { + return fail(res, `error.api.${result.error}`); + } + + const metadata = { + title: result.fileMetadata?.title || null, + author: result.fileMetadata?.artist || null, + duration: result.duration || null, + thumbnail: result.cover || null, + }; + + return res.json({ + status: "success", + metadata: metadata + }); + + } catch (error) { + console.error('Metadata endpoint error:', error); + return fail(res, "error.api.generic"); + } + }); + app.use('/tunnel', cors({ methods: ['GET'], exposedHeaders: [ diff --git a/api/src/processing/match-action.js b/api/src/processing/match-action.js index 6d1b0254..371c7bf6 100644 --- a/api/src/processing/match-action.js +++ b/api/src/processing/match-action.js @@ -36,6 +36,8 @@ export default function({ subtitles: r.subtitles, cover: !disableMetadata ? r.cover : false, cropCover: !disableMetadata ? r.cropCover : false, + clipStart: r.clipStart, + clipEnd: r.clipEnd, }, params = {}; diff --git a/api/src/processing/match.js b/api/src/processing/match.js index 360ed532..5dc4f0e0 100644 --- a/api/src/processing/match.js +++ b/api/src/processing/match.js @@ -123,6 +123,21 @@ export default async function({ host, patternMatch, params, authType }) { subtitleLang, } + if (typeof params.clipStart === 'number') { + fetchInfo.clipStart = params.clipStart; + } + if (typeof params.clipEnd === 'number') { + fetchInfo.clipEnd = params.clipEnd; + } + + if (fetchInfo.clipStart !== undefined && fetchInfo.clipEnd !== undefined) { + if (fetchInfo.clipStart >= fetchInfo.clipEnd) { + return createResponse("error", { + code: "error.api.clip.invalid_range" + }); + } + } + if (url.hostname === "music.youtube.com" || isAudioOnly) { fetchInfo.quality = "1080"; fetchInfo.codec = "vp9"; @@ -315,11 +330,17 @@ export default async function({ host, patternMatch, params, authType }) { const lpEnv = env.forceLocalProcessing; const shouldForceLocal = lpEnv === "always" || (lpEnv === "session" && authType === "session"); const localDisabled = (!localProcessing || localProcessing === "none"); + const isClip = typeof params.clipStart === 'number' && typeof params.clipEnd === 'number'; if (shouldForceLocal && localDisabled) { localProcessing = "preferred"; } + if (isClip) { + r.clipStart = params.clipStart; + r.clipEnd = params.clipEnd; + } + return matchAction({ r, host, diff --git a/api/src/processing/schema.js b/api/src/processing/schema.js index 7570a8b0..7863e333 100644 --- a/api/src/processing/schema.js +++ b/api/src/processing/schema.js @@ -60,5 +60,8 @@ export const apiSchema = z.object({ youtubeHLS: z.boolean().default(false), youtubeBetterAudio: z.boolean().default(false), + + clipStart: z.number().min(0).optional(), + clipEnd: z.number().min(0).optional(), }) .strict(); diff --git a/api/src/processing/services/youtube.js b/api/src/processing/services/youtube.js index dbf93bb8..9fbc5cef 100644 --- a/api/src/processing/services/youtube.js +++ b/api/src/processing/services/youtube.js @@ -495,6 +495,27 @@ export default async function (o) { } } + if (o.metadataOnly) { + let cover = `https://i.ytimg.com/vi/${o.id}/maxresdefault.jpg`; + try { + const testMaxCover = await fetch(cover, { dispatcher: o.dispatcher }) + .then(r => r.status === 200) + .catch(() => false); + + if (!testMaxCover) { + cover = basicInfo.thumbnail?.[0]?.url || null; + } + } catch { + cover = basicInfo.thumbnail?.[0]?.url || null; + } + + return { + fileMetadata, + duration: basicInfo.duration, + cover, + }; + } + if (subtitles) { fileMetadata.sublanguage = subtitles.language; } @@ -597,7 +618,8 @@ export default async function (o) { filenameAttributes, fileMetadata, isHLS: useHLS, - originalRequest + originalRequest, + duration: basicInfo.duration } } diff --git a/api/src/stream/ffmpeg.js b/api/src/stream/ffmpeg.js index 4a72a309..2899eca0 100644 --- a/api/src/stream/ffmpeg.js +++ b/api/src/stream/ffmpeg.js @@ -99,8 +99,16 @@ const render = async (res, streamInfo, ffargs, estimateMultiplier) => { const remux = async (streamInfo, res) => { const format = streamInfo.filename.split('.').pop(); const urls = Array.isArray(streamInfo.urls) ? streamInfo.urls : [streamInfo.urls]; + const isClipping = typeof streamInfo.clipStart === 'number' || typeof streamInfo.clipEnd === 'number'; const args = urls.flatMap(url => ['-i', url]); + if (typeof streamInfo.clipStart === 'number') { + args.push('-ss', streamInfo.clipStart.toString()); + } + if (typeof streamInfo.clipEnd === 'number') { + args.push('-to', streamInfo.clipEnd.toString()); + } + // if the stream type is merge, we expect two URLs if (streamInfo.type === 'merge' && urls.length !== 2) { return closeResponse(res); @@ -126,10 +134,19 @@ const remux = async (streamInfo, res) => { ); } - args.push( - '-c:v', 'copy', - ...(streamInfo.type === 'mute' ? ['-an'] : ['-c:a', 'copy']) - ); + if (isClipping) { + const vcodec = format === 'webm' ? 'libvpx-vp9' : 'libx264'; + const acodec = format === 'webm' ? 'libopus' : 'aac'; + args.push( + '-c:v', vcodec, + ...(streamInfo.type === 'mute' ? ['-an'] : ['-c:a', acodec]) + ); + } else { + args.push( + '-c:v', 'copy', + ...(streamInfo.type === 'mute' ? ['-an'] : ['-c:a', 'copy']) + ); + } if (format === 'mp4') { args.push('-movflags', 'faststart+frag_keyframe+empty_moov'); @@ -149,7 +166,7 @@ const remux = async (streamInfo, res) => { args.push('-f', format === 'mkv' ? 'matroska' : format, 'pipe:3'); - await render(res, streamInfo, args); + await render(res, streamInfo, args, estimateAudioMultiplier(streamInfo) * 1.1); } const convertAudio = async (streamInfo, res) => { @@ -159,6 +176,13 @@ const convertAudio = async (streamInfo, res) => { ...(streamInfo.audioCopy ? ['-c:a', 'copy'] : ['-b:a', `${streamInfo.audioBitrate}k`]), ]; + if (typeof streamInfo.clipStart === 'number') { + args.push('-ss', streamInfo.clipStart.toString()); + } + if (typeof streamInfo.clipEnd === 'number') { + args.push('-to', streamInfo.clipEnd.toString()); + } + if (streamInfo.audioFormat === 'mp3' && streamInfo.audioBitrate === '8') { args.push('-ar', '12000'); } diff --git a/api/src/stream/manage.js b/api/src/stream/manage.js index 93e9e652..dcd3cb97 100644 --- a/api/src/stream/manage.js +++ b/api/src/stream/manage.js @@ -45,6 +45,9 @@ export function createStream(obj) { // url to a subtitle file subtitles: obj.subtitles, + + clipStart: obj.clipStart, + clipEnd: obj.clipEnd, }; // FIXME: this is now a Promise, but it is not awaited diff --git a/web/src/components/save/ClipControls.svelte b/web/src/components/save/ClipControls.svelte new file mode 100644 index 00000000..d2ffec8b --- /dev/null +++ b/web/src/components/save/ClipControls.svelte @@ -0,0 +1,247 @@ + + +