Files
bitify/lib/ytdlp.ts
T
bitc0de 706d808b83 feat: v1.1.0 - Major UI/UX improvements and new features
- Remove confirmation dialogs for song deletion (faster workflow)
- Add bulk playlist creation from YouTube playlist URLs
- Display playlist tags on song cards in home page
- Enable moving songs between playlists
- Fix mobile UI: action buttons now appear above player bar
- Add bulk song selection and deletion from home page
- Improve loading states and user feedback
- Update documentation and CI/CD workflow
- Fix all toast messages to English
2025-12-25 13:15:34 +01:00

206 lines
6.2 KiB
TypeScript

import { spawn } from 'child_process'
import { join } from 'path'
// Detect yt-dlp command based on environment
const getYtDlpCommand = () => {
// In development on Windows with venv
if (process.platform === 'win32' && process.env.NODE_ENV !== 'production') {
return join(process.cwd(), '.venv', 'Scripts', 'yt-dlp.exe')
}
// In production or Unix systems
return 'yt-dlp'
}
const YT_DLP = getYtDlpCommand()
// Helper function to execute yt-dlp with arguments
async function executeYtDlp(args: string[], timeout: number = 30000): Promise<string> {
return new Promise((resolve, reject) => {
console.log('[yt-dlp] Executing:', YT_DLP, 'with args:', args)
const childProcess = spawn(YT_DLP, args)
let stdout = ''
let stderr = ''
let isResolved = false
// Set timeout
const timeoutId = setTimeout(() => {
if (!isResolved) {
console.error('[yt-dlp] Timeout reached, killing process')
childProcess.kill()
reject(new Error('Process timeout after ' + timeout + 'ms'))
}
}, timeout)
childProcess.stdout.on('data', (data) => {
stdout += data.toString()
})
childProcess.stderr.on('data', (data) => {
stderr += data.toString()
})
childProcess.on('close', (code) => {
if (isResolved) return
isResolved = true
clearTimeout(timeoutId)
console.log('[yt-dlp] Exit code:', code)
if (stderr) console.log('[yt-dlp] Stderr:', stderr)
if (code === 0) {
console.log('[yt-dlp] Success, output length:', stdout.length)
resolve(stdout)
} else {
console.error('[yt-dlp] Error:', stderr || `Process exited with code ${code}`)
reject(new Error(stderr || `Process exited with code ${code}`))
}
})
childProcess.on('error', (error) => {
if (isResolved) return
isResolved = true
clearTimeout(timeoutId)
console.error('[yt-dlp] Spawn error:', error)
reject(error)
})
})
}
export interface YtDlpMetadata {
id: string
title: string
channel: string
thumbnail: string
duration: number
}
export async function getYoutubeMetadata(url: string): Promise<YtDlpMetadata> {
try {
console.log('[getYoutubeMetadata] Fetching metadata for URL:', url)
const stdout = await executeYtDlp([
'--dump-json',
'--no-warnings',
'--no-playlist', // Only extract the video, not the playlist
'--flat-playlist', // Don't extract playlist info
url
], 60000) // 60 second timeout
const data = JSON.parse(stdout)
console.log('[getYoutubeMetadata] Successfully parsed metadata for:', data.title)
console.log('[getYoutubeMetadata] Available thumbnails:', data.thumbnails?.length || 0)
// Get best thumbnail URL - prefer high quality thumbnails
let thumbnail = ''
if (data.thumbnails && Array.isArray(data.thumbnails) && data.thumbnails.length > 0) {
// Find the highest quality thumbnail
const bestThumbnail = data.thumbnails
.filter((t: any) => t.url)
.sort((a: any, b: any) => {
const widthA = a.width || 0
const widthB = b.width || 0
return widthB - widthA
})[0]
thumbnail = bestThumbnail?.url || data.thumbnails[0]?.url || ''
}
// Fallback to standard YouTube thumbnail URLs
if (!thumbnail && data.id) {
thumbnail = `https://i.ytimg.com/vi/${data.id}/hqdefault.jpg`
}
console.log('[getYoutubeMetadata] Selected thumbnail URL:', thumbnail)
return {
id: data.id,
title: data.title,
channel: data.channel || data.uploader || 'Unknown',
thumbnail,
duration: data.duration || 0,
}
} catch (error) {
console.error('[getYoutubeMetadata] Error:', error)
throw new Error(`Failed to get metadata: ${error}`)
}
}
export async function getStreamUrl(youtubeId: string): Promise<string> {
try {
const stdout = await executeYtDlp([
'-g',
'-f',
'bestaudio',
`https://www.youtube.com/watch?v=${youtubeId}`
])
return stdout.trim()
} catch (error) {
throw new Error(`Failed to get stream URL: ${error}`)
}
}
export async function updateYtDlp(): Promise<string> {
try {
const stdout = await executeYtDlp(['-U'])
return stdout.trim()
} catch (error) {
throw new Error(`Failed to update yt-dlp: ${error}`)
}
}
export async function getYoutubePlaylistMetadata(url: string): Promise<Array<{
id: string
title: string
channel: string
thumbnail: string
duration: number
}>> {
try {
console.log('[getYoutubePlaylistMetadata] Fetching playlist metadata for URL:', url)
const stdout = await executeYtDlp([
'--dump-json',
'--no-warnings',
'--flat-playlist', // Extract playlist info
url
], 120000) // 2 minute timeout for playlists
// Split by newlines and parse each JSON object
const lines = stdout.trim().split('\n')
const videos = lines.map(line => JSON.parse(line)).filter((data: any) => data && data.id)
console.log('[getYoutubePlaylistMetadata] Found', videos.length, 'videos in playlist')
return videos.map((data: any) => {
console.log('[getYoutubePlaylistMetadata] Processing video:', data.title)
// Get best thumbnail URL
let thumbnail = ''
if (data.thumbnails && Array.isArray(data.thumbnails) && data.thumbnails.length > 0) {
const bestThumbnail = data.thumbnails
.filter((t: any) => t.url)
.sort((a: any, b: any) => {
const widthA = a.width || 0
const widthB = b.width || 0
return widthB - widthA
})[0]
thumbnail = bestThumbnail?.url || data.thumbnails[0]?.url || ''
}
// Fallback to standard YouTube thumbnail URLs
if (!thumbnail && data.id) {
thumbnail = `https://i.ytimg.com/vi/${data.id}/hqdefault.jpg`
}
return {
id: data.id,
title: data.title,
channel: data.channel || data.uploader || 'Unknown',
thumbnail,
duration: data.duration || 0,
}
})
} catch (error) {
console.error('[getYoutubePlaylistMetadata] Error:', error)
throw new Error(`Failed to get playlist metadata: ${error}`)
}
}