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:
xpltd 2026-03-19 02:32:14 -05:00
parent 6e27f8e424
commit 0d9e6b18ac
9 changed files with 495 additions and 13 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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.
*/ */

View file

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

View file

@ -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); }

View file

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

View file

@ -129,6 +129,7 @@ export const useAdminStore = defineStore('admin', () => {
return { return {
username, username,
password,
isAuthenticated, isAuthenticated,
authError, authError,
sessions, sessions,