audiobookshelf/server/utils/youtubeUtils.js
Claude aaf87821a3
Add YouTube download feature with yt-dlp integration
This commit implements a comprehensive YouTube download feature that allows
administrators to download audio from YouTube videos and playlists.

Backend Changes:
- Add yt-dlp-wrap npm dependency for YouTube downloading
- Create YouTubeDownloadManager to handle download queue and execution
- Create YouTubeDownloadController with API endpoints for download/queue/cancel
- Add YouTube utility functions for URL validation and metadata extraction
- Create YouTubeDownload object model for tracking download state
- Add API routes: POST /api/youtube/download, GET /api/youtube/queue, DELETE /api/youtube/download/:id
- Implement playlist support - automatically queue all videos from playlist URLs
- Download audio in MP3 format with best quality
- Organize files in subfolder structure: <Uploader>/<Video Title>/
- Extract metadata and create library items automatically
- Real-time progress updates via Socket.io

Frontend Changes:
- Create YouTubeDownloadModal component for user-friendly download interface
- Add download button to Appbar header (admin-only, visible with current library)
- Update Vuex store with modal state management
- Register modal globally in default layout
- Implement Socket.io listeners for download events (started, progress, completed, failed, queued)
- Show toast notifications for download status updates

Features:
- Admin-only access control
- Support for single videos and playlists
- Audio quality selection (best, 320kbps, 256kbps, 192kbps, 128kbps)
- Library and folder selection
- Download queue management
- Real-time progress tracking
- Automatic thumbnail download as cover image
- Automatic library item creation with metadata
- Error handling and user feedback

Technical Details:
- Uses yt-dlp for reliable YouTube downloading
- Integrates with existing task management system
- Respects library folder permissions
- Follows existing code patterns (similar to podcast downloads)
- Socket.io events for real-time updates
2025-12-31 01:32:37 +00:00

144 lines
3.2 KiB
JavaScript

const Logger = require('../Logger')
/**
* Validate if a URL is a valid YouTube URL
* Supports: youtube.com, youtu.be, music.youtube.com
*
* @param {string} url
* @returns {boolean}
*/
function isValidYouTubeUrl(url) {
if (!url || typeof url !== 'string') return false
try {
const urlObj = new URL(url)
const hostname = urlObj.hostname.toLowerCase()
// Support various YouTube domains
const validHosts = [
'www.youtube.com',
'youtube.com',
'youtu.be',
'music.youtube.com',
'm.youtube.com'
]
if (!validHosts.includes(hostname)) return false
// Check for video ID or playlist ID
if (hostname === 'youtu.be') {
return urlObj.pathname.length > 1
}
return urlObj.searchParams.has('v') || urlObj.searchParams.has('list') || urlObj.pathname.includes('/watch')
} catch (error) {
Logger.error('[youtubeUtils] Invalid URL:', error)
return false
}
}
/**
* Extract video ID from YouTube URL
*
* @param {string} url
* @returns {string|null}
*/
function extractVideoId(url) {
try {
const urlObj = new URL(url)
const hostname = urlObj.hostname.toLowerCase()
if (hostname === 'youtu.be') {
return urlObj.pathname.slice(1).split('?')[0]
}
return urlObj.searchParams.get('v')
} catch (error) {
Logger.error('[youtubeUtils] Failed to extract video ID:', error)
return null
}
}
/**
* Check if URL is a playlist
*
* @param {string} url
* @returns {boolean}
*/
function isPlaylistUrl(url) {
try {
const urlObj = new URL(url)
return urlObj.searchParams.has('list') || urlObj.pathname.includes('/playlist')
} catch (error) {
return false
}
}
/**
* Extract playlist ID from YouTube URL
*
* @param {string} url
* @returns {string|null}
*/
function extractPlaylistId(url) {
try {
const urlObj = new URL(url)
return urlObj.searchParams.get('list')
} catch (error) {
Logger.error('[youtubeUtils] Failed to extract playlist ID:', error)
return null
}
}
/**
* Sanitize title for filename
* Remove invalid characters and limit length
*
* @param {string} title
* @returns {string}
*/
function sanitizeTitleForFilename(title) {
if (!title) return 'untitled'
// Remove invalid filename characters
let sanitized = title.replace(/[<>:"/\\|?*\x00-\x1f]/g, '')
// Replace multiple spaces with single space
sanitized = sanitized.replace(/\s+/g, ' ').trim()
// Limit length to 200 characters
if (sanitized.length > 200) {
sanitized = sanitized.substring(0, 200).trim()
}
return sanitized || 'untitled'
}
/**
* Format duration from seconds to readable format
*
* @param {number} seconds
* @returns {string}
*/
function formatDuration(seconds) {
if (!seconds || isNaN(seconds)) return '0:00'
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
const secs = Math.floor(seconds % 60)
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
}
return `${minutes}:${secs.toString().padStart(2, '0')}`
}
module.exports = {
isValidYouTubeUrl,
extractVideoId,
isPlaylistUrl,
extractPlaylistId,
sanitizeTitleForFilename,
formatDuration
}