1
Fork 0
mirror of https://github.com/thegeneralist01/archivr synced 2026-07-22 03:05:32 +02:00

feat(server,frontend): playlist quality selector + per-video overrides + sync UI

routes.rs:
- CaptureBody gains per_item_quality (HashMap<String,String>, serde(default))
  and sync (bool, serde(default)); both validated before use
  - per_item_quality values validated against same quality predicate as top-level
    quality field ("best"|"audio"|"NNNp") so bad per-video values are rejected
    at the API boundary rather than silently falling through to quality_format
- capture_handler threads body.per_item_quality + body.sync into CaptureConfig
  (replaces hardcoded empty stubs); rearchive_handler keeps empty defaults
- New POST /api/archives/:id/captures/probe-playlist: calls
  probe_playlist_qualities via spawn_blocking; 400 for non-playlist locator,
  502 on yt-dlp failure, returns PlaylistProbeResult as JSON

api.js:
- probePlaylist(archiveId, locator): POST probe-playlist endpoint
- submitCapture: forwards per_item_quality (non-empty) and sync:true from
  extraExtensions param added to submitBgJob

CaptureDialog.jsx:
- isPlaylistSource(): detects yt:/youtube: playlist/@/channel, ytm:playlist/,
  YouTube/YTM HTTP(S) URLs with list= param or channel pathnames
- makeItem(): 6 new playlist state fields
- applyPlaylistQuality(): conflict logic — videos that can reach selected
  quality get it set; videos that can't and have no prior selection are left
  null (conflict); videos with a prior selection keep it when quality is raised
- hasConflict(): any playlistItems entry with quality===null
- updateLocator(): isPlaylistSource branch with 800ms debounce→probePlaylist;
  existing isVideoSource path unchanged
- Archive button disabled when anyConflict or any probe in flight
- Per-video expand list with individual quality selects, conflict badges,
  sync toggle (appears after probe completes)

styles.css: playlist expansion, conflict, sync toggle CSS
This commit is contained in:
TheGeneralist 2026-07-20 22:46:30 +02:00
parent 096c98a678
commit 7499deeab0
Signed by: thegeneralist01
SSH key fingerprint: SHA256:pp9qddbCNmVNoSjevdvQvM5z0DHN7LTa8qBMbcMq/R4
8 changed files with 447 additions and 87 deletions

View file

@ -1,5 +1,5 @@
import { useRef, useEffect, useState, useCallback } from 'react'
import { submitCapture, pollCaptureJob, probeCapture, getInstanceSettings } from '../api'
import { submitCapture, pollCaptureJob, probeCapture, probePlaylist, getInstanceSettings } from '../api'
let nextItemId = 1
@ -63,6 +63,43 @@ function isVideoSource(locator) {
return false
}
function isPlaylistSource(locator) {
const l = locator.trim()
const ll = l.toLowerCase()
// yt: / youtube: shorthands playlist, channel, @ handles
for (const scheme of ['yt:', 'youtube:']) {
if (ll.startsWith(scheme)) {
const after = ll.slice(scheme.length)
return after.startsWith('playlist/') || after.startsWith('@') ||
after.startsWith('channel/') || after.startsWith('c/')
}
}
// ytm: shorthand playlist
if (ll.startsWith('ytm:')) {
return ll.slice(4).startsWith('playlist/')
}
// HTTP/HTTPS URLs
if (ll.startsWith('http://') || ll.startsWith('https://')) {
try {
const url = new URL(l)
const host = url.hostname
if (host === 'youtube.com' || host === 'www.youtube.com' || host === 'm.youtube.com') {
if (url.searchParams.has('list')) return true
if (url.pathname.startsWith('/@') || url.pathname.startsWith('/channel/') ||
url.pathname.startsWith('/c/') || url.pathname.startsWith('/user/')) return true
}
if (host === 'music.youtube.com') {
if (url.pathname.startsWith('/playlist') || url.searchParams.has('list')) return true
}
} catch {}
}
return false
}
function makeItem(locator = '') {
return {
id: nextItemId++, locator, quality: 'best',
@ -71,9 +108,39 @@ function makeItem(locator = '') {
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,
// playlist probe state
playlistProbeState: 'idle', // 'idle' | 'probing' | 'done' | 'error'
playlistInfo: null, // raw API response or null
playlistItems: null, // [{id, url, title, qualities, has_audio, quality}] or null
playlistQuality: null, // selected playlist-level quality string or null
playlistExpanded: false, // whether per-video list is expanded
syncEnabled: false, // sync mode toggle
}
}
function applyPlaylistQuality(newQ, currentItems) {
if (newQ === 'best' || newQ === 'audio') {
return currentItems.map(item => ({ ...item, quality: newQ }))
}
const newHeight = parseInt(newQ)
return currentItems.map(item => {
if (item.qualities.length === 0) {
return item
}
const maxHeight = Math.max(...item.qualities.map(q => parseInt(q)))
if (maxHeight >= newHeight) {
return { ...item, quality: newQ }
} else {
if (item.quality !== null) return item
return { ...item, quality: null }
}
})
}
function hasConflict(item) {
return item.playlistItems !== null && item.playlistItems.some(pi => pi.quality === null)
}
export default function CaptureDialog({ open, archiveId, onClose, onCaptured, onToast, onJobStarted, onJobSettled, activeJobs = [] }) {
const dialogRef = useRef(null)
const isFirstRenderRef = useRef(true)
@ -283,7 +350,7 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
onToastRef.current(text, null, type, headline)
}
async function submitBgJob(locator, quality, batchId) {
async function submitBgJob(locator, quality, batchId, extraExtensions = {}) {
const aid = archiveIdRef.current
const id = crypto.randomUUID?.() ?? `job-${Date.now()}-${Math.random()}`
// Capture session options at call time (synchronous before first await)
@ -293,6 +360,7 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
cookie_ext_enabled: cookieExtEnabled,
modal_closer_enabled: modalCloserEnabled,
via_freedium: freediumEnabled,
...extraExtensions,
}
try {
const job = await submitCapture(aid, locator, quality, extensions)
@ -315,13 +383,21 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
if (batchId) {
batchRef.current.set(batchId, { total: toSubmit.length, archived: 0, warnings: 0, failed: 0, failedLocators: [], warningLocators: [] })
}
// Capture options at call time before any state changes
const capturedQuality = toSubmit.map(it => it.quality || 'best')
// Capture all submission data before any state changes
const submissions = toSubmit.map(it => ({
locator: it.locator.trim(),
quality: it.playlistItems !== null ? null : (it.quality || 'best'),
extraExtensions: it.playlistItems !== null
? { per_item_quality: Object.fromEntries(it.playlistItems.map(pi => [pi.id, pi.quality])), sync: it.syncEnabled }
: {},
}))
// Reset form and close dialog immediately
setItems([makeItem()])
dialogRef.current?.close()
// Submit each in background
toSubmit.forEach((it, i) => submitBgJob(it.locator.trim(), capturedQuality[i], batchId))
submissions.forEach(({ locator, quality, extraExtensions }) =>
submitBgJob(locator, quality, batchId, extraExtensions)
)
}
function addRow() {
@ -340,45 +416,92 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
function updateLocator(id, val) {
// 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.
// the 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, locator: val, probeState: 'idle', probeQualities: null, probeHasAudio: false, quality: 'best',
playlistProbeState: 'idle', playlistInfo: null, playlistItems: null, playlistQuality: null, playlistExpanded: false }
: 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)
if (isVideoSource(val)) {
// 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)
} else if (isPlaylistSource(val)) {
// Schedule a playlist probe (playlists are slower 800ms debounce)
const timer = setTimeout(async () => {
probeTimers.current.delete(id)
setItems(prev => prev.map(it => it.id === id ? { ...it, playlistProbeState: 'probing' } : it))
try {
const result = await probePlaylist(archiveIdRef.current, val.trim())
setItems(prev => prev.map(it => {
if (it.id !== id || it.locator !== val) return it // stale locator changed again
return {
...it,
playlistProbeState: 'done',
playlistInfo: result,
playlistItems: result.items.map(pi => ({ ...pi, quality: null })),
playlistQuality: null,
}
}))
} catch {
setItems(prev => prev.map(it =>
it.id === id ? { ...it, playlistProbeState: 'error' } : it
))
}
}, 800)
probeTimers.current.set(id, timer)
}
}
function updateQuality(id, val) {
setItems(prev => prev.map(it => it.id === id ? { ...it, quality: val } : it))
}
function updatePlaylistQuality(id, q) {
setItems(prev => prev.map(it => {
if (it.id !== id) return it
const newItems = applyPlaylistQuality(q, it.playlistItems)
return { ...it, playlistQuality: q, playlistItems: newItems }
}))
}
function updatePlaylistItemQuality(id, videoId, q) {
setItems(prev => prev.map(it => {
if (it.id !== id) return it
return { ...it, playlistItems: it.playlistItems.map(pi => pi.id === videoId ? { ...pi, quality: q } : pi) }
}))
}
function togglePlaylistExpanded(id) {
setItems(prev => prev.map(it => it.id === id ? { ...it, playlistExpanded: !it.playlistExpanded } : it))
}
function updateSync(id, val) {
setItems(prev => prev.map(it => it.id === id ? { ...it, syncEnabled: val } : it))
}
const pendingCount = items.filter(it => it.locator.trim()).length
const anyConflict = items.some(it => hasConflict(it))
const anyProbing = items.some(it => it.playlistProbeState === 'probing' || it.probeState === 'probing')
return (
<dialog ref={dialogRef} className="capture-dialog">
@ -407,6 +530,10 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
onQualityChange={val => updateQuality(item.id, val)}
onRemove={() => removeRow(item.id)}
onSubmit={handleArchive}
onPlaylistQualityChange={q => updatePlaylistQuality(item.id, q)}
onPlaylistItemQualityChange={(vid, q) => updatePlaylistItemQuality(item.id, vid, q)}
onPlaylistToggle={() => togglePlaylistExpanded(item.id)}
onSyncChange={val => updateSync(item.id, val)}
/>
))}
</div>
@ -530,7 +657,7 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
type="button"
className="capture-submit"
onClick={handleArchive}
disabled={pendingCount === 0}
disabled={pendingCount === 0 || anyConflict || anyProbing}
>
{pendingCount > 1 ? `Archive ${pendingCount}` : 'Archive'}
</button>
@ -543,7 +670,8 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
)
}
function CaptureRow({ item, autoFocus, onLocatorChange, onQualityChange, onRemove, onSubmit }) {
function CaptureRow({ item, autoFocus, onLocatorChange, onQualityChange, onRemove, onSubmit,
onPlaylistQualityChange, onPlaylistItemQualityChange, onPlaylistToggle, onSyncChange }) {
const inputRef = useRef(null)
useEffect(() => {
@ -554,6 +682,52 @@ function CaptureRow({ item, autoFocus, onLocatorChange, onQualityChange, onRemov
// Quality control shown right of the input
const qualityEl = (() => {
// Playlist source handling
if (isPlaylistSource(item.locator)) {
if (item.playlistProbeState === 'probing') {
return <span className="capture-quality-probing" aria-label="Probing playlist…"></span>
}
if (item.playlistProbeState === 'done') {
const allHeights = [...new Set(
item.playlistItems.flatMap(pi => pi.qualities.map(q => parseInt(q)))
)].sort((a, b) => b - a)
const hasAnyAudio = item.playlistItems.some(pi => pi.has_audio)
const conflictCount = item.playlistItems.filter(pi => pi.quality === null).length
return (
<>
<select
className="capture-quality"
value={item.playlistQuality ?? ''}
onChange={e => onPlaylistQualityChange(e.target.value)}
aria-label="Playlist quality"
>
{!item.playlistQuality && <option value="" disabled>Select quality</option>}
<option value="best">Best quality</option>
{allHeights.map(h => <option key={h} value={`${h}p`}>{h}p</option>)}
{hasAnyAudio && <option value="audio">Audio only</option>}
</select>
{conflictCount > 0 && (
<span className="capture-conflict-badge">{conflictCount} need selection</span>
)}
<button
type="button"
className="capture-playlist-toggle"
onClick={onPlaylistToggle}
aria-label={item.playlistExpanded ? 'Collapse video list' : 'Expand video list'}
aria-expanded={item.playlistExpanded}
>
{item.playlistExpanded ? '▲' : '▼'}
</button>
</>
)
}
if (item.playlistProbeState === 'error') {
return <span className="capture-quality-hint">Probe failed using best quality</span>
}
return null
}
// Video source handling (unchanged)
if (!isVideoSource(item.locator)) return null
if (item.probeState === 'probing') {
return <span className="capture-quality-probing" aria-label="Checking available qualities"></span>
@ -565,8 +739,6 @@ function CaptureRow({ item, autoFocus, onLocatorChange, onQualityChange, onRemov
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"
@ -594,6 +766,13 @@ function CaptureRow({ item, autoFocus, onLocatorChange, onQualityChange, onRemov
return null // probeState === 'idle', debounce not yet fired
})()
const syncToggle = isPlaylistSource(item.locator) && item.playlistProbeState === 'done' ? (
<label className="capture-sync-row">
<input type="checkbox" checked={item.syncEnabled} onChange={e => onSyncChange(e.target.checked)} />
<span>Sync skip already-archived videos</span>
</label>
) : null
return (
<div className="capture-row">
<div className="capture-row-main">
@ -618,6 +797,33 @@ function CaptureRow({ item, autoFocus, onLocatorChange, onQualityChange, onRemov
{item.error && (
<p className="capture-row-error">{item.error}</p>
)}
{isPlaylistSource(item.locator) && item.playlistProbeState === 'done' && item.playlistExpanded ? (
<div className="capture-playlist-items">
{item.playlistItems.map(pi => (
<div
key={pi.id}
className={`capture-playlist-item${pi.quality === null ? ' capture-playlist-item--conflict' : ''}`}
>
<span className="capture-playlist-item-title">{pi.title || pi.url}</span>
<select
className="capture-item-quality"
value={pi.quality ?? ''}
onChange={e => onPlaylistItemQualityChange(pi.id, e.target.value)}
aria-label={`Quality for ${pi.title || pi.url}`}
>
{pi.quality === null && <option value="" disabled>Choose</option>}
<option value="best">Best quality</option>
{pi.qualities.map(q => <option key={q} value={q}>{q}</option>)}
{pi.has_audio && <option value="audio">Audio only</option>}
</select>
{pi.quality === null && (
<span className="capture-playlist-conflict-badge">Choose quality</span>
)}
</div>
))}
{syncToggle}
</div>
) : syncToggle}
</div>
)
}