mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-07-08 18:31:43 +00:00
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:
parent
122fc34a75
commit
aaf87821a3
11 changed files with 1253 additions and 2 deletions
|
|
@ -36,6 +36,12 @@
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
</nuxt-link>
|
</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">
|
<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">
|
<ui-tooltip :text="$strings.HeaderSettings" direction="bottom" class="flex items-center">
|
||||||
<span class="material-symbols text-2xl" aria-label="System Settings" role="button"></span>
|
<span class="material-symbols text-2xl" aria-label="System Settings" role="button"></span>
|
||||||
|
|
@ -193,6 +199,9 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
openYouTubeDownloadModal() {
|
||||||
|
this.$store.commit('globals/setShowYouTubeDownloadModal', true)
|
||||||
|
},
|
||||||
requestBatchQuickEmbed() {
|
requestBatchQuickEmbed() {
|
||||||
const payload = {
|
const payload = {
|
||||||
message: this.$strings.MessageConfirmQuickEmbed,
|
message: this.$strings.MessageConfirmQuickEmbed,
|
||||||
|
|
|
||||||
202
client/components/modals/YouTubeDownloadModal.vue
Normal file
202
client/components/modals/YouTubeDownloadModal.vue
Normal 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>
|
||||||
|
|
@ -21,6 +21,7 @@
|
||||||
<modals-rssfeed-open-close-modal />
|
<modals-rssfeed-open-close-modal />
|
||||||
<modals-raw-cover-preview-modal />
|
<modals-raw-cover-preview-modal />
|
||||||
<modals-share-modal />
|
<modals-share-modal />
|
||||||
|
<modals-you-tube-download-modal />
|
||||||
<prompt-confirm />
|
<prompt-confirm />
|
||||||
<readers-reader />
|
<readers-reader />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -379,6 +380,26 @@ export default {
|
||||||
// Refresh providers cache
|
// Refresh providers cache
|
||||||
this.$store.dispatch('scanners/refreshProviders')
|
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() {
|
initializeSocket() {
|
||||||
if (this.$root.socket) {
|
if (this.$root.socket) {
|
||||||
// Can happen in dev due to hot reload
|
// Can happen in dev due to hot reload
|
||||||
|
|
@ -477,6 +498,13 @@ export default {
|
||||||
// Custom metadata provider Listeners
|
// Custom metadata provider Listeners
|
||||||
this.socket.on('custom_metadata_provider_added', this.customMetadataProviderAdded)
|
this.socket.on('custom_metadata_provider_added', this.customMetadataProviderAdded)
|
||||||
this.socket.on('custom_metadata_provider_removed', this.customMetadataProviderRemoved)
|
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) {
|
showUpdateToast(versionData) {
|
||||||
var ignoreVersion = localStorage.getItem('ignoreVersion')
|
var ignoreVersion = localStorage.getItem('ignoreVersion')
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ export const state = () => ({
|
||||||
showShareModal: false,
|
showShareModal: false,
|
||||||
showConfirmPrompt: false,
|
showConfirmPrompt: false,
|
||||||
showRawCoverPreviewModal: false,
|
showRawCoverPreviewModal: false,
|
||||||
|
showYouTubeDownloadModal: false,
|
||||||
confirmPromptOptions: null,
|
confirmPromptOptions: null,
|
||||||
showEditAuthorModal: false,
|
showEditAuthorModal: false,
|
||||||
rssFeedEntity: null,
|
rssFeedEntity: null,
|
||||||
|
|
@ -171,6 +172,9 @@ export const mutations = {
|
||||||
state.selectedRawCoverUrl = rawCoverUrl
|
state.selectedRawCoverUrl = rawCoverUrl
|
||||||
state.showRawCoverPreviewModal = true
|
state.showRawCoverPreviewModal = true
|
||||||
},
|
},
|
||||||
|
setShowYouTubeDownloadModal(state, val) {
|
||||||
|
state.showYouTubeDownloadModal = val
|
||||||
|
},
|
||||||
setEditCollection(state, collection) {
|
setEditCollection(state, collection) {
|
||||||
state.selectedCollection = collection
|
state.selectedCollection = collection
|
||||||
state.showEditCollectionModal = true
|
state.showEditCollectionModal = true
|
||||||
|
|
|
||||||
9
package-lock.json
generated
9
package-lock.json
generated
|
|
@ -28,7 +28,8 @@
|
||||||
"socket.io": "^4.5.4",
|
"socket.io": "^4.5.4",
|
||||||
"sqlite3": "^5.1.7",
|
"sqlite3": "^5.1.7",
|
||||||
"ssrf-req-filter": "^1.1.0",
|
"ssrf-req-filter": "^1.1.0",
|
||||||
"xml2js": "^0.5.0"
|
"xml2js": "^0.5.0",
|
||||||
|
"yt-dlp-wrap": "^2.3.12"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"audiobookshelf": "index.js"
|
"audiobookshelf": "index.js"
|
||||||
|
|
@ -5587,6 +5588,12 @@
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,8 @@
|
||||||
"socket.io": "^4.5.4",
|
"socket.io": "^4.5.4",
|
||||||
"sqlite3": "^5.1.7",
|
"sqlite3": "^5.1.7",
|
||||||
"ssrf-req-filter": "^1.1.0",
|
"ssrf-req-filter": "^1.1.0",
|
||||||
"xml2js": "^0.5.0"
|
"xml2js": "^0.5.0",
|
||||||
|
"yt-dlp-wrap": "^2.3.12"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"chai": "^4.3.10",
|
"chai": "^4.3.10",
|
||||||
|
|
|
||||||
166
server/controllers/YouTubeDownloadController.js
Normal file
166
server/controllers/YouTubeDownloadController.js
Normal 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()
|
||||||
486
server/managers/YouTubeDownloadManager.js
Normal file
486
server/managers/YouTubeDownloadManager.js
Normal 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()
|
||||||
196
server/objects/YouTubeDownload.js
Normal file
196
server/objects/YouTubeDownload.js
Normal 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
|
||||||
|
|
@ -35,6 +35,7 @@ const MiscController = require('../controllers/MiscController')
|
||||||
const ShareController = require('../controllers/ShareController')
|
const ShareController = require('../controllers/ShareController')
|
||||||
const StatsController = require('../controllers/StatsController')
|
const StatsController = require('../controllers/StatsController')
|
||||||
const ApiKeyController = require('../controllers/ApiKeyController')
|
const ApiKeyController = require('../controllers/ApiKeyController')
|
||||||
|
const YouTubeDownloadController = require('../controllers/YouTubeDownloadController')
|
||||||
|
|
||||||
class ApiRouter {
|
class ApiRouter {
|
||||||
constructor(Server) {
|
constructor(Server) {
|
||||||
|
|
@ -335,6 +336,13 @@ class ApiRouter {
|
||||||
this.router.patch('/api-keys/:id', ApiKeyController.middleware.bind(this), ApiKeyController.update.bind(this))
|
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))
|
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
|
// Misc Routes
|
||||||
//
|
//
|
||||||
|
|
|
||||||
144
server/utils/youtubeUtils.js
Normal file
144
server/utils/youtubeUtils.js
Normal 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
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue