audiobookshelf/server/objects/YouTubeDownload.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

196 lines
5.1 KiB
JavaScript

const { v4: uuidv4 } = require('uuid')
const Path = require('path')
const { sanitizeFilename, filePathToPOSIX } = require('../utils/fileUtils')
class YouTubeDownload {
constructor() {
this.id = null
this.url = null
this.libraryId = null
this.libraryFolderId = null
this.userId = null
// Video metadata from yt-dlp
this.videoId = null
this.title = null
this.uploader = null
this.uploaderUrl = null
this.description = null
this.duration = null
this.thumbnailUrl = null
this.uploadDate = null
// Download state
this.status = 'pending' // pending, downloading, processing, completed, failed
this.progress = 0 // 0-100
this.error = null
// File paths
this.targetDirectory = null
this.targetFilename = null
this.targetPath = null
this.audioFilePath = null
this.coverPath = null
// Timestamps
this.createdAt = null
this.startedAt = null
this.finishedAt = null
// Options
this.audioFormat = 'mp3'
this.audioQuality = 'best'
// Playlist support
this.isPlaylist = false
this.playlistId = null
this.playlistTitle = null
this.playlistIndex = null
this.playlistTotal = null
}
toJSONForClient() {
return {
id: this.id,
url: this.url,
libraryId: this.libraryId,
libraryFolderId: this.libraryFolderId,
userId: this.userId,
videoId: this.videoId,
title: this.title,
uploader: this.uploader,
uploaderUrl: this.uploaderUrl,
description: this.description,
duration: this.duration,
thumbnailUrl: this.thumbnailUrl,
uploadDate: this.uploadDate,
status: this.status,
progress: this.progress,
error: this.error,
targetPath: this.targetPath,
audioFilePath: this.audioFilePath,
coverPath: this.coverPath,
createdAt: this.createdAt,
startedAt: this.startedAt,
finishedAt: this.finishedAt,
audioFormat: this.audioFormat,
audioQuality: this.audioQuality,
isPlaylist: this.isPlaylist,
playlistId: this.playlistId,
playlistTitle: this.playlistTitle,
playlistIndex: this.playlistIndex,
playlistTotal: this.playlistTotal
}
}
/**
* Set initial download data
* @param {Object} data
*/
setData(data) {
this.id = uuidv4()
this.url = data.url
this.libraryId = data.libraryId
this.libraryFolderId = data.libraryFolderId
this.userId = data.userId
this.audioFormat = data.audioFormat || 'mp3'
this.audioQuality = data.audioQuality || 'best'
this.isPlaylist = data.isPlaylist || false
this.playlistId = data.playlistId || null
this.createdAt = Date.now()
this.status = 'pending'
}
/**
* Set metadata from yt-dlp info
* @param {Object} info - yt-dlp video info
*/
setMetadata(info) {
this.videoId = info.id || null
this.title = info.title || 'Unknown'
this.uploader = info.uploader || info.channel || 'Unknown'
this.uploaderUrl = info.uploader_url || info.channel_url || null
this.description = info.description || null
this.duration = info.duration || null
this.thumbnailUrl = info.thumbnail || null
this.uploadDate = info.upload_date || null
if (info.playlist_title) {
this.playlistTitle = info.playlist_title
this.playlistIndex = info.playlist_index || null
this.playlistTotal = info.playlist_count || null
}
}
/**
* Set target paths for download
* @param {string} libraryFolderPath
*/
setTargetPaths(libraryFolderPath) {
// Create directory structure: <Library Folder>/<Uploader>/<Video Title>/
const sanitizedUploader = sanitizeFilename(this.uploader || 'Unknown')
const sanitizedTitle = sanitizeFilename(this.title || 'Unknown')
this.targetDirectory = filePathToPOSIX(Path.join(libraryFolderPath, sanitizedUploader, sanitizedTitle))
this.targetFilename = `${sanitizedTitle}.${this.audioFormat}`
this.targetPath = filePathToPOSIX(Path.join(this.targetDirectory, this.targetFilename))
this.audioFilePath = this.targetPath
}
/**
* Update download progress
* @param {number} progress - 0-100
*/
setProgress(progress) {
this.progress = Math.min(100, Math.max(0, progress))
}
/**
* Set download status
* @param {string} status
*/
setStatus(status) {
this.status = status
if (status === 'downloading' && !this.startedAt) {
this.startedAt = Date.now()
}
if ((status === 'completed' || status === 'failed') && !this.finishedAt) {
this.finishedAt = Date.now()
}
}
/**
* Set error message
* @param {string} error
*/
setError(error) {
this.error = error
this.status = 'failed'
this.finishedAt = Date.now()
}
/**
* Mark download as completed
*/
setCompleted() {
this.status = 'completed'
this.progress = 100
this.finishedAt = Date.now()
}
get isFinished() {
return this.status === 'completed' || this.status === 'failed'
}
get isPending() {
return this.status === 'pending'
}
get isActive() {
return this.status === 'downloading' || this.status === 'processing'
}
}
module.exports = YouTubeDownload