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
This commit is contained in:
Claude 2025-12-31 01:32:37 +00:00
parent 122fc34a75
commit aaf87821a3
No known key found for this signature in database
11 changed files with 1253 additions and 2 deletions

View file

@ -36,6 +36,12 @@
</ui-tooltip>
</nuxt-link>
<div v-if="userIsAdminOrUp && currentLibrary" @click="openYouTubeDownloadModal" class="hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1">
<ui-tooltip text="Download from YouTube" direction="bottom" class="flex items-center">
<span class="material-symbols text-2xl" aria-label="YouTube Download" role="button">download</span>
</ui-tooltip>
</div>
<nuxt-link v-if="userIsAdminOrUp" to="/config" class="hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1">
<ui-tooltip :text="$strings.HeaderSettings" direction="bottom" class="flex items-center">
<span class="material-symbols text-2xl" aria-label="System Settings" role="button">&#xe8b8;</span>
@ -193,6 +199,9 @@ export default {
}
},
methods: {
openYouTubeDownloadModal() {
this.$store.commit('globals/setShowYouTubeDownloadModal', true)
},
requestBatchQuickEmbed() {
const payload = {
message: this.$strings.MessageConfirmQuickEmbed,

View file

@ -0,0 +1,202 @@
<template>
<modals-modal v-model="show" name="youtube-download-modal" :width="800" :processing="processing">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden pointer-events-none">
<p class="text-3xl text-white truncate">Download from YouTube</p>
</div>
</template>
<div class="p-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300">
<!-- YouTube URL Input -->
<div class="mb-4">
<ui-text-input-with-label
v-model="youtubeUrl"
label="YouTube URL"
placeholder="https://www.youtube.com/watch?v=... or https://www.youtube.com/playlist?list=..."
@input="urlChanged"
/>
<p v-if="isPlaylist" class="text-xs text-gray-400 mt-1">Playlist detected - all videos will be downloaded</p>
</div>
<!-- Library Selection -->
<div class="mb-4">
<ui-dropdown
v-model="selectedLibraryId"
:items="libraryItems"
label="Library"
@input="libraryChanged"
/>
</div>
<!-- Folder Selection -->
<div class="mb-4">
<ui-dropdown
v-model="selectedFolderId"
:items="folderItems"
label="Folder"
:disabled="!selectedLibraryId"
/>
</div>
<!-- Audio Quality Selection -->
<div class="mb-4">
<ui-dropdown
v-model="audioQuality"
:items="qualityItems"
label="Audio Quality"
/>
</div>
<!-- Info text -->
<div class="mb-4 p-3 bg-info bg-opacity-20 border border-info rounded">
<p class="text-sm">
<span class="font-semibold">Note:</span> Audio will be downloaded in MP3 format. Files will be organized as: <span class="font-mono text-xs bg-black bg-opacity-30 px-1 py-0.5 rounded">Uploader/Video Title/</span>
</p>
</div>
<!-- Buttons -->
<div class="flex justify-end pt-4">
<ui-btn @click="show = false" class="mr-2">Cancel</ui-btn>
<ui-btn color="success" :loading="processing" @click="submit">Download</ui-btn>
</div>
</div>
</modals-modal>
</template>
<script>
export default {
data() {
return {
processing: false,
youtubeUrl: '',
selectedLibraryId: null,
selectedFolderId: null,
audioQuality: 'best'
}
},
computed: {
show: {
get() {
return this.$store.state.globals.showYouTubeDownloadModal
},
set(val) {
this.$store.commit('globals/setShowYouTubeDownloadModal', val)
}
},
libraries() {
return this.$store.state.libraries.libraries || []
},
libraryItems() {
return this.libraries.map(lib => ({
value: lib.id,
text: lib.name
}))
},
selectedLibrary() {
return this.libraries.find(lib => lib.id === this.selectedLibraryId)
},
folderItems() {
if (!this.selectedLibrary) return []
return (this.selectedLibrary.folders || []).map(folder => ({
value: folder.id,
text: folder.fullPath
}))
},
qualityItems() {
return [
{ value: 'best', text: 'Best Quality' },
{ value: '320', text: '320 kbps' },
{ value: '256', text: '256 kbps' },
{ value: '192', text: '192 kbps' },
{ value: '128', text: '128 kbps' }
]
},
isPlaylist() {
if (!this.youtubeUrl) return false
try {
const url = new URL(this.youtubeUrl)
return url.searchParams.has('list') || url.pathname.includes('/playlist')
} catch {
return false
}
}
},
methods: {
urlChanged() {
// Validate URL format as user types
// Could add real-time validation here if needed
},
libraryChanged() {
this.selectedFolderId = null
if (this.selectedLibrary && this.selectedLibrary.folders?.length) {
this.selectedFolderId = this.selectedLibrary.folders[0].id
}
},
async submit() {
if (!this.youtubeUrl) {
this.$toast.error('YouTube URL is required')
return
}
if (!this.selectedLibraryId || !this.selectedFolderId) {
this.$toast.error('Please select a library and folder')
return
}
this.processing = true
const payload = {
url: this.youtubeUrl,
libraryId: this.selectedLibraryId,
folderId: this.selectedFolderId,
options: {
audioQuality: this.audioQuality
}
}
this.$axios
.$post('/api/youtube/download', payload)
.then((response) => {
if (response.isPlaylist) {
this.$toast.success(`Queued ${response.count} videos from playlist`)
} else {
this.$toast.success('YouTube download started')
}
this.show = false
this.reset()
})
.catch((error) => {
console.error('YouTube download failed', error)
const errorMsg = error.response?.data || 'YouTube download failed'
this.$toast.error(errorMsg)
})
.finally(() => {
this.processing = false
})
},
reset() {
this.youtubeUrl = ''
this.selectedLibraryId = null
this.selectedFolderId = null
this.audioQuality = 'best'
},
init() {
// Set default library from current library
const currentLibraryId = this.$store.state.libraries.currentLibraryId
if (currentLibraryId) {
this.selectedLibraryId = currentLibraryId
this.libraryChanged()
}
}
},
watch: {
show: {
immediate: true,
handler(newVal) {
if (newVal) {
this.init()
}
}
}
}
}
</script>

View file

@ -21,6 +21,7 @@
<modals-rssfeed-open-close-modal />
<modals-raw-cover-preview-modal />
<modals-share-modal />
<modals-you-tube-download-modal />
<prompt-confirm />
<readers-reader />
</div>
@ -379,6 +380,26 @@ export default {
// Refresh providers cache
this.$store.dispatch('scanners/refreshProviders')
},
youtubeDownloadStarted(download) {
console.log('YouTube download started:', download)
this.$toast.info(`Downloading: ${download.title || download.url}`)
},
youtubeDownloadProgress(data) {
// Optional: Could show progress in a notification or widget
console.log('YouTube download progress:', data.progress, data.title)
},
youtubeDownloadCompleted(download) {
console.log('YouTube download completed:', download)
this.$toast.success(`Download completed: ${download.title || 'YouTube video'}`)
},
youtubeDownloadFailed(download) {
console.log('YouTube download failed:', download)
this.$toast.error(`Download failed: ${download.error || 'Unknown error'}`)
},
youtubeDownloadQueued(download) {
console.log('YouTube download queued:', download)
this.$toast.info(`Queued: ${download.title || download.url}`)
},
initializeSocket() {
if (this.$root.socket) {
// Can happen in dev due to hot reload
@ -477,6 +498,13 @@ export default {
// Custom metadata provider Listeners
this.socket.on('custom_metadata_provider_added', this.customMetadataProviderAdded)
this.socket.on('custom_metadata_provider_removed', this.customMetadataProviderRemoved)
// YouTube Download Listeners
this.socket.on('youtube_download_started', this.youtubeDownloadStarted)
this.socket.on('youtube_download_progress', this.youtubeDownloadProgress)
this.socket.on('youtube_download_completed', this.youtubeDownloadCompleted)
this.socket.on('youtube_download_failed', this.youtubeDownloadFailed)
this.socket.on('youtube_download_queued', this.youtubeDownloadQueued)
},
showUpdateToast(versionData) {
var ignoreVersion = localStorage.getItem('ignoreVersion')

View file

@ -13,6 +13,7 @@ export const state = () => ({
showShareModal: false,
showConfirmPrompt: false,
showRawCoverPreviewModal: false,
showYouTubeDownloadModal: false,
confirmPromptOptions: null,
showEditAuthorModal: false,
rssFeedEntity: null,
@ -171,6 +172,9 @@ export const mutations = {
state.selectedRawCoverUrl = rawCoverUrl
state.showRawCoverPreviewModal = true
},
setShowYouTubeDownloadModal(state, val) {
state.showYouTubeDownloadModal = val
},
setEditCollection(state, collection) {
state.selectedCollection = collection
state.showEditCollectionModal = true

9
package-lock.json generated
View file

@ -28,7 +28,8 @@
"socket.io": "^4.5.4",
"sqlite3": "^5.1.7",
"ssrf-req-filter": "^1.1.0",
"xml2js": "^0.5.0"
"xml2js": "^0.5.0",
"yt-dlp-wrap": "^2.3.12"
},
"bin": {
"audiobookshelf": "index.js"
@ -5587,6 +5588,12 @@
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/yt-dlp-wrap": {
"version": "2.3.12",
"resolved": "https://registry.npmjs.org/yt-dlp-wrap/-/yt-dlp-wrap-2.3.12.tgz",
"integrity": "sha512-P8fJ+6M1YjukyJENCTviNLiZ8mokxprR54ho3DsSKPWDcac489OjRiStGEARJr6un6ETS6goTn4CWl/b/rM3aA==",
"license": "MIT"
}
}
}

View file

@ -57,7 +57,8 @@
"socket.io": "^4.5.4",
"sqlite3": "^5.1.7",
"ssrf-req-filter": "^1.1.0",
"xml2js": "^0.5.0"
"xml2js": "^0.5.0",
"yt-dlp-wrap": "^2.3.12"
},
"devDependencies": {
"chai": "^4.3.10",

View file

@ -0,0 +1,166 @@
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()

View file

@ -0,0 +1,486 @@
const Path = require('path')
const fs = require('../libs/fsExtra')
const YTDlpWrap = require('yt-dlp-wrap').default
const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority')
const Database = require('../Database')
const Watcher = require('../Watcher')
const { isValidYouTubeUrl, isPlaylistUrl, sanitizeTitleForFilename } = require('../utils/youtubeUtils')
const { downloadFile } = require('../utils/fileUtils')
const prober = require('../utils/prober')
const TaskManager = require('./TaskManager')
const CoverManager = require('./CoverManager')
const YouTubeDownload = require('../objects/YouTubeDownload')
const LibraryFile = require('../objects/files/LibraryFile')
const AudioFile = require('../objects/files/AudioFile')
class YouTubeDownloadManager {
constructor() {
/** @type {YouTubeDownload[]} */
this.downloadQueue = []
/** @type {YouTubeDownload} */
this.currentDownload = null
this.ytDlp = null
this.initYtDlp()
}
/**
* Initialize yt-dlp wrapper
*/
async initYtDlp() {
try {
this.ytDlp = new YTDlpWrap()
Logger.info('[YouTubeDownloadManager] yt-dlp initialized successfully')
} catch (error) {
Logger.error('[YouTubeDownloadManager] Failed to initialize yt-dlp:', error)
}
}
/**
* Get downloads in queue for a library
* @param {string} libraryId
* @returns {YouTubeDownload[]}
*/
getDownloadsInQueue(libraryId = null) {
if (!libraryId) return this.downloadQueue
return this.downloadQueue.filter(d => d.libraryId === libraryId)
}
/**
* Clear download queue
* @param {string} libraryId - Optional library ID to clear only that library's downloads
*/
clearDownloadQueue(libraryId = null) {
if (!this.downloadQueue.length) return
if (!libraryId) {
Logger.info(`[YouTubeDownloadManager] Clearing all downloads in queue (${this.downloadQueue.length})`)
this.downloadQueue = []
} else {
const itemDownloads = this.getDownloadsInQueue(libraryId)
Logger.info(`[YouTubeDownloadManager] Clearing downloads in queue for library "${libraryId}" (${itemDownloads.length})`)
this.downloadQueue = this.downloadQueue.filter(d => d.libraryId !== libraryId)
SocketAuthority.emitter('youtube_download_queue_cleared', libraryId)
}
}
/**
* Download from YouTube URL
* @param {Object} options
* @param {string} options.url - YouTube URL
* @param {string} options.libraryId - Library ID
* @param {string} options.libraryFolderId - Library folder ID
* @param {string} options.userId - User ID
* @param {string} options.audioFormat - Audio format (default: mp3)
* @param {string} options.audioQuality - Audio quality (default: best)
*/
async downloadFromYouTube(options) {
const { url, libraryId, libraryFolderId, userId, audioFormat = 'mp3', audioQuality = 'best' } = options
// Validate URL
if (!isValidYouTubeUrl(url)) {
throw new Error('Invalid YouTube URL')
}
// Check if it's a playlist
const isPlaylist = isPlaylistUrl(url)
if (isPlaylist) {
// Get playlist info first
try {
const playlistInfo = await this.getPlaylistInfo(url)
Logger.info(`[YouTubeDownloadManager] Playlist detected: ${playlistInfo.title} (${playlistInfo.entries?.length || 0} videos)`)
// Queue each video in the playlist
for (const entry of playlistInfo.entries || []) {
const videoUrl = `https://www.youtube.com/watch?v=${entry.id}`
const download = new YouTubeDownload()
download.setData({
url: videoUrl,
libraryId,
libraryFolderId,
userId,
audioFormat,
audioQuality,
isPlaylist: true,
playlistId: playlistInfo.id
})
await this.startDownload(download)
}
return {
success: true,
message: `Queued ${playlistInfo.entries?.length || 0} videos from playlist`,
isPlaylist: true,
count: playlistInfo.entries?.length || 0
}
} catch (error) {
Logger.error('[YouTubeDownloadManager] Failed to get playlist info:', error)
throw new Error(`Failed to process playlist: ${error.message}`)
}
} else {
// Single video download
const download = new YouTubeDownload()
download.setData({
url,
libraryId,
libraryFolderId,
userId,
audioFormat,
audioQuality
})
await this.startDownload(download)
return {
success: true,
message: 'Download started',
isPlaylist: false,
downloadId: download.id
}
}
}
/**
* Get playlist info using yt-dlp
* @param {string} url - Playlist URL
* @returns {Promise<Object>}
*/
async getPlaylistInfo(url) {
try {
const info = await this.ytDlp.getVideoInfo([url, '--flat-playlist'])
return info
} catch (error) {
Logger.error('[YouTubeDownloadManager] Failed to get playlist info:', error)
throw error
}
}
/**
* Start a download
* @param {YouTubeDownload} youtubeDownload
*/
async startDownload(youtubeDownload) {
// Check if already downloading or in queue
const existingDownload = this.downloadQueue.find(d => d.url === youtubeDownload.url && d.libraryId === youtubeDownload.libraryId)
if (existingDownload || (this.currentDownload && this.currentDownload.url === youtubeDownload.url)) {
Logger.warn(`[YouTubeDownloadManager] Video already in queue or downloading: ${youtubeDownload.url}`)
return
}
// If currently downloading, add to queue
if (this.currentDownload) {
this.downloadQueue.push(youtubeDownload)
SocketAuthority.emitter('youtube_download_queued', youtubeDownload.toJSONForClient())
Logger.info(`[YouTubeDownloadManager] Download queued: ${youtubeDownload.url}`)
return
}
// Start download immediately
this.currentDownload = youtubeDownload
SocketAuthority.emitter('youtube_download_started', youtubeDownload.toJSONForClient())
// Create task
const taskData = {
libraryId: youtubeDownload.libraryId,
libraryFolderId: youtubeDownload.libraryFolderId
}
const taskTitleString = {
text: 'Downloading from YouTube',
key: 'MessageDownloadingFromYouTube'
}
const taskDescriptionString = {
text: `Downloading from YouTube: ${youtubeDownload.url}`,
key: 'MessageTaskDownloadingFromYouTubeDescription',
subs: [youtubeDownload.url]
}
const task = TaskManager.createAndAddTask('youtube-download', taskTitleString, taskDescriptionString, false, taskData)
// Execute download
let success = false
try {
success = await this.executeDownload(youtubeDownload, task)
} catch (error) {
Logger.error(`[YouTubeDownloadManager] Download failed:`, error)
youtubeDownload.setError(error.message)
success = false
}
// Finish task
task.setFinished(success)
TaskManager.taskFinished(task)
// Emit completion event
if (success) {
SocketAuthority.emitter('youtube_download_completed', youtubeDownload.toJSONForClient())
} else {
SocketAuthority.emitter('youtube_download_failed', youtubeDownload.toJSONForClient())
}
// Start next download in queue
this.currentDownload = null
if (this.downloadQueue.length) {
const nextDownload = this.downloadQueue.shift()
await this.startDownload(nextDownload)
}
}
/**
* Execute the actual download
* @param {YouTubeDownload} download
* @param {Object} task
* @returns {Promise<boolean>}
*/
async executeDownload(download, task) {
try {
// Step 1: Get video metadata
Logger.info(`[YouTubeDownloadManager] Getting metadata for: ${download.url}`)
download.setStatus('downloading')
task.setDescription(`Getting video information...`)
const videoInfo = await this.ytDlp.getVideoInfo(download.url)
download.setMetadata(videoInfo)
Logger.info(`[YouTubeDownloadManager] Metadata retrieved: "${download.title}" by ${download.uploader}`)
// Step 2: Get library folder
const libraryFolder = await Database.libraryFolderModel.findByPk(download.libraryFolderId)
if (!libraryFolder) {
throw new Error('Library folder not found')
}
// Step 3: Set target paths
download.setTargetPaths(libraryFolder.path)
await fs.ensureDir(download.targetDirectory)
Logger.info(`[YouTubeDownloadManager] Downloading to: ${download.targetPath}`)
task.setDescription(`Downloading: ${download.title}`)
// Step 4: Download audio using yt-dlp
await this.downloadAudio(download)
// Step 5: Download thumbnail as cover
download.setStatus('processing')
task.setDescription(`Processing: ${download.title}`)
if (download.thumbnailUrl) {
try {
const coverFilename = 'cover.jpg'
const coverPath = Path.join(download.targetDirectory, coverFilename)
await downloadFile(download.thumbnailUrl, coverPath)
download.coverPath = coverPath
Logger.info(`[YouTubeDownloadManager] Downloaded cover image: ${coverPath}`)
} catch (error) {
Logger.warn(`[YouTubeDownloadManager] Failed to download thumbnail:`, error.message)
}
}
// Step 6: Probe audio file
const probeData = await prober.probe(download.audioFilePath)
Logger.debug(`[YouTubeDownloadManager] Audio file probed:`, probeData)
// Step 7: Create library item
await this.createLibraryItem(download, libraryFolder, probeData)
// Mark as completed
download.setCompleted()
task.setDescription(`Completed: ${download.title}`)
Logger.info(`[YouTubeDownloadManager] Download completed successfully: ${download.title}`)
return true
} catch (error) {
Logger.error(`[YouTubeDownloadManager] Download failed:`, error)
download.setError(error.message)
// Cleanup failed download
try {
if (download.targetDirectory && await fs.pathExists(download.targetDirectory)) {
await fs.remove(download.targetDirectory)
}
} catch (cleanupError) {
Logger.error('[YouTubeDownloadManager] Failed to cleanup:', cleanupError)
}
return false
}
}
/**
* Download audio using yt-dlp
* @param {YouTubeDownload} download
*/
async downloadAudio(download) {
return new Promise((resolve, reject) => {
const outputTemplate = Path.join(download.targetDirectory, '%(title)s.%(ext)s')
const ytDlpArgs = [
download.url,
'--extract-audio',
'--audio-format', download.audioFormat,
'--audio-quality', download.audioQuality === 'best' ? '0' : download.audioQuality,
'--add-metadata',
'--embed-thumbnail',
'--output', outputTemplate,
'--no-playlist', // Ensure single video download even if URL has playlist param
'--progress'
]
const ytDlpProcess = this.ytDlp.exec(ytDlpArgs)
ytDlpProcess.on('progress', (progress) => {
// Parse progress from yt-dlp output
if (progress.percent) {
download.setProgress(parseFloat(progress.percent))
SocketAuthority.emitter('youtube_download_progress', {
id: download.id,
progress: download.progress,
title: download.title
})
}
})
ytDlpProcess.on('close', async (code) => {
if (code === 0) {
// Download successful, find the downloaded file
try {
const files = await fs.readdir(download.targetDirectory)
const audioFile = files.find(f => f.endsWith(`.${download.audioFormat}`))
if (audioFile) {
download.audioFilePath = Path.join(download.targetDirectory, audioFile)
// Rename file to match target filename if different
if (audioFile !== download.targetFilename) {
const newPath = Path.join(download.targetDirectory, download.targetFilename)
await fs.rename(download.audioFilePath, newPath)
download.audioFilePath = newPath
}
download.setProgress(100)
Logger.info(`[YouTubeDownloadManager] Audio download completed: ${download.audioFilePath}`)
resolve()
} else {
reject(new Error('Downloaded audio file not found'))
}
} catch (error) {
reject(error)
}
} else {
reject(new Error(`yt-dlp process exited with code ${code}`))
}
})
ytDlpProcess.on('error', (error) => {
reject(error)
})
})
}
/**
* Create library item from download
* @param {YouTubeDownload} download
* @param {Object} libraryFolder
* @param {Object} probeData
*/
async createLibraryItem(download, libraryFolder, probeData) {
try {
// Ignore watcher while creating library item
if (Watcher) {
Watcher.addIgnoreDir(download.targetDirectory)
}
const library = await Database.libraryModel.findByPk(download.libraryId)
if (!library) {
throw new Error('Library not found')
}
// Create library item
const libraryItemData = {
path: download.targetDirectory,
relPath: download.targetDirectory.replace(libraryFolder.path, '').slice(1),
libraryId: download.libraryId,
folderId: download.libraryFolderId,
mediaType: library.mediaType || 'book', // Default to book for audiobooks
media: {
metadata: {
title: download.title,
author: download.uploader,
description: download.description,
publishedYear: download.uploadDate ? download.uploadDate.substring(0, 4) : null
}
}
}
// Add audio file
const audioFileData = {
metadata: {
filename: download.targetFilename,
ext: `.${download.audioFormat}`,
path: download.audioFilePath,
relPath: download.targetFilename,
size: (await fs.stat(download.audioFilePath)).size,
mtimeMs: Date.now(),
ctimeMs: Date.now(),
birthtimeMs: Date.now()
},
...probeData
}
// Create the library item
const libraryItem = await Database.libraryItemModel.create(libraryItemData)
Logger.info(`[YouTubeDownloadManager] Library item created: ${libraryItem.id}`)
// Emit library item added event
SocketAuthority.emitter('item_added', {
libraryItemId: libraryItem.id,
libraryId: download.libraryId
})
// Remove from watcher ignore
if (Watcher) {
Watcher.removeIgnoreDir(download.targetDirectory)
}
return libraryItem
} catch (error) {
Logger.error('[YouTubeDownloadManager] Failed to create library item:', error)
throw error
}
}
/**
* Cancel a download
* @param {string} downloadId
* @returns {boolean}
*/
cancelDownload(downloadId) {
// Check if it's the current download
if (this.currentDownload && this.currentDownload.id === downloadId) {
Logger.info(`[YouTubeDownloadManager] Canceling current download: ${this.currentDownload.title}`)
// Note: yt-dlp process cancellation would need additional implementation
this.currentDownload.setError('Download canceled by user')
this.currentDownload = null
return true
}
// Check if it's in the queue
const queueIndex = this.downloadQueue.findIndex(d => d.id === downloadId)
if (queueIndex >= 0) {
const download = this.downloadQueue[queueIndex]
Logger.info(`[YouTubeDownloadManager] Removing from queue: ${download.title}`)
this.downloadQueue.splice(queueIndex, 1)
SocketAuthority.emitter('youtube_download_removed', { id: downloadId })
return true
}
return false
}
}
module.exports = new YouTubeDownloadManager()

View file

@ -0,0 +1,196 @@
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

View file

@ -35,6 +35,7 @@ const MiscController = require('../controllers/MiscController')
const ShareController = require('../controllers/ShareController')
const StatsController = require('../controllers/StatsController')
const ApiKeyController = require('../controllers/ApiKeyController')
const YouTubeDownloadController = require('../controllers/YouTubeDownloadController')
class ApiRouter {
constructor(Server) {
@ -335,6 +336,13 @@ class ApiRouter {
this.router.patch('/api-keys/:id', ApiKeyController.middleware.bind(this), ApiKeyController.update.bind(this))
this.router.delete('/api-keys/:id', ApiKeyController.middleware.bind(this), ApiKeyController.delete.bind(this))
//
// YouTube Download Routes
//
this.router.post('/youtube/download', YouTubeDownloadController.downloadFromYouTube.bind(this))
this.router.get('/youtube/queue', YouTubeDownloadController.getDownloadQueue.bind(this))
this.router.delete('/youtube/download/:downloadId', YouTubeDownloadController.cancelDownload.bind(this))
//
// Misc Routes
//

View file

@ -0,0 +1,144 @@
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
}