Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions api/src/core/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down
2 changes: 2 additions & 0 deletions api/src/processing/match-action.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {};

Expand Down
21 changes: 21 additions & 0 deletions api/src/processing/match.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions api/src/processing/schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
24 changes: 23 additions & 1 deletion api/src/processing/services/youtube.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -597,7 +618,8 @@ export default async function (o) {
filenameAttributes,
fileMetadata,
isHLS: useHLS,
originalRequest
originalRequest,
duration: basicInfo.duration
}
}

Expand Down
34 changes: 29 additions & 5 deletions api/src/stream/ffmpeg.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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');
Expand All @@ -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) => {
Expand All @@ -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');
}
Expand Down
3 changes: 3 additions & 0 deletions api/src/stream/manage.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading