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 @@ + + +
+ {#if metaLoading} +
+
+ Loading video metadata... +
+ {:else if metaError} +
+ + + + + + {metaError} +
+ {:else if metadata} +
+ +
+
+ {metadata.title} +
+ {#if metadata.author} +
+ by {metadata.author} +
+ {/if} +
+
+ + + +
+
+ {formatTime(clipStart)} + + {formatTime(Math.max(0, clipEnd - clipStart))} selected + + {formatTime(clipEnd)} +
+
+ {/if} +
+ + diff --git a/web/src/components/save/ClipRangeSlider.svelte b/web/src/components/save/ClipRangeSlider.svelte new file mode 100644 index 00000000..e272a2f0 --- /dev/null +++ b/web/src/components/save/ClipRangeSlider.svelte @@ -0,0 +1,348 @@ + + +
+
+
+
+
handlePointerDown(e, 'start')} + onkeydown={(e) => handleKeydown(e, 'start')} + onfocus={() => focused = "start"} + onblur={() => focused = null} + > +
+ {Math.floor(start / 60)}:{String(Math.floor(start % 60)).padStart(2, '0')} +
+
+
handlePointerDown(e, 'end')} + onkeydown={(e) => handleKeydown(e, 'end')} + onfocus={() => focused = "end"} + onblur={() => focused = null} + > +
+ {Math.floor(end / 60)}:{String(Math.floor(end % 60)).padStart(2, '0')} +
+
+
+
+ + diff --git a/web/src/components/save/Omnibox.svelte b/web/src/components/save/Omnibox.svelte index f45f54a7..d1fefce1 100644 --- a/web/src/components/save/Omnibox.svelte +++ b/web/src/components/save/Omnibox.svelte @@ -18,6 +18,7 @@ import type { Optional } from "$lib/types/generic"; import type { DownloadModeOption } from "$lib/types/settings"; + import type { CobaltSaveRequestBody } from "$lib/types/api"; import ClearButton from "$components/save/buttons/ClearButton.svelte"; import DownloadButton from "$components/save/buttons/DownloadButton.svelte"; @@ -33,6 +34,11 @@ import IconSparkles from "$components/icons/Sparkles.svelte"; import IconClipboard from "$components/icons/Clipboard.svelte"; + import API from "$lib/api/api"; + import ClipRangeSlider from "./ClipRangeSlider.svelte"; + import ClipCheckbox from "./buttons/ClipCheckbox.svelte"; + import ClipControls from "./ClipControls.svelte"; + let linkInput: Optional; const validLink = (url: string) => { @@ -58,6 +64,40 @@ let downloadable = $derived(validLink($link)); let clearVisible = $derived($link && !isLoading); + let clipMode = $state(false); + let metadata: { title?: string; author?: string; duration?: number } | null = $state(null); + let metaLoading = $state(false); + let metaError: string | null = $state(null); + let clipStart = $state(0); + let clipEnd = $state(0); + + async function fetchMetadata() { + if (!validLink($link)) return; + metaLoading = true; + metaError = null; + metadata = null; + try { + const res = await API.getMetadata($link); + if (res.status === "success") { + metadata = res.metadata; + clipStart = 0; + clipEnd = metadata?.duration || 0; + } else { + metaError = res.message || "Failed to fetch metadata."; + } + } catch (e: any) { + metaError = e.message || "Failed to fetch metadata."; + } finally { + metaLoading = false; + } + } + + $effect(() => { + if (clipMode && validLink($link)) { + fetchMetadata(); + } + }); + $effect (() => { if (linkPrefill) { // prefilled link may be uri encoded @@ -139,6 +179,12 @@ break; } }; + + function formatTime(seconds: number) { + const m = Math.floor(seconds / 60); + const s = (seconds % 60).toFixed(3).padStart(6, '0'); + return `${m}:${s}`; + } @@ -192,6 +238,9 @@ ($link = "")} /> @@ -225,12 +274,25 @@ + + clipMode = !clipMode} /> + {$t("save.paste")} {$t("save.paste.long")} + + {#if clipMode && validLink($link)} + + {/if} diff --git a/web/src/components/save/buttons/DownloadButton.svelte b/web/src/components/save/buttons/DownloadButton.svelte index 94e8bbd2..532533dc 100644 --- a/web/src/components/save/buttons/DownloadButton.svelte +++ b/web/src/components/save/buttons/DownloadButton.svelte @@ -6,10 +6,14 @@ import { downloadButtonState } from "$lib/state/omnibox"; import type { CobaltDownloadButtonState } from "$lib/types/omnibox"; + import type { CobaltSaveRequestBody } from "$lib/types/api"; export let url: string; export let disabled = false; export let loading = false; + export let clipMode = false; + export let clipStart: number + export let clipEnd: number $: buttonText = ">>"; $: buttonAltText = $t("a11y.save.download"); @@ -56,7 +60,15 @@ {disabled} on:click={() => { hapticSwitch(); - savingHandler({ url }); + if (clipMode) { + savingHandler({ request: { + url, + clipStart, + clipEnd, + } }); + } else { + savingHandler({ url }); + } }} aria-label={buttonAltText} > diff --git a/web/src/lib/api/api.ts b/web/src/lib/api/api.ts index 0467adf3..eed7463a 100644 --- a/web/src/lib/api/api.ts +++ b/web/src/lib/api/api.ts @@ -137,7 +137,26 @@ const probeCobaltTunnel = async (url: string) => { return 0; } +const getMetadata = async (url: string) => { + const api = currentApiURL().replace(/\/?$/, '/metadata'); + const authorization = await getAuthorization(); + let extraHeaders = {}; + if (authorization && typeof authorization === "string") { + extraHeaders = { "Authorization": authorization }; + } + return fetch(api, { + method: "POST", + body: JSON.stringify({ url }), + headers: { + "Accept": "application/json", + "Content-Type": "application/json", + ...extraHeaders, + }, + }).then(r => r.json()); +}; + export default { request, probeCobaltTunnel, + getMetadata, } diff --git a/web/src/lib/api/saving-handler.ts b/web/src/lib/api/saving-handler.ts index 94c0319e..6cf31a34 100644 --- a/web/src/lib/api/saving-handler.ts +++ b/web/src/lib/api/saving-handler.ts @@ -41,7 +41,7 @@ export const savingHandler = async ({ url, request, oldTaskId }: SavingHandlerAr if (!request && !url) return; - const selectedRequest = request || { + const selectedRequest = { url: url!, // not lazy cuz default depends on device capabilities @@ -67,6 +67,7 @@ export const savingHandler = async ({ url, request, oldTaskId }: SavingHandlerAr allowH265: getSetting("save", "allowH265"), convertGif: getSetting("save", "convertGif"), + ...request, } const response = await API.request(selectedRequest); diff --git a/web/src/lib/types/api.ts b/web/src/lib/types/api.ts index f127d02e..58a5c7e5 100644 --- a/web/src/lib/types/api.ts +++ b/web/src/lib/types/api.ts @@ -113,7 +113,10 @@ export type CobaltServerInfo = { // this allows for extra properties, which is not ideal, // but i couldn't figure out how to make a strict partial :( export type CobaltSaveRequestBody = - { url: string } & Partial>; + { url: string } & Partial> & { + clipStart?: number, + clipEnd?: number, + }; export type CobaltSessionResponse = CobaltSession | CobaltErrorResponse; export type CobaltServerInfoResponse = CobaltServerInfo | CobaltErrorResponse;