mirror of
https://github.com/xpltdco/media-rip.git
synced 2026-06-02 12:34:29 -06:00
Full-featured self-hosted yt-dlp web frontend:
- Python 3.12+ / FastAPI backend with async SQLite, SSE transport, session isolation
- Vue 3 / TypeScript / Pinia frontend with real-time progress, theme picker
- 3 built-in themes (cyberpunk/dark/light) + drop-in custom theme system
- Admin auth (bcrypt), purge system, cookie upload, file serving
- Docker multi-stage build, GitHub Actions CI/CD
- 179 backend tests, 29 frontend tests (208 total)
Slices: S01 (Foundation), S02 (SSE+Sessions), S03 (Frontend),
S04 (Admin+Auth), S05 (Themes), S06 (Docker+CI)
211 lines
4.4 KiB
Vue
211 lines
4.4 KiB
Vue
<script setup lang="ts">
|
|
import { ref } from 'vue'
|
|
import { api } from '@/api/client'
|
|
import { useDownloadsStore } from '@/stores/downloads'
|
|
import FormatPicker from './FormatPicker.vue'
|
|
import type { FormatInfo } 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 showFormats = ref(false)
|
|
|
|
async function extractFormats(): Promise<void> {
|
|
const trimmed = url.value.trim()
|
|
if (!trimmed) return
|
|
|
|
isExtracting.value = true
|
|
extractError.value = null
|
|
formats.value = []
|
|
showFormats.value = false
|
|
selectedFormatId.value = null
|
|
|
|
try {
|
|
formats.value = await api.getFormats(trimmed)
|
|
showFormats.value = true
|
|
} 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,
|
|
})
|
|
// Reset form on success
|
|
url.value = ''
|
|
formats.value = []
|
|
showFormats.value = false
|
|
selectedFormatId.value = null
|
|
extractError.value = null
|
|
} catch {
|
|
// Error already in store.submitError
|
|
}
|
|
}
|
|
|
|
function onFormatSelect(formatId: string | null): void {
|
|
selectedFormatId.value = formatId
|
|
}
|
|
|
|
function handlePaste(): void {
|
|
// Auto-extract on paste after a tick (value not yet updated in paste event)
|
|
setTimeout(() => {
|
|
if (url.value.trim()) {
|
|
extractFormats()
|
|
}
|
|
}, 50)
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="url-input">
|
|
<div class="input-row">
|
|
<input
|
|
v-model="url"
|
|
type="url"
|
|
placeholder="Paste a URL to download…"
|
|
class="url-field"
|
|
@paste="handlePaste"
|
|
@keydown.enter="showFormats ? submitDownload() : extractFormats()"
|
|
:disabled="isExtracting"
|
|
/>
|
|
<button
|
|
v-if="!showFormats"
|
|
class="btn-extract"
|
|
@click="extractFormats"
|
|
:disabled="!url.trim() || isExtracting"
|
|
>
|
|
{{ isExtracting ? 'Extracting…' : 'Get Formats' }}
|
|
</button>
|
|
<button
|
|
v-else
|
|
class="btn-download"
|
|
@click="submitDownload"
|
|
:disabled="store.isSubmitting"
|
|
>
|
|
{{ store.isSubmitting ? 'Submitting…' : 'Download' }}
|
|
</button>
|
|
</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>
|
|
|
|
<FormatPicker
|
|
v-if="showFormats"
|
|
:formats="formats"
|
|
@select="onFormatSelect"
|
|
/>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.url-input {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--space-sm);
|
|
width: 100%;
|
|
}
|
|
|
|
.input-row {
|
|
display: flex;
|
|
gap: var(--space-sm);
|
|
}
|
|
|
|
.url-field {
|
|
flex: 1;
|
|
font-size: var(--font-size-base);
|
|
}
|
|
|
|
.btn-extract,
|
|
.btn-download {
|
|
white-space: nowrap;
|
|
padding: var(--space-sm) var(--space-lg);
|
|
font-weight: 600;
|
|
}
|
|
|
|
.btn-extract {
|
|
background: var(--color-surface);
|
|
color: var(--color-accent);
|
|
border: 1px solid var(--color-accent);
|
|
}
|
|
|
|
.btn-extract:hover:not(:disabled) {
|
|
background: color-mix(in srgb, var(--color-accent) 15%, transparent);
|
|
}
|
|
|
|
.btn-download {
|
|
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;
|
|
}
|
|
|
|
.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);
|
|
}
|
|
|
|
/* Mobile: stack vertically */
|
|
@media (max-width: 767px) {
|
|
.input-row {
|
|
flex-direction: column;
|
|
}
|
|
|
|
.btn-extract,
|
|
.btn-download {
|
|
width: 100%;
|
|
}
|
|
}
|
|
</style>
|