summaryrefslogtreecommitdiff
path: root/src/lib/server/ytdlp.ts
diff options
context:
space:
mode:
Diffstat (limited to 'src/lib/server/ytdlp.ts')
-rw-r--r--src/lib/server/ytdlp.ts329
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;
+}