diff --git a/client/components/app/Appbar.vue b/client/components/app/Appbar.vue index f74134041..d9c70015e 100644 --- a/client/components/app/Appbar.vue +++ b/client/components/app/Appbar.vue @@ -36,6 +36,12 @@ +
+ + download + +
+ @@ -193,6 +199,9 @@ export default { } }, methods: { + openYouTubeDownloadModal() { + this.$store.commit('globals/setShowYouTubeDownloadModal', true) + }, requestBatchQuickEmbed() { const payload = { message: this.$strings.MessageConfirmQuickEmbed, diff --git a/client/components/modals/YouTubeDownloadModal.vue b/client/components/modals/YouTubeDownloadModal.vue new file mode 100644 index 000000000..22e16edcb --- /dev/null +++ b/client/components/modals/YouTubeDownloadModal.vue @@ -0,0 +1,202 @@ + + + diff --git a/client/layouts/default.vue b/client/layouts/default.vue index 75753b214..15c17d13c 100644 --- a/client/layouts/default.vue +++ b/client/layouts/default.vue @@ -21,6 +21,7 @@ + @@ -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') diff --git a/client/store/globals.js b/client/store/globals.js index 7b416196a..a584e29ea 100644 --- a/client/store/globals.js +++ b/client/store/globals.js @@ -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 diff --git a/package-lock.json b/package-lock.json index 08707893d..68f0c1218 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" } } } diff --git a/package.json b/package.json index 3ee3fb391..ae6974b7e 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/server/controllers/YouTubeDownloadController.js b/server/controllers/YouTubeDownloadController.js new file mode 100644 index 000000000..2063921c0 --- /dev/null +++ b/server/controllers/YouTubeDownloadController.js @@ -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() diff --git a/server/managers/YouTubeDownloadManager.js b/server/managers/YouTubeDownloadManager.js new file mode 100644 index 000000000..ed68e1d97 --- /dev/null +++ b/server/managers/YouTubeDownloadManager.js @@ -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} + */ + 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} + */ + 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() diff --git a/server/objects/YouTubeDownload.js b/server/objects/YouTubeDownload.js new file mode 100644 index 000000000..cfc746762 --- /dev/null +++ b/server/objects/YouTubeDownload.js @@ -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: //