From aff62f48ecd76dee79cba36c582a103e1afd02d0 Mon Sep 17 00:00:00 2001 From: localhost Date: Mon, 15 Sep 2025 22:03:05 +0200 Subject: [PATCH] add support for shorts urls, channel handles, channel /c/ and /user/ --- src/router/websocket.ts | 1 + src/utils/regex.ts | 75 +++++++++++++++++++++++++++-------------- 2 files changed, 51 insertions(+), 25 deletions(-) diff --git a/src/router/websocket.ts b/src/router/websocket.ts index d2a03c9..a30605d 100644 --- a/src/router/websocket.ts +++ b/src/router/websocket.ts @@ -128,6 +128,7 @@ app.ws('/savechannel', { const channelId = await validateChannel(ws.data.query.url); if (!channelId) return sendError(ws, 'Invalid channel URL.'); + if (typeof channelId !== 'string') return sendError(ws, `Failed to fetch channel ID - ${channelId.error}`) ws.send('DATA - This process is automatic. Your video will start archiving shortly.') ws.send('CAPTCHA - Solving a cryptographic challenge before downloading.') diff --git a/src/utils/regex.ts b/src/utils/regex.ts index 4c78b9f..3d5062f 100644 --- a/src/utils/regex.ts +++ b/src/utils/regex.ts @@ -1,35 +1,29 @@ function validateVideo(input: string): string | false { try { const url = new URL(input); + let videoId: string = '' const hostnames = [ 'youtube.com', 'www.youtube.com', 'm.youtube.com' ]; - // basic hostname check if (hostnames.includes(url.hostname)) { - // basic url if (url.pathname === '/watch') { - const videoId = url.searchParams.get('v'); - return videoId || false; - } - - // embed url - const embedMatch = url.pathname.match(/^\/embed\/([a-zA-Z0-9_-]+)/); - if (embedMatch) { - return embedMatch[1]; - } - - return false; + if (!url.searchParams.get('v')) return false + videoId = url.searchParams.get('v')! + } else if (url.pathname.startsWith('/shorts/')) { + videoId = url.pathname.replace('/shorts/', '') + } else return false + // removed - embed url } // short urls if (url.hostname === 'youtu.be') { - const videoId = url.pathname.replace(/^\//, ''); - return videoId || false; + videoId = url.pathname.replace(/^\//, ''); } + if (videoId && videoId.match(/[\w\-_]{11}/)) return videoId return false; } catch { return false; @@ -59,7 +53,7 @@ function validatePlaylist(input: string): string | false { } } -async function validateChannel(input: string): Promise { +async function validateChannel(input: string): Promise { try { const url = new URL(input); const hostnames = [ @@ -67,19 +61,43 @@ async function validateChannel(input: string): Promise { 'www.youtube.com', 'm.youtube.com' ]; + const whereIsIt: Record = { + channel: ['metadata', 'channelMetadataRenderer', 'externalId'], + handle: ['responseContext', 'serviceTrackingParams', 0, 'params'], + user: ['metadata', 'channelMetadataRenderer', 'externalId'] + } if (hostnames.includes(url.hostname)) { - // @ urls - const atMatch = url.pathname.match(/^\/@([a-zA-Z0-9.-]+)/); - if (atMatch) { - const channelId = await (await fetch(`https://yt.jaybee.digital/api/channels?part=channels&handle=${atMatch[1]}`)).json() - return channelId['items'][0]['id'] + let whatIsIt = '' + + // many thanks to Benjamin Loison (@Benjamin-Loison) for his PHP implementation of this + // https://github.com/Benjamin-Loison/YouTube-operational-API/blob/main/channels.php + // and the stackoverflow answer with all the possible options, thank you. + // https://stackoverflow.com/a/75843807 + + if (url.pathname.startsWith('/channel/')) { // /channel/[id] + return url.pathname.match(/UC[\w\-_]{22}/gm)?.[0] || false + } else if (url.pathname.startsWith('/c/')) { // /c/[custom] + whatIsIt = 'channel' + } else if (url.pathname.startsWith('/user/')) { + whatIsIt = 'user' + } else if (url.pathname.match(/@[\w\-_.]{3,}/gm)) { // /@[handle] + whatIsIt = 'handle' + } else return false + + const channelReq = await fetch(`${process.env.METADATA}/getWebpageJson?url=${url}`) + if (!channelReq.ok) return { + error: `Failed to fetch Youtube with status ${channelReq.status}. Please retry.` } - // /channel/ and /c/ - const channelMatch = url.pathname.match(/^\/(channel|c)\/([a-zA-Z0-9_-]+)/); - if (channelMatch) { - return channelMatch[2]; + const channelJson = await channelReq.json() + let channelId: string | Record = getByPath(channelJson, whereIsIt[whatIsIt]!); + if (whatIsIt == 'handle') { + channelId = channelId.find((c:any) => c.key == 'browse_id').value + } + + return typeof channelId == 'string' ? channelId : { + error: 'Failed to extract channel ID from the Youtube provided JSON.' } } @@ -89,4 +107,11 @@ async function validateChannel(input: string): Promise { } } +function getByPath(obj: Record, path: (string|number)[]) { + return path.reduce((acc, key) => { + if (acc === undefined || acc === null) return undefined + return acc[key] + }, obj) +} + export { validateVideo, validatePlaylist, validateChannel } \ No newline at end of file