mirror of
https://github.com/thegeneralist01/archivr
synced 2026-07-21 18:55:36 +02:00
feat: add video quality selection for yt-dlp captures (#17)
- ytdlp::download() accepts quality: Option<&str>; quality_format() maps best/1080p/720p/480p/360p to yt-dlp -f format strings - perform_capture() threads quality through to the downloader - CaptureBody gains optional quality field; capture_handler validates it against the allowlist (400 on unknown values) before spawning - CLI passes None (preserves existing best-quality behaviour) - Frontend: isVideoSource() mirrors determine_source() exactly — shows quality picker only for yt-dlp-backed sources, excludes playlist/channel shorthands and tweet/thread paths - submitCapture(archiveId, locator, quality) sends quality in POST body - CSS: .capture-quality styles the inline select to fit the capture row - Tests: quality_format unit tests in ytdlp.rs; two new route tests (valid quality accepted, invalid quality rejected with 400) - Docs: video quality section added under Supported Platforms
This commit is contained in:
parent
84c5730b6a
commit
b8e496457f
13 changed files with 689 additions and 89 deletions
|
|
@ -95,11 +95,13 @@ export async function fetchTags(archiveId) {
|
|||
return getJson(`/api/archives/${archiveId}/tags`);
|
||||
}
|
||||
|
||||
export async function submitCapture(archiveId, locator) {
|
||||
export async function submitCapture(archiveId, locator, quality = null) {
|
||||
const payload = { locator }
|
||||
if (quality && quality !== 'best') payload.quality = quality
|
||||
const res = await fetch(`/api/archives/${archiveId}/captures`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ locator }),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
|
|
@ -108,6 +110,12 @@ export async function submitCapture(archiveId, locator) {
|
|||
return res.json(); // { job_uid, status: "pending" }
|
||||
}
|
||||
|
||||
// Returns { has_video: bool, qualities: string[] } e.g. { has_video: true, qualities: ["1080p","720p","480p"] }
|
||||
// Throws on network error; returns { has_video: false, qualities: [] } on non-video locators.
|
||||
export async function probeCapture(archiveId, locator) {
|
||||
return getJson(`/api/archives/${archiveId}/captures/probe?locator=${encodeURIComponent(locator)}`);
|
||||
}
|
||||
|
||||
export async function pollCaptureJob(archiveId, jobUid) {
|
||||
return getJson(`/api/archives/${archiveId}/capture_jobs/${jobUid}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,65 @@
|
|||
import { useRef, useEffect, useState } from 'react'
|
||||
import { submitCapture, pollCaptureJob } from '../api'
|
||||
import { submitCapture, pollCaptureJob, probeCapture } from '../api'
|
||||
|
||||
let nextItemId = 1
|
||||
|
||||
// Returns true only for locators that determine_source() routes to yt-dlp download.
|
||||
// Mirrors the exact conditions in capture.rs — playlist/channel shorthands are excluded
|
||||
// (they return an "not yet implemented" error), as are tweet/thread shorthands.
|
||||
function isVideoSource(locator) {
|
||||
const l = locator.trim()
|
||||
const ll = l.toLowerCase()
|
||||
|
||||
// yt: / youtube: shorthands — video/short/shorts only; playlist and channel are unsupported
|
||||
for (const scheme of ['yt:', 'youtube:']) {
|
||||
if (ll.startsWith(scheme)) {
|
||||
const after = ll.slice(scheme.length)
|
||||
return after.startsWith('video/') || after.startsWith('short/') || after.startsWith('shorts/')
|
||||
}
|
||||
}
|
||||
|
||||
// x: / twitter: / tweet: shorthands — only x:media:ID routes to yt-dlp (Source::X)
|
||||
for (const scheme of ['x:', 'twitter:', 'tweet:']) {
|
||||
if (ll.startsWith(scheme)) {
|
||||
return ll.slice(scheme.length).startsWith('media:')
|
||||
}
|
||||
}
|
||||
|
||||
// Other platform shorthands — all go to yt-dlp
|
||||
if (ll.startsWith('instagram:') || ll.startsWith('facebook:') ||
|
||||
ll.startsWith('tiktok:') || ll.startsWith('reddit:') ||
|
||||
ll.startsWith('snapchat:')) return true
|
||||
|
||||
// HTTP/HTTPS URLs — match the same regexes and prefix checks as determine_source
|
||||
if (ll.startsWith('http://') || ll.startsWith('https://')) {
|
||||
// YouTube video (watch, youtu.be, shorts) — not playlist or channel
|
||||
if (/^https?:\/\/(?:www\.)?(?:youtu\.be\/[0-9A-Za-z_-]+|youtube\.com\/watch\?v=[0-9A-Za-z_-]+|youtube\.com\/shorts\/[0-9A-Za-z_-]+)/.test(l)) return true
|
||||
// x.com → Source::X → yt-dlp (note: twitter.com URLs fall through to Source::Url, not yt-dlp)
|
||||
if (ll.startsWith('https://x.com/') || ll.startsWith('http://x.com/')) return true
|
||||
// Instagram
|
||||
if (/^https?:\/\/(?:www\.)?instagram\.com\//.test(ll)) return true
|
||||
// Facebook + fb.watch
|
||||
if (/^https?:\/\/(?:www\.)?facebook\.com\//.test(ll) || ll.startsWith('https://fb.watch/') || ll.startsWith('http://fb.watch/')) return true
|
||||
// TikTok
|
||||
if (/^https?:\/\/(?:www\.)?tiktok\.com\//.test(ll)) return true
|
||||
// Reddit + redd.it
|
||||
if (/^https?:\/\/(?:www\.)?reddit\.com\//.test(ll) || ll.startsWith('https://redd.it/') || ll.startsWith('http://redd.it/')) return true
|
||||
// Snapchat
|
||||
if (/^https?:\/\/(?:www\.)?snapchat\.com\//.test(ll)) return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function makeItem(locator = '') {
|
||||
return { id: nextItemId++, locator, status: 'idle', error: null, jobUid: null, archiveId: null }
|
||||
return {
|
||||
id: nextItemId++, locator, quality: 'best',
|
||||
// probe: tracks yt-dlp metadata fetch for the locator
|
||||
probeState: 'idle', // 'idle' | 'probing' | 'done'
|
||||
probeQualities: null, // null | string[] when done, e.g. ["1080p","720p","480p"]
|
||||
probeHasAudio: false, // true when probe confirms at least one audio track
|
||||
status: 'idle', error: null, jobUid: null, archiveId: null,
|
||||
}
|
||||
}
|
||||
|
||||
function hasActiveJobs(items) {
|
||||
|
|
@ -16,6 +71,11 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
|
|||
const isFirstRenderRef = useRef(true)
|
||||
// jobUid → intervalId; survives dialog close since component stays mounted
|
||||
const pollIntervals = useRef(new Map())
|
||||
// itemId → debounce timeoutId for probe calls
|
||||
const probeTimers = useRef(new Map())
|
||||
// stable ref so probe callbacks always see the current archiveId
|
||||
const archiveIdRef = useRef(archiveId)
|
||||
useEffect(() => { archiveIdRef.current = archiveId }, [archiveId])
|
||||
|
||||
// Stable refs so polling callbacks always use the latest prop values
|
||||
const onCapturedRef = useRef(onCaptured)
|
||||
|
|
@ -63,7 +123,11 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
|
|||
useEffect(() => {
|
||||
const dialog = dialogRef.current
|
||||
if (!dialog) return
|
||||
const handler = () => onClose()
|
||||
const handler = () => {
|
||||
probeTimers.current.forEach(id => clearTimeout(id))
|
||||
probeTimers.current.clear()
|
||||
onClose()
|
||||
}
|
||||
dialog.addEventListener('close', handler)
|
||||
return () => dialog.removeEventListener('close', handler)
|
||||
}, [onClose])
|
||||
|
|
@ -79,13 +143,18 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
|
|||
isFirstRenderRef.current = false
|
||||
if (!dialog.open) dialog.showModal()
|
||||
} else {
|
||||
probeTimers.current.forEach(id => clearTimeout(id))
|
||||
probeTimers.current.clear()
|
||||
if (dialog.open) dialog.close()
|
||||
}
|
||||
}, [open]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Clear all intervals on unmount (component teardown)
|
||||
// Clear all intervals and probe timers on unmount (component teardown)
|
||||
useEffect(() => {
|
||||
return () => pollIntervals.current.forEach(id => clearInterval(id))
|
||||
return () => {
|
||||
pollIntervals.current.forEach(id => clearInterval(id))
|
||||
probeTimers.current.forEach(id => clearTimeout(id))
|
||||
}
|
||||
}, [])
|
||||
|
||||
function startPolling(itemId, jobUid, locator, aid) {
|
||||
|
|
@ -132,9 +201,10 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
|
|||
if (!item.locator.trim()) return
|
||||
const aid = archiveId // capture at submit time
|
||||
const loc = item.locator.trim()
|
||||
const qual = item.quality || 'best'
|
||||
setItems(prev => prev.map(it => it.id === item.id ? { ...it, status: 'submitting', error: null } : it))
|
||||
try {
|
||||
const job = await submitCapture(aid, loc)
|
||||
const job = await submitCapture(aid, loc, qual)
|
||||
setItems(prev => prev.map(it =>
|
||||
it.id === item.id ? { ...it, status: 'running', jobUid: job.job_uid, archiveId: aid } : it
|
||||
))
|
||||
|
|
@ -156,6 +226,8 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
|
|||
}
|
||||
|
||||
function removeRow(id) {
|
||||
clearTimeout(probeTimers.current.get(id))
|
||||
probeTimers.current.delete(id)
|
||||
setItems(prev => {
|
||||
const next = prev.filter(it => it.id !== id)
|
||||
return next.length === 0 ? [makeItem()] : next
|
||||
|
|
@ -169,7 +241,44 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
|
|||
}
|
||||
|
||||
function updateLocator(id, val) {
|
||||
setItems(prev => prev.map(it => it.id === id ? { ...it, locator: val } : it))
|
||||
// Cancel any in-flight debounce and immediately clear stale probe results.
|
||||
// This prevents old qualities from being visible (and submittable) while
|
||||
// the 600ms debounce is pending for the new URL.
|
||||
clearTimeout(probeTimers.current.get(id))
|
||||
setItems(prev => prev.map(it =>
|
||||
it.id === id
|
||||
? { ...it, locator: val, probeState: 'idle', probeQualities: null, probeHasAudio: false, quality: 'best' }
|
||||
: it
|
||||
))
|
||||
|
||||
if (!isVideoSource(val)) return
|
||||
|
||||
// Schedule a fresh probe after the user stops typing
|
||||
const timer = setTimeout(async () => {
|
||||
probeTimers.current.delete(id)
|
||||
setItems(prev => prev.map(it => it.id === id ? { ...it, probeState: 'probing' } : it))
|
||||
try {
|
||||
const result = await probeCapture(archiveIdRef.current, val.trim())
|
||||
setItems(prev => prev.map(it => {
|
||||
if (it.id !== id || it.locator !== val) return it // stale — locator changed again
|
||||
const qualities = result.qualities ?? []
|
||||
const hasAudio = result.has_audio ?? false
|
||||
// Audio-only source: no video heights but audio confirmed — force audio mode
|
||||
const quality = (qualities.length === 0 && hasAudio) ? 'audio' : 'best'
|
||||
return { ...it, probeState: 'done', probeQualities: qualities, probeHasAudio: hasAudio, quality }
|
||||
}))
|
||||
} catch {
|
||||
// Probe failed (network error, etc.) — clear silently; user can still submit
|
||||
setItems(prev => prev.map(it =>
|
||||
it.id === id ? { ...it, probeState: 'idle', probeQualities: null } : it
|
||||
))
|
||||
}
|
||||
}, 600)
|
||||
probeTimers.current.set(id, timer)
|
||||
}
|
||||
|
||||
function updateQuality(id, val) {
|
||||
setItems(prev => prev.map(it => it.id === id ? { ...it, quality: val } : it))
|
||||
}
|
||||
|
||||
const pendingCount = items.filter(it => it.status === 'idle' && it.locator.trim()).length
|
||||
|
|
@ -199,6 +308,7 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
|
|||
item={item}
|
||||
autoFocus={idx === items.length - 1 && item.status === 'idle'}
|
||||
onLocatorChange={val => updateLocator(item.id, val)}
|
||||
onQualityChange={val => updateQuality(item.id, val)}
|
||||
onRemove={() => removeRow(item.id)}
|
||||
onReset={() => resetRow(item.id)}
|
||||
onSubmit={handleArchive}
|
||||
|
|
@ -231,7 +341,7 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
|
|||
)
|
||||
}
|
||||
|
||||
function CaptureRow({ item, autoFocus, onLocatorChange, onRemove, onReset, onSubmit }) {
|
||||
function CaptureRow({ item, autoFocus, onLocatorChange, onQualityChange, onRemove, onReset, onSubmit }) {
|
||||
const inputRef = useRef(null)
|
||||
const isActive = item.status === 'submitting' || item.status === 'running'
|
||||
|
||||
|
|
@ -241,6 +351,49 @@ function CaptureRow({ item, autoFocus, onLocatorChange, onRemove, onReset, onSub
|
|||
}
|
||||
}, [autoFocus]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Quality control shown right of the input (hidden when active or completed)
|
||||
const qualityEl = (() => {
|
||||
if (item.status === 'completed' || isActive) return null
|
||||
if (!isVideoSource(item.locator)) return null
|
||||
if (item.probeState === 'probing') {
|
||||
return <span className="capture-quality-probing" aria-label="Checking available qualities">…</span>
|
||||
}
|
||||
if (item.probeState === 'done') {
|
||||
const qualities = item.probeQualities ?? []
|
||||
const hasAudio = item.probeHasAudio ?? false
|
||||
if (qualities.length === 0 && !hasAudio) {
|
||||
return <span className="capture-quality-hint">No media detected</span>
|
||||
}
|
||||
if (qualities.length === 0 && hasAudio) {
|
||||
// Audio-only source: no video tracks, only audio available.
|
||||
// Don't offer "Best quality" — it would request a video format and fail.
|
||||
return (
|
||||
<select
|
||||
className="capture-quality"
|
||||
value="audio"
|
||||
onChange={e => onQualityChange(e.target.value)}
|
||||
aria-label="Video quality"
|
||||
>
|
||||
<option value="audio">Audio only</option>
|
||||
</select>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<select
|
||||
className="capture-quality"
|
||||
value={item.quality || 'best'}
|
||||
onChange={e => onQualityChange(e.target.value)}
|
||||
aria-label="Video quality"
|
||||
>
|
||||
<option value="best">Best quality</option>
|
||||
{qualities.map(q => <option key={q} value={q}>{q}</option>)}
|
||||
{hasAudio && <option value="audio">Audio only</option>}
|
||||
</select>
|
||||
)
|
||||
}
|
||||
return null // probeState === 'idle', debounce not yet fired
|
||||
})()
|
||||
|
||||
return (
|
||||
<div className={`capture-row capture-row--${item.status}`}>
|
||||
<div className="capture-row-main">
|
||||
|
|
@ -257,6 +410,7 @@ function CaptureRow({ item, autoFocus, onLocatorChange, onRemove, onReset, onSub
|
|||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
{qualityEl}
|
||||
{item.status === 'failed' && (
|
||||
<button type="button" className="capture-row-action" onClick={onReset} title="Retry">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
|
|
|
|||
|
|
@ -676,6 +676,39 @@ select {
|
|||
/* No bottom-margin on inputs inside rows; gap handles spacing */
|
||||
.capture-row-main .capture-input { margin-bottom: 0; }
|
||||
|
||||
/* Quality selector shown for yt-dlp sources */
|
||||
.capture-quality {
|
||||
flex-shrink: 0;
|
||||
height: 32px;
|
||||
padding: 0 6px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r);
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
transition: border-color .15s;
|
||||
}
|
||||
.capture-quality:hover { border-color: var(--accent-2); }
|
||||
.capture-quality:focus { border-color: var(--accent); }
|
||||
.capture-quality:disabled { opacity: 0.5; cursor: default; }
|
||||
/* Probing state: ellipsis shown while yt-dlp metadata is in flight */
|
||||
.capture-quality-probing {
|
||||
flex-shrink: 0;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
padding: 0 4px;
|
||||
letter-spacing: .05em;
|
||||
}
|
||||
/* "No video detected" note */
|
||||
.capture-quality-hint {
|
||||
flex-shrink: 0;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Status dot */
|
||||
.cap-dot {
|
||||
flex-shrink: 0;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue