/* * Copyright (C) 2026 Fluxer Contributors * * This file is part of Fluxer. * * Fluxer is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Fluxer is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with Fluxer. If not, see . */ import {URL} from 'node:url'; import {Config} from '~/Config'; import type {MessageEmbedResponse} from '~/channel/EmbedTypes'; import {Logger} from '~/Logger'; import {BaseResolver} from '~/unfurler/resolvers/BaseResolver'; import {buildEmbedMediaPayload} from '~/unfurler/resolvers/media/MediaMetadataHelpers'; import * as FetchUtils from '~/utils/FetchUtils'; import {parseString} from '~/utils/StringUtils'; interface YouTubeApiResponse { items?: Array<{ snippet: { title: string; description: string; channelTitle: string; channelId: string; thumbnails: { maxres?: { url: string; width: number; height: number; }; high: { url: string; width: number; height: number; }; }; }; player: { embedHtml: string; }; status: { uploadStatus?: string; privacyStatus?: string; embeddable?: boolean; publicStatsViewable?: boolean; madeForKids?: boolean; }; }>; } export class YouTubeResolver extends BaseResolver { private readonly API_BASE = 'https://www.googleapis.com/youtube/v3'; private readonly YOUTUBE_COLOR = 0xff0000; match(url: URL, _mimeType: string, _content: Uint8Array): boolean { if (!['www.youtube.com', 'youtube.com', 'youtu.be'].includes(url.hostname)) { return false; } return ( url.pathname.startsWith('/watch') || url.pathname.startsWith('/shorts') || url.pathname.startsWith('/v/') || url.hostname === 'youtu.be' ); } async resolve(url: URL, _content: Uint8Array, isNSFWAllowed: boolean = false): Promise> { if (!Config.youtube.apiKey) { Logger.debug('No Google API key configured'); return []; } const videoId = this.extractVideoId(url); if (!videoId) { Logger.error('No video ID found in URL'); return []; } try { const timestamp = this.extractTimestamp(url); const apiUrl = new URL(`${this.API_BASE}/videos`); apiUrl.searchParams.set('key', Config.youtube.apiKey); apiUrl.searchParams.set('id', videoId); apiUrl.searchParams.set('part', 'snippet,player,status'); const response = await FetchUtils.sendRequest({ url: apiUrl.toString(), }); if (response.status !== 200) { Logger.error({videoId, status: response.status}, 'Failed to fetch YouTube API data'); return []; } const responseText = await FetchUtils.streamToString(response.stream); const data = JSON.parse(responseText) as YouTubeApiResponse; const video = data.items?.[0]; if (!video) { Logger.error({videoId}, 'No video data found'); return []; } const thumbnailUrl = video.snippet.thumbnails.maxres?.url || video.snippet.thumbnails.high.url; const thumbnailMetadata = await this.mediaService.getMetadata({ type: 'external', url: thumbnailUrl, isNSFWAllowed, }); const embedHtmlMatch = video.player.embedHtml.match(/width="(\d+)"\s+height="(\d+)"/); const embedWidth = embedHtmlMatch ? Number.parseInt(embedHtmlMatch[1], 10) : 1280; const embedHeight = embedHtmlMatch ? Number.parseInt(embedHtmlMatch[2], 10) : 720; const mainUrl = new URL('https://www.youtube.com/watch'); mainUrl.searchParams.set('v', videoId); if (timestamp !== undefined) { mainUrl.searchParams.set('start', timestamp.toString()); } const embedUrl = new URL(`https://www.youtube.com/embed/${videoId}`); if (timestamp !== undefined) { embedUrl.searchParams.set('start', timestamp.toString()); } const embed: MessageEmbedResponse = { type: 'video', url: mainUrl.toString(), title: video.snippet.title, description: parseString(video.snippet.description, 350), color: this.YOUTUBE_COLOR, author: video.snippet.channelTitle ? { name: video.snippet.channelTitle, url: `https://www.youtube.com/channel/${video.snippet.channelId}`, } : undefined, provider: { name: 'YouTube', url: 'https://www.youtube.com', }, thumbnail: buildEmbedMediaPayload(thumbnailUrl, thumbnailMetadata, { width: video.snippet.thumbnails.maxres?.width || video.snippet.thumbnails.high.width, height: video.snippet.thumbnails.maxres?.height || video.snippet.thumbnails.high.height, }), video: { url: embedUrl.toString(), width: embedWidth, height: embedHeight, flags: 0, }, }; return [embed]; } catch (error) { Logger.error({error, videoId: this.extractVideoId(url)}, 'Failed to resolve YouTube URL'); return []; } } private extractVideoId(url: URL): string { if (url.pathname.startsWith('/shorts/')) { return url.pathname.split('/shorts/')[1]; } if (url.pathname.startsWith('/v/')) { return url.pathname.split('/v/')[1]; } if (url.hostname === 'youtu.be') { return url.pathname.slice(1); } return url.searchParams.get('v') || ''; } private extractTimestamp(url: URL): number | undefined { const tParam = url.searchParams.get('t'); if (tParam) { if (tParam.endsWith('s')) { return Number.parseInt(tParam.slice(0, -1), 10); } return Number.parseInt(tParam, 10); } return; } }