Skip to content

Commit d965cf4

Browse files
committed
app: clipping prototype
1 parent 2504274 commit d965cf4

File tree

15 files changed

+916
-44
lines changed

15 files changed

+916
-44
lines changed

api/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
"express": "^4.21.2",
3333
"express-rate-limit": "^7.4.1",
3434
"ffmpeg-static": "^5.1.0",
35+
"ffprobe-static": "^3.1.0",
3536
"hls-parser": "^0.10.7",
3637
"ipaddr.js": "2.2.0",
3738
"mime": "^4.0.4",

api/src/core/api.js

Lines changed: 60 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -278,44 +278,71 @@ export const runAPI = async (express, app, __dirname, isPrimary = true) => {
278278

279279
app.post('/metadata', apiLimiter);
280280
app.post('/metadata', async (req, res) => {
281-
const request = req.body;
282-
if (!request.url) {
283-
return fail(res, "error.api.link.missing");
284-
}
285-
const { success, data: normalizedRequest } = await normalizeRequest(request);
286-
if (!success) {
287-
return fail(res, "error.api.invalid_body");
288-
}
289-
const parsed = extract(
290-
normalizedRequest.url,
291-
APIKeys.getAllowedServices(req.rateLimitKey),
292-
);
293-
if (!parsed) {
294-
return fail(res, "error.api.link.invalid");
295-
}
296-
if ("error" in parsed) {
297-
let context;
298-
if (parsed?.context) {
299-
context = parsed.context;
281+
try {
282+
const request = req.body;
283+
284+
if (!request.url) {
285+
return fail(res, "error.api.link.missing");
286+
}
287+
288+
const { success, data: normalizedRequest } = await normalizeRequest(request);
289+
if (!success) {
290+
return fail(res, "error.api.invalid_body");
291+
}
292+
293+
const parsed = extract(
294+
normalizedRequest.url,
295+
APIKeys.getAllowedServices(req.rateLimitKey),
296+
);
297+
298+
if (!parsed) {
299+
return fail(res, "error.api.link.invalid");
300+
}
301+
302+
if ("error" in parsed) {
303+
let context;
304+
if (parsed?.context) {
305+
context = parsed.context;
306+
}
307+
return fail(res, `error.api.${parsed.error}`, context);
308+
}
309+
310+
if (parsed.host !== "youtube") {
311+
return res.status(501).json({
312+
status: "error",
313+
code: "not_implemented",
314+
message: "Metadata endpoint is only implemented for YouTube."
315+
});
300316
}
301-
return fail(res, `error.api.${parsed.error}`, context);
302-
}
303317

304-
if (parsed.host === "youtube") {
305318
const youtube = (await import("../processing/services/youtube.js")).default;
306-
const info = await youtube({ id: parsed.patternMatch.id });
307-
if (info.error) {
308-
return fail(res, info.error);
319+
320+
const fetchInfo = {
321+
id: parsed.patternMatch.id.slice(0, 11),
322+
metadataOnly: true,
323+
};
324+
325+
const result = await youtube(fetchInfo);
326+
327+
if (result.error) {
328+
return fail(res, `error.api.${result.error}`);
309329
}
310-
const meta = {
311-
title: info.fileMetadata?.title,
312-
duration: info.duration || null,
313-
thumbnail: info.cover || null,
314-
author: info.fileMetadata?.artist || null,
330+
331+
const metadata = {
332+
title: result.fileMetadata?.title || null,
333+
author: result.fileMetadata?.artist || null,
334+
duration: result.duration || null,
335+
thumbnail: result.cover || null,
315336
};
316-
return res.json({ status: "success", metadata: meta });
317-
} else {
318-
return res.status(501).json({ status: "error", code: "not_implemented", message: "Metadata endpoint is only implemented for YouTube." });
337+
338+
return res.json({
339+
status: "success",
340+
metadata: metadata
341+
});
342+
343+
} catch (error) {
344+
console.error('Metadata endpoint error:', error);
345+
return fail(res, "error.api.generic");
319346
}
320347
});
321348

api/src/processing/match-action.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ export default function({
3636
subtitles: r.subtitles,
3737
cover: !disableMetadata ? r.cover : false,
3838
cropCover: !disableMetadata ? r.cropCover : false,
39+
clipStart: r.clipStart,
40+
clipEnd: r.clipEnd,
3941
},
4042
params = {};
4143

api/src/processing/match.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,21 @@ export default async function({ host, patternMatch, params, authType }) {
123123
subtitleLang,
124124
}
125125

126+
if (typeof params.clipStart === 'number') {
127+
fetchInfo.clipStart = params.clipStart;
128+
}
129+
if (typeof params.clipEnd === 'number') {
130+
fetchInfo.clipEnd = params.clipEnd;
131+
}
132+
133+
if (fetchInfo.clipStart !== undefined && fetchInfo.clipEnd !== undefined) {
134+
if (fetchInfo.clipStart >= fetchInfo.clipEnd) {
135+
return createResponse("error", {
136+
code: "error.api.clip.invalid_range"
137+
});
138+
}
139+
}
140+
126141
if (url.hostname === "music.youtube.com" || isAudioOnly) {
127142
fetchInfo.quality = "1080";
128143
fetchInfo.codec = "vp9";

api/src/processing/schema.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,5 +60,8 @@ export const apiSchema = z.object({
6060

6161
youtubeHLS: z.boolean().default(false),
6262
youtubeBetterAudio: z.boolean().default(false),
63+
64+
clipStart: z.number().min(0).optional(),
65+
clipEnd: z.number().min(0).optional(),
6366
})
6467
.strict();

api/src/processing/services/youtube.js

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,13 @@ export default async function (o) {
291291
return { error: "content.too_long" };
292292
}
293293

294+
if (typeof o.clipStart === 'number' && o.clipStart >= basicInfo.duration) {
295+
return { error: "clip.start_exceeds_duration" };
296+
}
297+
if (typeof o.clipEnd === 'number' && o.clipEnd > basicInfo.duration) {
298+
return { error: "clip.end_exceeds_duration" };
299+
}
300+
294301
// return a critical error if returned video is "Video Not Available"
295302
// or a similar stub by youtube
296303
if (basicInfo.id !== o.id) {
@@ -495,6 +502,27 @@ export default async function (o) {
495502
}
496503
}
497504

505+
if (o.metadataOnly) {
506+
let cover = `https://i.ytimg.com/vi/${o.id}/maxresdefault.jpg`;
507+
try {
508+
const testMaxCover = await fetch(cover, { dispatcher: o.dispatcher })
509+
.then(r => r.status === 200)
510+
.catch(() => false);
511+
512+
if (!testMaxCover) {
513+
cover = basicInfo.thumbnail?.[0]?.url || null;
514+
}
515+
} catch {
516+
cover = basicInfo.thumbnail?.[0]?.url || null;
517+
}
518+
519+
return {
520+
fileMetadata,
521+
duration: basicInfo.duration,
522+
cover,
523+
};
524+
}
525+
498526
if (subtitles) {
499527
fileMetadata.sublanguage = subtitles.language;
500528
}
@@ -553,6 +581,8 @@ export default async function (o) {
553581

554582
cover,
555583
cropCover: basicInfo.author.endsWith("- Topic"),
584+
clipStart: o.clipStart,
585+
clipEnd: o.clipEnd,
556586
}
557587
}
558588

@@ -599,6 +629,8 @@ export default async function (o) {
599629
isHLS: useHLS,
600630
originalRequest,
601631
duration: basicInfo.duration,
632+
clipStart: o.clipStart,
633+
clipEnd: o.clipEnd,
602634
}
603635
}
604636

api/src/stream/ffmpeg.js

Lines changed: 36 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -99,9 +99,18 @@ const render = async (res, streamInfo, ffargs, estimateMultiplier) => {
9999
const remux = async (streamInfo, res) => {
100100
const format = streamInfo.filename.split('.').pop();
101101
const urls = Array.isArray(streamInfo.urls) ? streamInfo.urls : [streamInfo.urls];
102-
const args = urls.flatMap(url => ['-i', url]);
102+
const isClipping = typeof streamInfo.clipStart === 'number' || typeof streamInfo.clipEnd === 'number';
103+
let args = [];
104+
105+
args.push(...urls.flatMap(url => ['-i', url]));
106+
107+
if (typeof streamInfo.clipStart === 'number') {
108+
args.push('-ss', streamInfo.clipStart.toString());
109+
}
110+
if (typeof streamInfo.clipEnd === 'number') {
111+
args.push('-to', streamInfo.clipEnd.toString());
112+
}
103113

104-
// if the stream type is merge, we expect two URLs
105114
if (streamInfo.type === 'merge' && urls.length !== 2) {
106115
return closeResponse(res);
107116
}
@@ -126,10 +135,19 @@ const remux = async (streamInfo, res) => {
126135
);
127136
}
128137

129-
args.push(
130-
'-c:v', 'copy',
131-
...(streamInfo.type === 'mute' ? ['-an'] : ['-c:a', 'copy'])
132-
);
138+
if (isClipping) {
139+
const vcodec = format === 'webm' ? 'libvpx-vp9' : 'libx264';
140+
const acodec = format === 'webm' ? 'libopus' : 'aac';
141+
args.push(
142+
'-c:v', vcodec,
143+
...(streamInfo.type === 'mute' ? ['-an'] : ['-c:a', acodec])
144+
);
145+
} else {
146+
args.push(
147+
'-c:v', 'copy',
148+
...(streamInfo.type === 'mute' ? ['-an'] : ['-c:a', 'copy'])
149+
);
150+
}
133151

134152
if (format === 'mp4') {
135153
args.push('-movflags', 'faststart+frag_keyframe+empty_moov');
@@ -149,15 +167,24 @@ const remux = async (streamInfo, res) => {
149167

150168
args.push('-f', format === 'mkv' ? 'matroska' : format, 'pipe:3');
151169

152-
await render(res, streamInfo, args);
170+
await render(res, streamInfo, args, estimateAudioMultiplier(streamInfo) * 1.1);
153171
}
154172

155173
const convertAudio = async (streamInfo, res) => {
156-
const args = [
174+
let args = [];
175+
176+
args.push(
157177
'-i', streamInfo.urls,
158178
'-vn',
159179
...(streamInfo.audioCopy ? ['-c:a', 'copy'] : ['-b:a', `${streamInfo.audioBitrate}k`]),
160-
];
180+
);
181+
182+
if (typeof streamInfo.clipStart === 'number') {
183+
args.push('-ss', streamInfo.clipStart.toString());
184+
}
185+
if (typeof streamInfo.clipEnd === 'number') {
186+
args.push('-to', streamInfo.clipEnd.toString());
187+
}
161188

162189
if (streamInfo.audioFormat === 'mp3' && streamInfo.audioBitrate === '8') {
163190
args.push('-ar', '12000');

api/src/stream/manage.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@ export function createStream(obj) {
4545

4646
// url to a subtitle file
4747
subtitles: obj.subtitles,
48+
49+
clipStart: obj.clipStart,
50+
clipEnd: obj.clipEnd,
4851
};
4952

5053
// FIXME: this is now a Promise, but it is not awaited

pnpm-lock.yaml

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)