feat(route/youtube): subtitle attachment (#19772)

* feat(route/youtube): subtitle attachment

* chore: update

* update
This commit is contained in:
Stephen Zhou 2025-08-07 20:42:00 +08:00 committed by GitHub
parent 74f0d40244
commit 37507f6553
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 82 additions and 20 deletions

View File

@ -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',
},
],
};
})
),
};
};

View File

@ -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"
},

View File

@ -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