diff options
Diffstat (limited to 'src/lib/server/ytdlp.ts')
| -rw-r--r-- | src/lib/server/ytdlp.ts | 329 |
1 files changed, 329 insertions, 0 deletions
diff --git a/src/lib/server/ytdlp.ts b/src/lib/server/ytdlp.ts new file mode 100644 index 0000000..2f6f47e --- /dev/null +++ b/src/lib/server/ytdlp.ts @@ -0,0 +1,329 @@ +import { spawn } from 'child_process'; + +export interface YtdlpVideo { + id: string; + title: string; + description: string; + channel: string; + channel_id: string; + channel_url: string; + thumbnail: string; + thumbnails: { url: string; width?: number; height?: number }[]; + duration: number; + view_count: number; + upload_date: string; + like_count?: number; + formats: YtdlpFormat[]; +} + +export interface YtdlpFormat { + format_id: string; + url: string; + ext: string; + resolution?: string; + height?: number; + width?: number; + vcodec?: string; + acodec?: string; + format_note?: string; +} + +export interface YtdlpSearchResult { + id: string; + title: string; + channel: string; + channel_id: string; + channel_url: string; + thumbnail: string; + thumbnails: { url: string; width?: number; height?: number }[]; + duration: number; + view_count: number; + upload_date: string; +} + +export interface YtdlpChannel { + id: string; + channel: string; + channel_id: string; + description: string; + channel_follower_count: number; + thumbnails: { url: string; width?: number; height?: number }[]; + entries?: YtdlpSearchResult[]; +} + +function runYtdlp(args: string[]): Promise<string> { + return new Promise((resolve, reject) => { + const proc = spawn('yt-dlp', args); + let stdout = ''; + let stderr = ''; + + proc.stdout.on('data', (data: Buffer) => { + stdout += data.toString(); + }); + + proc.stderr.on('data', (data: Buffer) => { + stderr += data.toString(); + }); + + proc.on('close', (code: number | null) => { + if (code === 0) { + resolve(stdout); + } else { + reject(new Error(stderr || `yt-dlp exited with code ${code}`)); + } + }); + + proc.on('error', (err: Error) => { + reject(err); + }); + }); +} + +export async function search(query: string, limit = 20): Promise<YtdlpSearchResult[]> { + const output = await runYtdlp([ + `ytsearch${limit}:${query}`, + '--dump-json', + '--flat-playlist', + '--no-warnings', + '--ignore-errors' + ]); + + const results: YtdlpSearchResult[] = []; + for (const line of output.trim().split('\n')) { + if (!line) continue; + try { + const item = JSON.parse(line); + if (item.duration && item.duration >= 60) { + results.push({ + id: item.id, + title: item.title, + channel: item.channel || item.uploader || '', + channel_id: item.channel_id || item.uploader_id || '', + channel_url: item.channel_url || item.uploader_url || '', + thumbnail: item.thumbnail || item.thumbnails?.[0]?.url || '', + thumbnails: item.thumbnails || [], + duration: item.duration || 0, + view_count: item.view_count || 0, + upload_date: item.upload_date || '' + }); + } + } catch { + // Skip invalid JSON lines + } + } + + return results; +} + +export async function getVideo(videoId: string): Promise<YtdlpVideo> { + const output = await runYtdlp([ + `https://www.youtube.com/watch?v=${videoId}`, + '--dump-json', + '--no-warnings' + ]); + + const data = JSON.parse(output); + return { + id: data.id, + title: data.title, + description: data.description || '', + channel: data.channel || data.uploader || '', + channel_id: data.channel_id || data.uploader_id || '', + channel_url: data.channel_url || data.uploader_url || '', + thumbnail: data.thumbnail || data.thumbnails?.[0]?.url || '', + thumbnails: data.thumbnails || [], + duration: data.duration || 0, + view_count: data.view_count || 0, + upload_date: data.upload_date || '', + like_count: data.like_count, + formats: (data.formats || []).filter((f: YtdlpFormat) => + f.vcodec !== 'none' && f.acodec !== 'none' && f.url + ).map((f: YtdlpFormat) => ({ + format_id: f.format_id, + url: f.url, + ext: f.ext, + resolution: f.resolution, + height: f.height, + width: f.width, + vcodec: f.vcodec, + acodec: f.acodec, + format_note: f.format_note + })) + }; +} + +export async function getChannel(channelId: string): Promise<YtdlpChannel> { + const url = channelId.startsWith('@') + ? `https://www.youtube.com/${channelId}` + : `https://www.youtube.com/channel/${channelId}`; + + const output = await runYtdlp([ + `${url}/videos`, + '--dump-json', + '--flat-playlist', + '--playlist-end', '30', + '--no-warnings', + '--ignore-errors' + ]); + + const lines = output.trim().split('\n').filter(Boolean); + const entries: YtdlpSearchResult[] = []; + let channelInfo: Partial<YtdlpChannel> = {}; + + for (const line of lines) { + try { + const item = JSON.parse(line); + if (item.channel_id || item.uploader_id) { + channelInfo = { + id: item.channel_id || item.uploader_id, + channel: item.channel || item.uploader || '', + channel_id: item.channel_id || item.uploader_id || '', + description: item.description || '', + channel_follower_count: item.channel_follower_count || 0, + thumbnails: item.thumbnails || [] + }; + } + if (item.id && item.title && item.duration && item.duration >= 60) { + entries.push({ + id: item.id, + title: item.title, + channel: item.channel || item.uploader || '', + channel_id: item.channel_id || item.uploader_id || '', + channel_url: item.channel_url || item.uploader_url || '', + thumbnail: item.thumbnail || item.thumbnails?.[0]?.url || '', + thumbnails: item.thumbnails || [], + duration: item.duration || 0, + view_count: item.view_count || 0, + upload_date: item.upload_date || '' + }); + } + } catch { + // Skip invalid JSON + } + } + + return { + id: channelInfo.channel_id || channelId, + channel: channelInfo.channel || '', + channel_id: channelInfo.channel_id || channelId, + description: channelInfo.description || '', + channel_follower_count: channelInfo.channel_follower_count || 0, + thumbnails: channelInfo.thumbnails || [], + entries + }; +} + +export interface YtdlpPlaylist { + id: string; + title: string; + channel: string; + channel_id: string; + entries: YtdlpSearchResult[]; +} + +export async function getRelatedVideos(title: string, excludeId: string): Promise<YtdlpSearchResult[]> { + // Extract key words from title for search + const keywords = title + .replace(/[^\w\s]/g, '') + .split(/\s+/) + .filter(w => w.length > 3) + .slice(0, 5) + .join(' '); + + if (!keywords) return []; + + const results = await search(keywords, 15); + return results.filter(r => r.id !== excludeId).slice(0, 10); +} + +export async function getPlaylist(playlistUrl: string): Promise<YtdlpPlaylist> { + const output = await runYtdlp([ + playlistUrl, + '--dump-json', + '--flat-playlist', + '--no-warnings', + '--ignore-errors' + ]); + + const lines = output.trim().split('\n').filter(Boolean); + const entries: YtdlpSearchResult[] = []; + let playlistInfo: Partial<YtdlpPlaylist> = {}; + + for (const line of lines) { + try { + const item = JSON.parse(line); + + // Capture playlist metadata from first item + if (!playlistInfo.id && item.playlist_id) { + playlistInfo = { + id: item.playlist_id, + title: item.playlist_title || item.playlist || 'Imported Playlist', + channel: item.playlist_uploader || item.channel || item.uploader || '', + channel_id: item.playlist_uploader_id || item.channel_id || item.uploader_id || '' + }; + } + + if (item.id && item.title) { + entries.push({ + id: item.id, + title: item.title, + channel: item.channel || item.uploader || '', + channel_id: item.channel_id || item.uploader_id || '', + channel_url: item.channel_url || item.uploader_url || '', + thumbnail: item.thumbnail || item.thumbnails?.[0]?.url || `https://i.ytimg.com/vi/${item.id}/hqdefault.jpg`, + thumbnails: item.thumbnails || [], + duration: item.duration || 0, + view_count: item.view_count || 0, + upload_date: item.upload_date || '' + }); + } + } catch { + // Skip invalid JSON + } + } + + return { + id: playlistInfo.id || '', + title: playlistInfo.title || 'Imported Playlist', + channel: playlistInfo.channel || '', + channel_id: playlistInfo.channel_id || '', + entries + }; +} + +export async function getTrending(): Promise<YtdlpSearchResult[]> { + const output = await runYtdlp([ + 'https://www.youtube.com/feed/trending', + '--dump-json', + '--flat-playlist', + '--playlist-end', '30', + '--no-warnings', + '--ignore-errors' + ]); + + const results: YtdlpSearchResult[] = []; + for (const line of output.trim().split('\n')) { + if (!line) continue; + try { + const item = JSON.parse(line); + if (item.duration && item.duration >= 60) { + results.push({ + id: item.id, + title: item.title, + channel: item.channel || item.uploader || '', + channel_id: item.channel_id || item.uploader_id || '', + channel_url: item.channel_url || item.uploader_url || '', + thumbnail: item.thumbnail || item.thumbnails?.[0]?.url || '', + thumbnails: item.thumbnails || [], + duration: item.duration || 0, + view_count: item.view_count || 0, + upload_date: item.upload_date || '' + }); + } + } catch { + // Skip invalid JSON + } + } + + return results; +} |
