mirror of
https://github.com/xpltdco/media-rip.git
synced 2026-04-03 02:53:58 -06:00
M002/S04: URL preview, playlist support, admin improvements, UX polish
URL preview & playlist support:
- POST /url-info endpoint extracts metadata (title, type, entry count)
- Preview box shows playlist contents before downloading (up to 10 items)
- Auto-detect audio-only sources (SoundCloud, etc) and switch to Audio mode
- Video toggle grayed out for audio-only sources
- Enable playlist downloading (noplaylist=False)
Admin panel improvements:
- Expandable session rows show per-session job list with filename, size,
status, timestamp, and source URL link
- GET /admin/sessions/{id}/jobs endpoint for session job details
- Logout now redirects to home page instead of staying on login form
- Logo in header is clickable → navigates to home
UX polish:
- Tooltips on output format chips (explains Auto vs specific formats)
- Format tooltips change based on video/audio mode
This commit is contained in:
parent
6e27f8e424
commit
0d9e6b18ac
9 changed files with 495 additions and 13 deletions
|
|
@ -43,6 +43,41 @@ async def list_sessions(
|
||||||
return {"sessions": sessions, "total": len(sessions)}
|
return {"sessions": sessions, "total": len(sessions)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/sessions/{session_id}/jobs")
|
||||||
|
async def session_jobs(
|
||||||
|
session_id: str,
|
||||||
|
request: Request,
|
||||||
|
_admin: str = Depends(require_admin),
|
||||||
|
) -> dict:
|
||||||
|
"""List jobs for a specific session with file details."""
|
||||||
|
db = request.app.state.db
|
||||||
|
cursor = await db.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, url, status, filename, filesize,
|
||||||
|
created_at, started_at, completed_at
|
||||||
|
FROM jobs
|
||||||
|
WHERE session_id = ?
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
""",
|
||||||
|
(session_id,),
|
||||||
|
)
|
||||||
|
rows = await cursor.fetchall()
|
||||||
|
jobs = [
|
||||||
|
{
|
||||||
|
"id": row["id"],
|
||||||
|
"url": row["url"],
|
||||||
|
"status": row["status"],
|
||||||
|
"filename": row["filename"],
|
||||||
|
"filesize": row["filesize"],
|
||||||
|
"created_at": row["created_at"],
|
||||||
|
"started_at": row["started_at"],
|
||||||
|
"completed_at": row["completed_at"],
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
return {"jobs": jobs}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/storage")
|
@router.get("/storage")
|
||||||
async def storage_info(
|
async def storage_info(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
|
|
||||||
|
|
@ -68,3 +68,16 @@ async def cancel_download(
|
||||||
)
|
)
|
||||||
|
|
||||||
return {"status": "cancelled"}
|
return {"status": "cancelled"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/url-info")
|
||||||
|
async def url_info(
|
||||||
|
request: Request,
|
||||||
|
body: dict,
|
||||||
|
) -> dict:
|
||||||
|
"""Extract metadata about a URL (title, playlist detection, audio-only detection)."""
|
||||||
|
url = body.get("url", "").strip()
|
||||||
|
if not url:
|
||||||
|
return JSONResponse(status_code=400, content={"detail": "URL required"})
|
||||||
|
download_service = request.app.state.download_service
|
||||||
|
return await download_service.get_url_info(url)
|
||||||
|
|
|
||||||
|
|
@ -113,6 +113,7 @@ class DownloadService:
|
||||||
"quiet": True,
|
"quiet": True,
|
||||||
"no_warnings": True,
|
"no_warnings": True,
|
||||||
"noprogress": True,
|
"noprogress": True,
|
||||||
|
"noplaylist": False,
|
||||||
}
|
}
|
||||||
if job_create.format_id:
|
if job_create.format_id:
|
||||||
opts["format"] = job_create.format_id
|
opts["format"] = job_create.format_id
|
||||||
|
|
@ -378,6 +379,66 @@ class DownloadService:
|
||||||
logger.exception("Format extraction failed for %s", url)
|
logger.exception("Format extraction failed for %s", url)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def _extract_url_info(self, url: str) -> dict | None:
|
||||||
|
"""Extract URL metadata including playlist detection."""
|
||||||
|
opts = {
|
||||||
|
"quiet": True,
|
||||||
|
"no_warnings": True,
|
||||||
|
"skip_download": True,
|
||||||
|
"extract_flat": "in_playlist",
|
||||||
|
"noplaylist": False,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
with yt_dlp.YoutubeDL(opts) as ydl:
|
||||||
|
return ydl.extract_info(url, download=False)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("URL info extraction failed for %s", url)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def get_url_info(self, url: str) -> dict:
|
||||||
|
"""Get URL metadata: title, type (single/playlist), entries."""
|
||||||
|
info = await self._loop.run_in_executor(
|
||||||
|
self._executor,
|
||||||
|
self._extract_url_info,
|
||||||
|
url,
|
||||||
|
)
|
||||||
|
if not info:
|
||||||
|
return {"type": "unknown", "title": None, "entries": []}
|
||||||
|
|
||||||
|
result_type = info.get("_type", "video")
|
||||||
|
if result_type == "playlist" or "entries" in info:
|
||||||
|
entries_raw = info.get("entries") or []
|
||||||
|
entries = []
|
||||||
|
for e in entries_raw:
|
||||||
|
if isinstance(e, dict):
|
||||||
|
entries.append({
|
||||||
|
"title": e.get("title") or e.get("id", "Unknown"),
|
||||||
|
"url": e.get("url") or e.get("webpage_url", ""),
|
||||||
|
"duration": e.get("duration"),
|
||||||
|
})
|
||||||
|
# Detect audio-only source (no video formats)
|
||||||
|
is_audio_only = False
|
||||||
|
if info.get("categories"):
|
||||||
|
is_audio_only = "Music" in info["categories"]
|
||||||
|
return {
|
||||||
|
"type": "playlist",
|
||||||
|
"title": info.get("title", "Playlist"),
|
||||||
|
"count": len(entries),
|
||||||
|
"entries": entries,
|
||||||
|
"is_audio_only": is_audio_only,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
# Single video/track
|
||||||
|
has_video = bool(info.get("vcodec") and info["vcodec"] != "none")
|
||||||
|
is_audio_only = not has_video
|
||||||
|
return {
|
||||||
|
"type": "single",
|
||||||
|
"title": info.get("title"),
|
||||||
|
"duration": info.get("duration"),
|
||||||
|
"is_audio_only": is_audio_only,
|
||||||
|
"entries": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Helpers
|
# Helpers
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
* relative paths work without configuration.
|
* relative paths work without configuration.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Job, JobCreate, FormatInfo, PublicConfig, HealthStatus } from './types'
|
import type { Job, JobCreate, FormatInfo, PublicConfig, HealthStatus, UrlInfo } from './types'
|
||||||
|
|
||||||
class ApiError extends Error {
|
class ApiError extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
|
|
@ -77,6 +77,14 @@ export const api = {
|
||||||
async getHealth(): Promise<HealthStatus> {
|
async getHealth(): Promise<HealthStatus> {
|
||||||
return request<HealthStatus>('/api/health')
|
return request<HealthStatus>('/api/health')
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Get URL metadata (title, playlist detection, audio-only detection). */
|
||||||
|
async getUrlInfo(url: string): Promise<UrlInfo> {
|
||||||
|
return request<UrlInfo>('/api/url-info', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ url }),
|
||||||
|
})
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export { ApiError }
|
export { ApiError }
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,21 @@ export interface HealthStatus {
|
||||||
queue_depth: number
|
queue_depth: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UrlInfoEntry {
|
||||||
|
title: string
|
||||||
|
url: string
|
||||||
|
duration: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UrlInfo {
|
||||||
|
type: 'single' | 'playlist' | 'unknown'
|
||||||
|
title: string | null
|
||||||
|
count?: number
|
||||||
|
entries: UrlInfoEntry[]
|
||||||
|
duration?: number | null
|
||||||
|
is_audio_only: boolean
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SSE event types received from GET /api/events.
|
* SSE event types received from GET /api/events.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,19 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref } from 'vue'
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
import { useAdminStore } from '@/stores/admin'
|
import { useAdminStore } from '@/stores/admin'
|
||||||
import { api } from '@/api/client'
|
import { api } from '@/api/client'
|
||||||
import AdminLogin from './AdminLogin.vue'
|
import AdminLogin from './AdminLogin.vue'
|
||||||
|
|
||||||
const store = useAdminStore()
|
const store = useAdminStore()
|
||||||
|
const router = useRouter()
|
||||||
const activeTab = ref<'sessions' | 'storage' | 'purge' | 'settings'>('sessions')
|
const activeTab = ref<'sessions' | 'storage' | 'purge' | 'settings'>('sessions')
|
||||||
|
|
||||||
|
// Session expansion state
|
||||||
|
const expandedSessions = ref<Set<string>>(new Set())
|
||||||
|
const sessionJobs = ref<Record<string, any[]>>({})
|
||||||
|
const loadingJobs = ref<Set<string>>(new Set())
|
||||||
|
|
||||||
// Settings state
|
// Settings state
|
||||||
const welcomeMessage = ref('')
|
const welcomeMessage = ref('')
|
||||||
const settingsSaved = ref(false)
|
const settingsSaved = ref(false)
|
||||||
|
|
@ -41,6 +48,38 @@ async function saveSettings() {
|
||||||
setTimeout(() => { settingsSaved.value = false }, 3000)
|
setTimeout(() => { settingsSaved.value = false }, 3000)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function toggleSession(sessionId: string) {
|
||||||
|
if (expandedSessions.value.has(sessionId)) {
|
||||||
|
expandedSessions.value.delete(sessionId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
expandedSessions.value.add(sessionId)
|
||||||
|
if (!sessionJobs.value[sessionId]) {
|
||||||
|
loadingJobs.value.add(sessionId)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/admin/sessions/${sessionId}/jobs`, {
|
||||||
|
headers: { Authorization: `Basic ${btoa(`${store.username}:${store.password}`)}` },
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
sessionJobs.value[sessionId] = data.jobs
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
loadingJobs.value.delete(sessionId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function jobFilename(job: any): string {
|
||||||
|
if (!job.filename) return '—'
|
||||||
|
const parts = job.filename.replace(/\\/g, '/').split('/')
|
||||||
|
return parts[parts.length - 1]
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFilesize(bytes: number | null): string {
|
||||||
|
if (!bytes) return '—'
|
||||||
|
return formatBytes(bytes)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
@ -50,7 +89,7 @@ async function saveSettings() {
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="admin-header">
|
<div class="admin-header">
|
||||||
<h2>Admin Panel</h2>
|
<h2>Admin Panel</h2>
|
||||||
<button class="btn-logout" @click="store.logout()">Logout</button>
|
<button class="btn-logout" @click="store.logout(); router.push('/')">Logout</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="admin-tabs">
|
<div class="admin-tabs">
|
||||||
|
|
@ -69,17 +108,42 @@ async function saveSettings() {
|
||||||
<table class="admin-table" v-if="store.sessions.length">
|
<table class="admin-table" v-if="store.sessions.length">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
<th></th>
|
||||||
<th>Session ID</th>
|
<th>Session ID</th>
|
||||||
<th>Last Seen</th>
|
<th>Last Seen</th>
|
||||||
<th>Jobs</th>
|
<th>Jobs</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="s in store.sessions" :key="s.id">
|
<template v-for="s in store.sessions" :key="s.id">
|
||||||
<td class="mono">{{ s.id.slice(0, 8) }}…</td>
|
<tr
|
||||||
<td>{{ new Date(s.last_seen).toLocaleString() }}</td>
|
class="session-row"
|
||||||
<td>{{ s.job_count }}</td>
|
:class="{ expanded: expandedSessions.has(s.id), clickable: s.job_count > 0 }"
|
||||||
</tr>
|
@click="s.job_count > 0 && toggleSession(s.id)"
|
||||||
|
>
|
||||||
|
<td class="col-expand">
|
||||||
|
<span v-if="s.job_count > 0" class="expand-icon">{{ expandedSessions.has(s.id) ? '▼' : '▶' }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="mono">{{ s.id.slice(0, 8) }}…</td>
|
||||||
|
<td>{{ new Date(s.last_seen).toLocaleString() }}</td>
|
||||||
|
<td>{{ s.job_count }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="expandedSessions.has(s.id)" class="jobs-detail-row">
|
||||||
|
<td colspan="4">
|
||||||
|
<div v-if="loadingJobs.has(s.id)" class="jobs-loading">Loading…</div>
|
||||||
|
<div v-else-if="sessionJobs[s.id]?.length" class="jobs-detail">
|
||||||
|
<div v-for="job in sessionJobs[s.id]" :key="job.id" class="job-item">
|
||||||
|
<span class="job-filename">{{ jobFilename(job) }}</span>
|
||||||
|
<span class="job-size">{{ formatFilesize(job.filesize) }}</span>
|
||||||
|
<span class="job-status badge-sm" :class="'badge-' + job.status">{{ job.status }}</span>
|
||||||
|
<span class="job-time">{{ new Date(job.created_at).toLocaleString() }}</span>
|
||||||
|
<a v-if="job.url" class="job-url" :href="job.url" target="_blank" rel="noopener" :title="job.url">↗</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="jobs-empty">No jobs found.</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<p v-else class="empty">No sessions found.</p>
|
<p v-else class="empty">No sessions found.</p>
|
||||||
|
|
@ -348,4 +412,102 @@ h3 {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Expandable session rows */
|
||||||
|
.session-row.clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-row.clickable:hover {
|
||||||
|
background: var(--color-surface-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-row.expanded {
|
||||||
|
background: color-mix(in srgb, var(--color-accent) 5%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-expand {
|
||||||
|
width: 24px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-icon {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jobs-detail-row td {
|
||||||
|
padding: 0 var(--space-md) var(--space-md);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jobs-detail {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
padding: var(--space-sm) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-md);
|
||||||
|
padding: var(--space-xs) var(--space-sm);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--color-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-filename {
|
||||||
|
flex: 1;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-size {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
min-width: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-sm {
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-completed { background: color-mix(in srgb, var(--color-success) 15%, transparent); color: var(--color-success); }
|
||||||
|
.badge-failed { background: color-mix(in srgb, var(--color-error) 15%, transparent); color: var(--color-error); }
|
||||||
|
.badge-downloading { background: color-mix(in srgb, var(--color-accent) 15%, transparent); color: var(--color-accent); }
|
||||||
|
.badge-queued { background: color-mix(in srgb, var(--color-text-muted) 15%, transparent); color: var(--color-text-muted); }
|
||||||
|
|
||||||
|
.job-time {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-url {
|
||||||
|
color: var(--color-accent);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-url:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jobs-loading, .jobs-empty {
|
||||||
|
padding: var(--space-sm);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,18 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
import DarkModeToggle from '@/components/DarkModeToggle.vue'
|
import DarkModeToggle from '@/components/DarkModeToggle.vue'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
function goHome(): void {
|
||||||
|
router.push('/')
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<header class="app-header">
|
<header class="app-header">
|
||||||
<div class="header-content">
|
<div class="header-content">
|
||||||
<h1 class="header-title">
|
<h1 class="header-title" @click="goHome" role="link" tabindex="0" @keydown.enter="goHome" title="Back to downloads">
|
||||||
<span class="title-media">media</span><span class="title-dot">.</span><span class="title-rip">rip</span><span class="title-parens">()</span>
|
<span class="title-media">media</span><span class="title-dot">.</span><span class="title-rip">rip</span><span class="title-parens">()</span>
|
||||||
</h1>
|
</h1>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
|
|
@ -42,6 +49,8 @@ import DarkModeToggle from '@/components/DarkModeToggle.vue'
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-family: var(--font-display);
|
font-family: var(--font-display);
|
||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.02em;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.title-media { color: var(--color-text); }
|
.title-media { color: var(--color-text); }
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { ref, computed, watch } from 'vue'
|
||||||
import { api } from '@/api/client'
|
import { api } from '@/api/client'
|
||||||
import { useDownloadsStore } from '@/stores/downloads'
|
import { useDownloadsStore } from '@/stores/downloads'
|
||||||
import FormatPicker from './FormatPicker.vue'
|
import FormatPicker from './FormatPicker.vue'
|
||||||
import type { FormatInfo } from '@/api/types'
|
import type { FormatInfo, UrlInfo } from '@/api/types'
|
||||||
|
|
||||||
const store = useDownloadsStore()
|
const store = useDownloadsStore()
|
||||||
|
|
||||||
|
|
@ -14,6 +14,11 @@ const isExtracting = ref(false)
|
||||||
const extractError = ref<string | null>(null)
|
const extractError = ref<string | null>(null)
|
||||||
const showOptions = ref(false)
|
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'
|
type MediaType = 'video' | 'audio'
|
||||||
const mediaType = ref<MediaType>('video')
|
const mediaType = ref<MediaType>('video')
|
||||||
const outputFormat = ref<string>('auto')
|
const outputFormat = ref<string>('auto')
|
||||||
|
|
@ -71,6 +76,8 @@ async function submitDownload(): Promise<void> {
|
||||||
extractError.value = null
|
extractError.value = null
|
||||||
mediaType.value = 'video'
|
mediaType.value = 'video'
|
||||||
outputFormat.value = 'auto'
|
outputFormat.value = 'auto'
|
||||||
|
urlInfo.value = null
|
||||||
|
audioLocked.value = false
|
||||||
} catch {
|
} catch {
|
||||||
// Error already in store.submitError
|
// Error already in store.submitError
|
||||||
}
|
}
|
||||||
|
|
@ -81,14 +88,43 @@ function onFormatSelect(formatId: string | null): void {
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePaste(): void {
|
function handlePaste(): void {
|
||||||
// Auto-extract on paste (populate formats silently in background)
|
// Auto-extract on paste (populate formats + URL info silently in background)
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (url.value.trim()) {
|
if (url.value.trim()) {
|
||||||
extractFormats()
|
extractFormats()
|
||||||
|
fetchUrlInfo()
|
||||||
}
|
}
|
||||||
}, 50)
|
}, 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 {
|
function toggleOptions(): void {
|
||||||
showOptions.value = !showOptions.value
|
showOptions.value = !showOptions.value
|
||||||
// Extract formats when opening options if we haven't yet
|
// Extract formats when opening options if we haven't yet
|
||||||
|
|
@ -101,6 +137,14 @@ function formatLabel(fmt: string): string {
|
||||||
if (fmt === 'auto') return 'Auto'
|
if (fmt === 'auto') return 'Auto'
|
||||||
return fmt.toUpperCase()
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
@ -131,9 +175,10 @@ function formatLabel(fmt: string): string {
|
||||||
<div class="media-toggle">
|
<div class="media-toggle">
|
||||||
<button
|
<button
|
||||||
class="toggle-pill"
|
class="toggle-pill"
|
||||||
:class="{ active: mediaType === 'video' }"
|
:class="{ active: mediaType === 'video', disabled: audioLocked }"
|
||||||
@click="mediaType = 'video'"
|
@click="!audioLocked && (mediaType = 'video')"
|
||||||
:title="'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>
|
<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>
|
<span class="toggle-label">Video</span>
|
||||||
|
|
@ -158,6 +203,35 @@ function formatLabel(fmt: string): string {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="isExtracting" class="extract-loading">
|
<div v-if="isExtracting" class="extract-loading">
|
||||||
<span class="spinner"></span>
|
<span class="spinner"></span>
|
||||||
Extracting available formats…
|
Extracting available formats…
|
||||||
|
|
@ -183,6 +257,7 @@ function formatLabel(fmt: string): string {
|
||||||
:key="fmt"
|
:key="fmt"
|
||||||
class="format-chip"
|
class="format-chip"
|
||||||
:class="{ active: outputFormat === fmt }"
|
:class="{ active: outputFormat === fmt }"
|
||||||
|
:title="formatTooltip(fmt)"
|
||||||
@click="outputFormat = fmt"
|
@click="outputFormat = fmt"
|
||||||
>
|
>
|
||||||
{{ formatLabel(fmt) }}
|
{{ formatLabel(fmt) }}
|
||||||
|
|
@ -415,6 +490,109 @@ button:disabled {
|
||||||
font-size: var(--font-size-sm);
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-pill.disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
/* Narrow viewports: hide toggle labels, keep icons only */
|
/* Narrow viewports: hide toggle labels, keep icons only */
|
||||||
@media (max-width: 540px) {
|
@media (max-width: 540px) {
|
||||||
.toggle-label {
|
.toggle-label {
|
||||||
|
|
|
||||||
|
|
@ -129,6 +129,7 @@ export const useAdminStore = defineStore('admin', () => {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
username,
|
username,
|
||||||
|
password,
|
||||||
isAuthenticated,
|
isAuthenticated,
|
||||||
authError,
|
authError,
|
||||||
sessions,
|
sessions,
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue