mirror of
https://github.com/xpltdco/media-rip.git
synced 2026-06-02 12:24:29 -06:00
Playlist handling: - Playlists are split into individual jobs at enqueue time - Each entry downloads independently with its own progress tracking - Private/unavailable playlist entries detected and reported in preview - Individual jobs use noplaylist=True to prevent re-expansion Session persistence: - App.vue now calls fetchJobs() on mount to reload history from backend - Download history survives page refresh via session cookie Audio detection: - Domain-based detection for known audio sources (bandcamp, soundcloud) - Bandcamp albums now correctly trigger audio-only mode Bug fixes: - ProgressEvent accepts float for downloaded_bytes/total_bytes (fixes pydantic int_from_float validation errors from some extractors) - SSE job_update events now include error_message for failed jobs - Fixed test_health_queue_depth test to use direct DB insertion instead of POST endpoint (avoids yt-dlp side effects in test env)
625 lines
16 KiB
Vue
625 lines
16 KiB
Vue
<script setup lang="ts">
|
|
import { ref, computed, watch } from 'vue'
|
|
import { api } from '@/api/client'
|
|
import { useDownloadsStore } from '@/stores/downloads'
|
|
import FormatPicker from './FormatPicker.vue'
|
|
import type { FormatInfo, UrlInfo } from '@/api/types'
|
|
|
|
const store = useDownloadsStore()
|
|
|
|
const url = ref('')
|
|
const formats = ref<FormatInfo[]>([])
|
|
const selectedFormatId = ref<string | null>(null)
|
|
const isExtracting = ref(false)
|
|
const extractError = ref<string | null>(null)
|
|
const showOptions = ref(false)
|
|
|
|
// URL preview state
|
|
const urlInfo = ref<UrlInfo | null>(null)
|
|
const isLoadingInfo = ref(false)
|
|
const audioLocked = ref(false) // true when source is audio-only
|
|
|
|
type MediaType = 'video' | 'audio'
|
|
const mediaType = ref<MediaType>('video')
|
|
const outputFormat = ref<string>('auto')
|
|
|
|
const audioFormats = ['auto', 'mp3', 'wav', 'm4a', 'flac', 'opus'] as const
|
|
const videoFormats = ['auto', 'mp4', 'webm'] as const
|
|
|
|
const availableFormats = computed(() =>
|
|
mediaType.value === 'audio' ? audioFormats : videoFormats
|
|
)
|
|
|
|
// Reset output format when switching media type
|
|
watch(mediaType, () => {
|
|
outputFormat.value = 'auto'
|
|
})
|
|
|
|
/** Whether formats have been fetched for the current URL. */
|
|
const formatsReady = computed(() => formats.value.length > 0)
|
|
|
|
async function extractFormats(): Promise<void> {
|
|
const trimmed = url.value.trim()
|
|
if (!trimmed) return
|
|
|
|
isExtracting.value = true
|
|
extractError.value = null
|
|
formats.value = []
|
|
selectedFormatId.value = null
|
|
|
|
try {
|
|
formats.value = await api.getFormats(trimmed)
|
|
} catch (err: any) {
|
|
extractError.value = err.message || 'Failed to extract formats'
|
|
} finally {
|
|
isExtracting.value = false
|
|
}
|
|
}
|
|
|
|
async function submitDownload(): Promise<void> {
|
|
const trimmed = url.value.trim()
|
|
if (!trimmed) return
|
|
|
|
try {
|
|
await store.submitDownload({
|
|
url: trimmed,
|
|
format_id: selectedFormatId.value,
|
|
quality: mediaType.value === 'audio' && !selectedFormatId.value ? 'bestaudio' : null,
|
|
media_type: mediaType.value,
|
|
output_format: outputFormat.value === 'auto' ? null : outputFormat.value,
|
|
})
|
|
// Reset form on success
|
|
url.value = ''
|
|
formats.value = []
|
|
showOptions.value = false
|
|
selectedFormatId.value = null
|
|
extractError.value = null
|
|
mediaType.value = 'video'
|
|
outputFormat.value = 'auto'
|
|
urlInfo.value = null
|
|
audioLocked.value = false
|
|
} catch {
|
|
// Error already in store.submitError
|
|
}
|
|
}
|
|
|
|
function onFormatSelect(formatId: string | null): void {
|
|
selectedFormatId.value = formatId
|
|
}
|
|
|
|
function handlePaste(): void {
|
|
// Auto-extract on paste (populate formats + URL info silently in background)
|
|
setTimeout(() => {
|
|
if (url.value.trim()) {
|
|
extractFormats()
|
|
fetchUrlInfo()
|
|
}
|
|
}, 50)
|
|
}
|
|
|
|
async function fetchUrlInfo(): Promise<void> {
|
|
const trimmed = url.value.trim()
|
|
if (!trimmed) return
|
|
isLoadingInfo.value = true
|
|
urlInfo.value = null
|
|
audioLocked.value = false
|
|
try {
|
|
const info = await api.getUrlInfo(trimmed)
|
|
urlInfo.value = info
|
|
// Auto-switch to audio if the source is audio-only
|
|
if (info.is_audio_only) {
|
|
mediaType.value = 'audio'
|
|
audioLocked.value = true
|
|
}
|
|
} catch {
|
|
// Non-critical — preview is optional
|
|
} finally {
|
|
isLoadingInfo.value = false
|
|
}
|
|
}
|
|
|
|
function formatDuration(seconds: number | null): string {
|
|
if (!seconds) return ''
|
|
const m = Math.floor(seconds / 60)
|
|
const s = Math.floor(seconds % 60)
|
|
return `${m}:${s.toString().padStart(2, '0')}`
|
|
}
|
|
|
|
function toggleOptions(): void {
|
|
showOptions.value = !showOptions.value
|
|
// Extract formats when opening options if we haven't yet
|
|
if (showOptions.value && !formatsReady.value && url.value.trim() && !isExtracting.value) {
|
|
extractFormats()
|
|
}
|
|
}
|
|
|
|
function formatLabel(fmt: string): string {
|
|
if (fmt === 'auto') return 'Auto'
|
|
return fmt.toUpperCase()
|
|
}
|
|
|
|
function formatTooltip(fmt: string): string {
|
|
if (fmt !== 'auto') return `Convert to ${fmt.toUpperCase()}`
|
|
if (mediaType.value === 'audio') {
|
|
return 'Best quality audio in its native format (usually Opus/WebM)'
|
|
}
|
|
return 'Best quality video in its native format (usually WebM/MKV)'
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="url-input">
|
|
<!-- URL field -->
|
|
<input
|
|
v-model="url"
|
|
type="url"
|
|
placeholder="Paste a URL to download…"
|
|
class="url-field"
|
|
@paste="handlePaste"
|
|
@keydown.enter="submitDownload"
|
|
:disabled="isExtracting || store.isSubmitting"
|
|
/>
|
|
|
|
<!-- Action row: gear, media toggle, download button -->
|
|
<div class="action-row">
|
|
<button
|
|
class="btn-options"
|
|
:class="{ active: showOptions }"
|
|
@click="toggleOptions"
|
|
:disabled="!url.trim()"
|
|
title="Format options"
|
|
>
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
|
</button>
|
|
|
|
<div class="media-toggle">
|
|
<button
|
|
class="toggle-pill"
|
|
:class="{ active: mediaType === 'video', disabled: audioLocked }"
|
|
@click="!audioLocked && (mediaType = 'video')"
|
|
:title="audioLocked ? 'Source contains audio only' : 'Video'"
|
|
:disabled="audioLocked"
|
|
>
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2" ry="2"/></svg>
|
|
<span class="toggle-label">Video</span>
|
|
</button>
|
|
<button
|
|
class="toggle-pill"
|
|
:class="{ active: mediaType === 'audio' }"
|
|
@click="mediaType = 'audio'"
|
|
:title="'Audio'"
|
|
>
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
|
|
<span class="toggle-label">Audio</span>
|
|
</button>
|
|
</div>
|
|
|
|
<button
|
|
class="btn-download"
|
|
@click="submitDownload"
|
|
:disabled="!url.trim() || store.isSubmitting"
|
|
>
|
|
{{ store.isSubmitting ? 'Submitting…' : 'Download' }}
|
|
</button>
|
|
</div>
|
|
|
|
<div v-if="isLoadingInfo" class="url-preview loading">
|
|
<span class="spinner"></span>
|
|
Checking URL…
|
|
</div>
|
|
|
|
<!-- URL preview: show what will be downloaded -->
|
|
<div v-else-if="urlInfo" class="url-preview">
|
|
<div class="preview-header">
|
|
<span v-if="urlInfo.type === 'playlist'" class="preview-badge playlist">Playlist · {{ urlInfo.count }} items</span>
|
|
<span v-else class="preview-badge single">Single {{ audioLocked ? 'track' : 'video' }}</span>
|
|
<span v-if="audioLocked" class="preview-badge audio-only">Audio only</span>
|
|
<span v-if="urlInfo.title" class="preview-title">{{ urlInfo.title }}</span>
|
|
</div>
|
|
<div v-if="urlInfo.type === 'playlist' && urlInfo.entries.length" class="preview-entries">
|
|
<div
|
|
v-for="(entry, i) in urlInfo.entries.slice(0, 10)"
|
|
:key="i"
|
|
class="preview-entry"
|
|
>
|
|
<span class="entry-num">{{ i + 1 }}.</span>
|
|
<span class="entry-title">{{ entry.title }}</span>
|
|
<span v-if="entry.duration" class="entry-duration">{{ formatDuration(entry.duration) }}</span>
|
|
</div>
|
|
<div v-if="urlInfo.entries.length > 10" class="preview-more">
|
|
…and {{ urlInfo.entries.length - 10 }} more
|
|
</div>
|
|
<div v-if="urlInfo.unavailable_count" class="preview-warning">
|
|
⚠ {{ urlInfo.unavailable_count }} private/unavailable item{{ urlInfo.unavailable_count > 1 ? 's' : '' }} skipped
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="isExtracting" class="extract-loading">
|
|
<span class="spinner"></span>
|
|
Extracting available formats…
|
|
</div>
|
|
|
|
<div v-if="extractError" class="extract-error">
|
|
{{ extractError }}
|
|
</div>
|
|
|
|
<div v-if="store.submitError" class="extract-error">
|
|
{{ store.submitError }}
|
|
</div>
|
|
|
|
<!-- Collapsible options panel -->
|
|
<Transition name="options-slide">
|
|
<div v-if="showOptions" class="options-panel">
|
|
<!-- Output format selector -->
|
|
<div class="format-selector">
|
|
<label class="format-label">Output format</label>
|
|
<div class="format-chips">
|
|
<button
|
|
v-for="fmt in availableFormats"
|
|
:key="fmt"
|
|
class="format-chip"
|
|
:class="{ active: outputFormat === fmt }"
|
|
:title="formatTooltip(fmt)"
|
|
@click="outputFormat = fmt"
|
|
>
|
|
{{ formatLabel(fmt) }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Advanced format picker from yt-dlp extract_info -->
|
|
<FormatPicker
|
|
v-if="formatsReady"
|
|
:formats="formats"
|
|
@select="onFormatSelect"
|
|
/>
|
|
<div v-else-if="!isExtracting" class="options-hint">
|
|
Paste a URL and formats will load automatically.
|
|
</div>
|
|
</div>
|
|
</Transition>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.url-input {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--space-sm);
|
|
width: 100%;
|
|
}
|
|
|
|
.url-field {
|
|
width: 100%;
|
|
font-size: var(--font-size-base);
|
|
}
|
|
|
|
/* Action row: gear | toggle | download */
|
|
.action-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--space-sm);
|
|
}
|
|
|
|
.btn-options {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 38px;
|
|
height: 38px;
|
|
padding: 0;
|
|
flex-shrink: 0;
|
|
background: var(--color-surface);
|
|
color: var(--color-text-muted);
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-sm);
|
|
transition: all 0.15s ease;
|
|
}
|
|
|
|
.btn-options:hover:not(:disabled) {
|
|
color: var(--color-accent);
|
|
border-color: var(--color-accent);
|
|
}
|
|
|
|
.btn-options.active {
|
|
color: var(--color-accent);
|
|
border-color: var(--color-accent);
|
|
background: color-mix(in srgb, var(--color-accent) 10%, transparent);
|
|
}
|
|
|
|
.media-toggle {
|
|
display: flex;
|
|
gap: 0;
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-sm);
|
|
overflow: hidden;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.toggle-pill {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--space-xs);
|
|
padding: var(--space-xs) var(--space-md);
|
|
background: var(--color-surface);
|
|
color: var(--color-text-muted);
|
|
border: none;
|
|
border-radius: 0;
|
|
font-size: var(--font-size-sm);
|
|
min-height: 38px;
|
|
transition: all 0.15s ease;
|
|
}
|
|
|
|
.toggle-pill:not(:last-child) {
|
|
border-right: 1px solid var(--color-border);
|
|
}
|
|
|
|
.toggle-pill:hover {
|
|
background: var(--color-surface-hover);
|
|
color: var(--color-text);
|
|
}
|
|
|
|
.toggle-pill.active {
|
|
background: color-mix(in srgb, var(--color-accent) 15%, transparent);
|
|
color: var(--color-accent);
|
|
}
|
|
|
|
.toggle-label {
|
|
/* Visible by default, hidden at narrow widths */
|
|
}
|
|
|
|
.btn-download {
|
|
flex: 1;
|
|
white-space: nowrap;
|
|
padding: var(--space-sm) var(--space-lg);
|
|
font-weight: 600;
|
|
min-height: 38px;
|
|
background: var(--color-accent);
|
|
color: var(--color-bg);
|
|
}
|
|
|
|
.btn-download:hover:not(:disabled) {
|
|
background: var(--color-accent-hover);
|
|
}
|
|
|
|
button:disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
/* Loading / errors */
|
|
.extract-loading {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--space-sm);
|
|
color: var(--color-text-muted);
|
|
font-size: var(--font-size-sm);
|
|
padding: var(--space-sm);
|
|
}
|
|
|
|
.spinner {
|
|
display: inline-block;
|
|
width: 16px;
|
|
height: 16px;
|
|
border: 2px solid var(--color-border);
|
|
border-top-color: var(--color-accent);
|
|
border-radius: 50%;
|
|
animation: spin 0.6s linear infinite;
|
|
}
|
|
|
|
@keyframes spin {
|
|
to { transform: rotate(360deg); }
|
|
}
|
|
|
|
.extract-error {
|
|
color: var(--color-error);
|
|
font-size: var(--font-size-sm);
|
|
padding: var(--space-sm);
|
|
}
|
|
|
|
/* Options panel */
|
|
.options-panel {
|
|
overflow: hidden;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--space-md);
|
|
}
|
|
|
|
.format-selector {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--space-md);
|
|
padding: var(--space-sm) 0;
|
|
}
|
|
|
|
.format-label {
|
|
font-size: var(--font-size-sm);
|
|
color: var(--color-text-muted);
|
|
white-space: nowrap;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.format-chips {
|
|
display: flex;
|
|
gap: var(--space-xs);
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.format-chip {
|
|
padding: var(--space-xs) var(--space-md);
|
|
font-size: var(--font-size-sm);
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-sm);
|
|
background: var(--color-surface);
|
|
color: var(--color-text-muted);
|
|
cursor: pointer;
|
|
transition: all 0.15s ease;
|
|
min-height: 30px;
|
|
}
|
|
|
|
.format-chip:hover {
|
|
color: var(--color-text);
|
|
border-color: var(--color-text-muted);
|
|
}
|
|
|
|
.format-chip.active {
|
|
background: color-mix(in srgb, var(--color-accent) 15%, transparent);
|
|
color: var(--color-accent);
|
|
border-color: var(--color-accent);
|
|
}
|
|
|
|
.options-slide-enter-active,
|
|
.options-slide-leave-active {
|
|
transition: all 0.25s ease;
|
|
}
|
|
|
|
.options-slide-enter-from,
|
|
.options-slide-leave-to {
|
|
opacity: 0;
|
|
max-height: 0;
|
|
}
|
|
|
|
.options-slide-enter-to,
|
|
.options-slide-leave-from {
|
|
opacity: 1;
|
|
max-height: 400px;
|
|
}
|
|
|
|
.options-hint {
|
|
padding: var(--space-md);
|
|
text-align: center;
|
|
color: var(--color-text-muted);
|
|
font-size: var(--font-size-sm);
|
|
}
|
|
|
|
/* URL preview */
|
|
.url-preview {
|
|
padding: var(--space-sm) var(--space-md);
|
|
background: var(--color-surface);
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-sm);
|
|
font-size: var(--font-size-sm);
|
|
}
|
|
|
|
.url-preview.loading {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--space-sm);
|
|
color: var(--color-text-muted);
|
|
}
|
|
|
|
.preview-header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--space-sm);
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.preview-badge {
|
|
font-size: 11px;
|
|
padding: 1px 8px;
|
|
border-radius: 3px;
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.preview-badge.playlist {
|
|
background: color-mix(in srgb, var(--color-accent) 15%, transparent);
|
|
color: var(--color-accent);
|
|
}
|
|
|
|
.preview-badge.single {
|
|
background: color-mix(in srgb, var(--color-text-muted) 15%, transparent);
|
|
color: var(--color-text-muted);
|
|
}
|
|
|
|
.preview-badge.audio-only {
|
|
background: color-mix(in srgb, var(--color-warning) 15%, transparent);
|
|
color: var(--color-warning);
|
|
}
|
|
|
|
.preview-title {
|
|
color: var(--color-text);
|
|
font-weight: 500;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.preview-entries {
|
|
margin-top: var(--space-xs);
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1px;
|
|
max-height: 200px;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.preview-entry {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--space-sm);
|
|
padding: 2px 0;
|
|
color: var(--color-text-muted);
|
|
}
|
|
|
|
.entry-num {
|
|
min-width: 24px;
|
|
text-align: right;
|
|
color: var(--color-text-muted);
|
|
opacity: 0.6;
|
|
}
|
|
|
|
.entry-title {
|
|
flex: 1;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.entry-duration {
|
|
font-family: var(--font-mono);
|
|
color: var(--color-text-muted);
|
|
opacity: 0.6;
|
|
}
|
|
|
|
.preview-more {
|
|
padding: var(--space-xs) 0;
|
|
color: var(--color-text-muted);
|
|
font-style: italic;
|
|
}
|
|
|
|
.preview-warning {
|
|
padding: var(--space-xs) var(--space-sm);
|
|
margin-top: var(--space-xs);
|
|
color: var(--color-warning);
|
|
font-size: var(--font-size-sm);
|
|
background: color-mix(in srgb, var(--color-warning) 10%, transparent);
|
|
border-radius: var(--radius-sm);
|
|
}
|
|
|
|
.toggle-pill.disabled {
|
|
opacity: 0.4;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
/* Narrow viewports: hide toggle labels, keep icons only */
|
|
@media (max-width: 540px) {
|
|
.toggle-label {
|
|
display: none;
|
|
}
|
|
|
|
.toggle-pill {
|
|
padding: var(--space-xs) var(--space-sm);
|
|
}
|
|
}
|
|
|
|
/* Mobile */
|
|
@media (max-width: 767px) {
|
|
.btn-download {
|
|
padding: var(--space-sm) var(--space-md);
|
|
}
|
|
}
|
|
</style>
|