audiobookshelf/server/controllers/YouTubeDownloadController.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

166 lines
4.8 KiB
JavaScript

const { Request, Response } = require('express')
const Logger = require('../Logger')
const Database = require('../Database')
const { isValidYouTubeUrl } = require('../utils/youtubeUtils')
const YouTubeDownloadManager = require('../managers/YouTubeDownloadManager')
/**
* @typedef RequestUserObject
* @property {import('../models/User')} user
*
* @typedef {Request & RequestUserObject} RequestWithUser
*/
class YouTubeDownloadController {
/**
* POST /api/youtube/download
* Start a YouTube download
*
* @param {RequestWithUser} req
* @param {Response} res
*/
async downloadFromYouTube(req, res) {
// Admin-only access
if (!req.user.isAdminOrUp) {
Logger.error(`[YouTubeDownloadController] Non-admin user "${req.user.username}" attempted to download from YouTube`)
return res.sendStatus(403)
}
const { url, libraryId, folderId, options = {} } = req.body
// Validate required fields
if (!url) {
return res.status(400).send('YouTube URL is required')
}
if (!libraryId) {
return res.status(400).send('Library ID is required')
}
if (!folderId) {
return res.status(400).send('Folder ID is required')
}
// Validate YouTube URL
if (!isValidYouTubeUrl(url)) {
Logger.error(`[YouTubeDownloadController] Invalid YouTube URL: ${url}`)
return res.status(400).send('Invalid YouTube URL')
}
// Verify library exists
const library = await Database.libraryModel.findByPk(libraryId)
if (!library) {
Logger.error(`[YouTubeDownloadController] Library not found: ${libraryId}`)
return res.status(404).send('Library not found')
}
// Verify folder exists
const folder = await Database.libraryFolderModel.findByPk(folderId)
if (!folder) {
Logger.error(`[YouTubeDownloadController] Folder not found: ${folderId}`)
return res.status(404).send('Folder not found')
}
// Verify folder belongs to library
if (folder.libraryId !== libraryId) {
Logger.error(`[YouTubeDownloadController] Folder ${folderId} does not belong to library ${libraryId}`)
return res.status(400).send('Folder does not belong to the specified library')
}
try {
const downloadOptions = {
url,
libraryId,
libraryFolderId: folderId,
userId: req.user.id,
audioFormat: 'mp3', // Fixed to MP3 as per requirements
audioQuality: options.audioQuality || 'best'
}
const result = await YouTubeDownloadManager.downloadFromYouTube(downloadOptions)
Logger.info(`[YouTubeDownloadController] Download started by ${req.user.username}: ${url}`)
res.json({
success: true,
...result
})
} catch (error) {
Logger.error(`[YouTubeDownloadController] Download failed:`, error)
res.status(500).send(error.message || 'Failed to start download')
}
}
/**
* GET /api/youtube/queue
* Get current download queue
*
* @param {RequestWithUser} req
* @param {Response} res
*/
async getDownloadQueue(req, res) {
// Admin-only access
if (!req.user.isAdminOrUp) {
Logger.error(`[YouTubeDownloadController] Non-admin user "${req.user.username}" attempted to view download queue`)
return res.sendStatus(403)
}
const { libraryId } = req.query
const queue = YouTubeDownloadManager.getDownloadsInQueue(libraryId)
const current = YouTubeDownloadManager.currentDownload
res.json({
queue: queue.map(d => d.toJSONForClient()),
current: current ? current.toJSONForClient() : null
})
}
/**
* DELETE /api/youtube/download/:downloadId
* Cancel a download
*
* @param {RequestWithUser} req
* @param {Response} res
*/
async cancelDownload(req, res) {
// Admin-only access
if (!req.user.isAdminOrUp) {
Logger.error(`[YouTubeDownloadController] Non-admin user "${req.user.username}" attempted to cancel download`)
return res.sendStatus(403)
}
const { downloadId } = req.params
if (!downloadId) {
return res.status(400).send('Download ID is required')
}
const canceled = YouTubeDownloadManager.cancelDownload(downloadId)
if (canceled) {
Logger.info(`[YouTubeDownloadController] Download canceled by ${req.user.username}: ${downloadId}`)
res.json({ success: true, message: 'Download canceled' })
} else {
res.status(404).send('Download not found')
}
}
/**
* Middleware to check user permissions
*
* @param {RequestWithUser} req
* @param {Response} res
* @param {Function} next
*/
middleware(req, res, next) {
if (!req.user.isAdminOrUp) {
Logger.error(`[YouTubeDownloadController] Non-admin user "${req.user.username}" attempted to access YouTube download feature`)
return res.sendStatus(403)
}
next()
}
}
module.exports = new YouTubeDownloadController()