audiobookshelf/client/components/modals/YouTubeDownloadModal.vue
Claude aaf87821a3
Add YouTube download feature with yt-dlp integration
This commit implements a comprehensive YouTube download feature that allows
administrators to download audio from YouTube videos and playlists.

Backend Changes:
- Add yt-dlp-wrap npm dependency for YouTube downloading
- Create YouTubeDownloadManager to handle download queue and execution
- Create YouTubeDownloadController with API endpoints for download/queue/cancel
- Add YouTube utility functions for URL validation and metadata extraction
- Create YouTubeDownload object model for tracking download state
- Add API routes: POST /api/youtube/download, GET /api/youtube/queue, DELETE /api/youtube/download/:id
- Implement playlist support - automatically queue all videos from playlist URLs
- Download audio in MP3 format with best quality
- Organize files in subfolder structure: <Uploader>/<Video Title>/
- Extract metadata and create library items automatically
- Real-time progress updates via Socket.io

Frontend Changes:
- Create YouTubeDownloadModal component for user-friendly download interface
- Add download button to Appbar header (admin-only, visible with current library)
- Update Vuex store with modal state management
- Register modal globally in default layout
- Implement Socket.io listeners for download events (started, progress, completed, failed, queued)
- Show toast notifications for download status updates

Features:
- Admin-only access control
- Support for single videos and playlists
- Audio quality selection (best, 320kbps, 256kbps, 192kbps, 128kbps)
- Library and folder selection
- Download queue management
- Real-time progress tracking
- Automatic thumbnail download as cover image
- Automatic library item creation with metadata
- Error handling and user feedback

Technical Details:
- Uses yt-dlp for reliable YouTube downloading
- Integrates with existing task management system
- Respects library folder permissions
- Follows existing code patterns (similar to podcast downloads)
- Socket.io events for real-time updates
2025-12-31 01:32:37 +00:00

202 lines
5.6 KiB
Vue

<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>