diff --git a/lib/routes/youtube/api/youtubei.ts b/lib/routes/youtube/api/youtubei.ts index 12e5b6dc1..ef750167b 100644 --- a/lib/routes/youtube/api/youtubei.ts +++ b/lib/routes/youtube/api/youtubei.ts @@ -1,3 +1,4 @@ +import { getSubtitles } from 'youtube-caption-extractor'; import cache from '@/utils/cache'; import { Innertube } from 'youtubei.js'; import utils, { getVideoUrl } from '../utils'; @@ -6,6 +7,38 @@ import { parseRelativeDate } from '@/utils/parse-date'; const innertubePromise = Innertube.create(); +function pad(n: number, width: number = 2) { + return String(n).padStart(width, '0'); +} + +function toSrtTime(seconds: number): string { + const totalMs = Math.floor(seconds * 1000); + const hours = Math.floor(totalMs / 3_600_000); + const minutes = Math.floor((totalMs % 3_600_000) / 60000); + const secs = Math.floor((totalMs % 60000) / 1000); + const millis = totalMs % 1000; + return `${pad(hours)}:${pad(minutes)}:${pad(secs)},${pad(millis, 3)}`; +} + +type Subtitle = { + start: string; + dur: string; + text: string; +}; + +function convertToSrt(segments: Subtitle[]): string { + return segments + .map((seg, index) => { + const start = Number.parseFloat(seg.start); + const end = start + Number.parseFloat(seg.dur); + return `${index + 1} +${toSrtTime(start)} --> ${toSrtTime(end)} +${seg.text} +`; + }) + .join('\n'); +} + export const getChannelIdByUsername = (username: string) => cache.tryGet(`youtube:getChannelIdByUsername:${username}`, async () => { const innertube = await innertubePromise; @@ -29,27 +62,38 @@ export const getDataByChannelId = async ({ channelId, embed }: { channelId: stri image: channel.metadata.avatar?.[0].url, description: channel.metadata.description, - item: videos.videos - .filter((video) => 'video_id' in video) - .map((video) => { - const img = 'best_thumbnail' in video ? video.best_thumbnail?.url : ('thumbnails' in video ? video.thumbnails?.[0]?.url : undefined); + item: await Promise.all( + videos.videos + .filter((video) => 'video_id' in video) + .map(async (video) => { + const subtitles = await getSubtitles({ videoID: video.video_id }); + const srt = convertToSrt(subtitles); + const dataUrl = `data:text/plain;charset=utf-8,${encodeURIComponent(srt)}`; - return { - title: video.title.text || `YouTube Video ${video.video_id}`, - description: 'description_snippet' in video ? utils.renderDescription(embed, video.video_id, img, utils.formatDescription(video.description_snippet?.toHTML())) : null, - link: `https://www.youtube.com/watch?v=${video.video_id}`, - author: typeof video.author === 'string' ? video.author : (video.author.name === 'N/A' ? undefined : video.author.name), - image: img, - pubDate: 'published' in video && video.published?.text ? parseRelativeDate(video.published.text) : undefined, - attachments: [ - { - url: getVideoUrl(video.video_id), - mime_type: 'text/html', - duration_in_seconds: video.duration && 'seconds' in video.duration ? video.duration.seconds : undefined, - }, - ], - }; - }), + const img = 'best_thumbnail' in video ? video.best_thumbnail?.url : ('thumbnails' in video ? video.thumbnails?.[0]?.url : undefined); + + return { + title: video.title.text || `YouTube Video ${video.video_id}`, + description: 'description_snippet' in video ? utils.renderDescription(embed, video.video_id, img, utils.formatDescription(video.description_snippet?.toHTML())) : null, + link: `https://www.youtube.com/watch?v=${video.video_id}`, + author: typeof video.author === 'string' ? video.author : (video.author.name === 'N/A' ? undefined : video.author.name), + image: img, + pubDate: 'published' in video && video.published?.text ? parseRelativeDate(video.published.text) : undefined, + attachments: [ + { + url: getVideoUrl(video.video_id), + mime_type: 'text/html', + duration_in_seconds: video.duration && 'seconds' in video.duration ? video.duration.seconds : undefined, + }, + { + url: dataUrl, + mime_type: 'text/srt', + title: 'Subtitles', + }, + ], + }; + }) + ), }; }; diff --git a/package.json b/package.json index 837ddb377..c0abc14c4 100644 --- a/package.json +++ b/package.json @@ -133,6 +133,7 @@ "uuid": "11.1.0", "winston": "3.17.0", "xxhash-wasm": "1.1.0", + "youtube-caption-extractor": "1.8.2", "youtubei.js": "15.0.1", "zod": "3.25.76" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bbd2fa3c8..4a307a1aa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -287,6 +287,9 @@ importers: xxhash-wasm: specifier: 1.1.0 version: 1.1.0 + youtube-caption-extractor: + specifier: 1.8.2 + version: 1.8.2 youtubei.js: specifier: 15.0.1 version: 15.0.1 @@ -5446,6 +5449,9 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + striptags@3.2.0: + resolution: {integrity: sha512-g45ZOGzHDMe2bdYMdIvdAfCQkCTDMGBazSw1ypMowwGIee7ZQ5dU0rBJ8Jqgl+jAKIv4dbeE1jscZq9wid1Tkw==} + superagent@10.2.3: resolution: {integrity: sha512-y/hkYGeXAj7wUMjxRbB21g/l6aAEituGXM9Rwl4o20+SX3e8YOSV6BxFXl+dL3Uk0mjSL3kCbNkwURm8/gEDig==} engines: {node: '>=14.18.0'} @@ -6106,6 +6112,10 @@ packages: resolution: {integrity: sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA==} engines: {node: '>=18'} + youtube-caption-extractor@1.8.2: + resolution: {integrity: sha512-4VNDP5rIjBmTF4Us2aPiu5c795Yf/yLqXtEQ3U33PPJsoutKrmeIyx/8NBYH0Qlzk+tEmaftQ7/6a6TgfYNtVA==} + engines: {node: '>=14.0.0'} + youtubei.js@15.0.1: resolution: {integrity: sha512-2slapqJS5NuXKHvcACEknyVz0AjH/TrXaOhDM0q2twQKa54kCmfj+7B/2nGfd20uzAe29zW1ejk2qOc4ABuGkg==} @@ -11649,6 +11659,8 @@ snapshots: strip-json-comments@3.1.1: {} + striptags@3.2.0: {} + superagent@10.2.3: dependencies: component-emitter: 1.3.1 @@ -12307,6 +12319,11 @@ snapshots: yoctocolors-cjs@2.1.2: {} + youtube-caption-extractor@1.8.2: + dependencies: + he: 1.2.0 + striptags: 3.2.0 + youtubei.js@15.0.1: dependencies: '@bufbuild/protobuf': 2.6.1