mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-07-07 18:01:42 +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>
|
||||
</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"></span>
|
||||
|
|
@ -193,6 +199,9 @@ export default {
|
|||
}
|
||||
},
|
||||
methods: {
|
||||
openYouTubeDownloadModal() {
|
||||
this.$store.commit('globals/setShowYouTubeDownloadModal', true)
|
||||
},
|
||||
requestBatchQuickEmbed() {
|
||||
const payload = {
|
||||
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-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')
|
||||
|
|
|
|||
|
|
@ -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
9
package-lock.json
generated
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
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 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
|
||||
//
|
||||
|
|
|
|||
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