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

@ -264,6 +264,10 @@ pub fn app_with_state(state: AppState) -> Router {
"/api/archives/:archive_id/captures/probe", "/api/archives/:archive_id/captures/probe",
get(probe_handler), get(probe_handler),
) )
.route(
"/api/archives/:archive_id/captures/probe-playlist",
post(probe_playlist_handler),
)
.route( .route(
"/api/archives/:archive_id/capture_jobs/:job_uid", "/api/archives/:archive_id/capture_jobs/:job_uid",
get(get_capture_job_handler), get(get_capture_job_handler),
@ -765,6 +769,13 @@ struct CaptureBody {
modal_closer_enabled: Option<bool>, modal_closer_enabled: Option<bool>,
/// Route through Freedium mirror for WebPage captures. Absent = true (on by default). /// Route through Freedium mirror for WebPage captures. Absent = true (on by default).
via_freedium: Option<bool>, via_freedium: Option<bool>,
/// Per-video quality overrides for playlist captures.
/// Keys are yt-dlp video IDs; values are quality strings ("best", "1080p", "audio", etc.).
#[serde(default)]
per_item_quality: std::collections::HashMap<String, String>,
/// When true, skip playlist items already archived under an existing container.
#[serde(default)]
sync: bool,
} }
#[derive(Debug, serde::Deserialize)] #[derive(Debug, serde::Deserialize)]
@ -772,6 +783,11 @@ struct ProbeQuery {
locator: String, locator: String,
} }
#[derive(Debug, serde::Deserialize)]
struct ProbePlaylistBody {
locator: String,
}
#[derive(Debug, serde::Deserialize)] #[derive(Debug, serde::Deserialize)]
struct LoginBody { struct LoginBody {
username: String, username: String,
@ -842,6 +858,20 @@ async fn capture_handler(
)); ));
} }
} }
{
let is_valid_quality = |q: &str| {
q == "best"
|| q == "audio"
|| q.strip_suffix('p')
.and_then(|n| n.parse::<u32>().ok())
.is_some()
};
if let Some(bad) = body.per_item_quality.values().find(|q| !is_valid_quality(q)) {
return Err(ApiError::bad_request(&format!(
"invalid per_item_quality value {bad:?}: must be \"best\", \"audio\", or a height string like \"1080p\""
)));
}
}
let mounted = mounted_archive(&state, &archive_id)?; let mounted = mounted_archive(&state, &archive_id)?;
let archive_paths = let archive_paths =
archive::read_archive_paths(&mounted.archive_path).map_err(ApiError::from)?; archive::read_archive_paths(&mounted.archive_path).map_err(ApiError::from)?;
@ -879,8 +909,8 @@ async fn capture_handler(
modal_closer_enabled: Some(effective_modal_closer), modal_closer_enabled: Some(effective_modal_closer),
reader_mode: body.reader_mode.unwrap_or(false), reader_mode: body.reader_mode.unwrap_or(false),
via_freedium: body.via_freedium.unwrap_or(true), via_freedium: body.via_freedium.unwrap_or(true),
per_item_quality: std::collections::HashMap::new(), per_item_quality: body.per_item_quality.clone(),
sync: false, sync: body.sync,
}; };
// Spawn background capture. // Spawn background capture.
@ -1130,6 +1160,41 @@ async fn probe_handler(
}))) })))
} }
async fn probe_playlist_handler(
State(state): State<AppState>,
auth_user: AuthUser,
Path(archive_id): Path<String>,
Json(body): Json<ProbePlaylistBody>,
) -> Result<Json<downloader::ytdlp::PlaylistProbeResult>, ApiError> {
auth_user.require_role(ROLE_USER)?;
let locator = body.locator.trim().to_string();
if locator.is_empty() {
return Err(ApiError::bad_request("locator must not be empty"));
}
// Validate it's a playlist/channel source and expand shorthands.
let canonical_url = capture::locator_to_playlist_url(&locator)
.ok_or_else(|| ApiError::bad_request("locator is not a YouTube playlist, channel, or YTM playlist"))?;
// Verify archive exists.
let _ = mounted_archive(&state, &archive_id)?;
// Resolve cookies.
let cookie_rules = match database::open_auth_db(&state.auth_db_path) {
Ok(conn) => database::list_cookie_rules(&conn).unwrap_or_default(),
Err(_) => vec![],
};
let cookies = capture::resolve_cookies_for_url(&cookie_rules, &canonical_url);
// Shell out to yt-dlp in a blocking task.
let result = tokio::task::spawn_blocking(move || {
downloader::ytdlp::probe_playlist_qualities(&canonical_url, &cookies)
})
.await
.map_err(|_| ApiError::internal("probe-playlist task panicked"))?
.map_err(|e| ApiError {
status: StatusCode::BAD_GATEWAY,
message: format!("playlist probe failed: {e:#}"),
})?;
Ok(Json(result))
}
async fn auth_setup_status( async fn auth_setup_status(
State(state): State<AppState>, State(state): State<AppState>,
) -> Result<Json<serde_json::Value>, ApiError> { ) -> Result<Json<serde_json::Value>, ApiError> {

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -4,8 +4,8 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Archivr</title> <title>Archivr</title>
<script type="module" crossorigin src="/assets/index-DILm4nQE.js"></script> <script type="module" crossorigin src="/assets/index-JerAE27R.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Ba3TFwml.css"> <link rel="stylesheet" crossorigin href="/assets/index-vCRGeW0A.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View file

@ -153,6 +153,8 @@ export async function submitCapture(archiveId, locator, quality = null, extensio
if (typeof extensions.cookie_ext_enabled === 'boolean') payload.cookie_ext_enabled = extensions.cookie_ext_enabled if (typeof extensions.cookie_ext_enabled === 'boolean') payload.cookie_ext_enabled = extensions.cookie_ext_enabled
if (typeof extensions.modal_closer_enabled === 'boolean') payload.modal_closer_enabled = extensions.modal_closer_enabled if (typeof extensions.modal_closer_enabled === 'boolean') payload.modal_closer_enabled = extensions.modal_closer_enabled
if (typeof extensions.via_freedium === 'boolean') payload.via_freedium = extensions.via_freedium if (typeof extensions.via_freedium === 'boolean') payload.via_freedium = extensions.via_freedium
if (extensions.per_item_quality && typeof extensions.per_item_quality === 'object' && Object.keys(extensions.per_item_quality).length > 0) payload.per_item_quality = extensions.per_item_quality
if (extensions.sync === true) payload.sync = true
} }
const res = await fetch(`/api/archives/${archiveId}/captures`, { const res = await fetch(`/api/archives/${archiveId}/captures`, {
method: "POST", method: "POST",
@ -172,6 +174,19 @@ export async function probeCapture(archiveId, locator) {
return getJson(`/api/archives/${archiveId}/captures/probe?locator=${encodeURIComponent(locator)}`); return getJson(`/api/archives/${archiveId}/captures/probe?locator=${encodeURIComponent(locator)}`);
} }
export async function probePlaylist(archiveId, locator) {
const res = await fetch(`/api/archives/${archiveId}/captures/probe-playlist`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ locator }),
})
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error(body.error || `HTTP ${res.status}`)
}
return res.json()
}
export async function pollCaptureJob(archiveId, jobUid) { export async function pollCaptureJob(archiveId, jobUid) {
return getJson(`/api/archives/${archiveId}/capture_jobs/${jobUid}`); return getJson(`/api/archives/${archiveId}/capture_jobs/${jobUid}`);
} }

View file

@ -1,5 +1,5 @@
import { useRef, useEffect, useState, useCallback } from 'react' 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 let nextItemId = 1
@ -63,6 +63,43 @@ function isVideoSource(locator) {
return false 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 = '') { function makeItem(locator = '') {
return { return {
id: nextItemId++, locator, quality: 'best', id: nextItemId++, locator, quality: 'best',
@ -71,9 +108,39 @@ function makeItem(locator = '') {
probeQualities: null, // null | string[] when done, e.g. ["1080p","720p","480p"] probeQualities: null, // null | string[] when done, e.g. ["1080p","720p","480p"]
probeHasAudio: false, // true when probe confirms at least one audio track probeHasAudio: false, // true when probe confirms at least one audio track
status: 'idle', error: null, jobUid: null, archiveId: null, 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 = [] }) { export default function CaptureDialog({ open, archiveId, onClose, onCaptured, onToast, onJobStarted, onJobSettled, activeJobs = [] }) {
const dialogRef = useRef(null) const dialogRef = useRef(null)
const isFirstRenderRef = useRef(true) const isFirstRenderRef = useRef(true)
@ -283,7 +350,7 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
onToastRef.current(text, null, type, headline) onToastRef.current(text, null, type, headline)
} }
async function submitBgJob(locator, quality, batchId) { async function submitBgJob(locator, quality, batchId, extraExtensions = {}) {
const aid = archiveIdRef.current const aid = archiveIdRef.current
const id = crypto.randomUUID?.() ?? `job-${Date.now()}-${Math.random()}` const id = crypto.randomUUID?.() ?? `job-${Date.now()}-${Math.random()}`
// Capture session options at call time (synchronous before first await) // 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, cookie_ext_enabled: cookieExtEnabled,
modal_closer_enabled: modalCloserEnabled, modal_closer_enabled: modalCloserEnabled,
via_freedium: freediumEnabled, via_freedium: freediumEnabled,
...extraExtensions,
} }
try { try {
const job = await submitCapture(aid, locator, quality, extensions) const job = await submitCapture(aid, locator, quality, extensions)
@ -315,13 +383,21 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
if (batchId) { if (batchId) {
batchRef.current.set(batchId, { total: toSubmit.length, archived: 0, warnings: 0, failed: 0, failedLocators: [], warningLocators: [] }) batchRef.current.set(batchId, { total: toSubmit.length, archived: 0, warnings: 0, failed: 0, failedLocators: [], warningLocators: [] })
} }
// Capture options at call time before any state changes // Capture all submission data before any state changes
const capturedQuality = toSubmit.map(it => it.quality || 'best') 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 // Reset form and close dialog immediately
setItems([makeItem()]) setItems([makeItem()])
dialogRef.current?.close() dialogRef.current?.close()
// Submit each in background // 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() { function addRow() {
@ -340,16 +416,16 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
function updateLocator(id, val) { function updateLocator(id, val) {
// Cancel any in-flight debounce and immediately clear stale probe results. // Cancel any in-flight debounce and immediately clear stale probe results.
// This prevents old qualities from being visible (and submittable) while // 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)) clearTimeout(probeTimers.current.get(id))
setItems(prev => prev.map(it => setItems(prev => prev.map(it =>
it.id === id 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 : it
)) ))
if (!isVideoSource(val)) return if (isVideoSource(val)) {
// Schedule a fresh probe after the user stops typing // Schedule a fresh probe after the user stops typing
const timer = setTimeout(async () => { const timer = setTimeout(async () => {
probeTimers.current.delete(id) probeTimers.current.delete(id)
@ -372,13 +448,60 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
} }
}, 600) }, 600)
probeTimers.current.set(id, timer) 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) { function updateQuality(id, val) {
setItems(prev => prev.map(it => it.id === id ? { ...it, quality: val } : it)) 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 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 ( return (
<dialog ref={dialogRef} className="capture-dialog"> <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)} onQualityChange={val => updateQuality(item.id, val)}
onRemove={() => removeRow(item.id)} onRemove={() => removeRow(item.id)}
onSubmit={handleArchive} 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> </div>
@ -530,7 +657,7 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
type="button" type="button"
className="capture-submit" className="capture-submit"
onClick={handleArchive} onClick={handleArchive}
disabled={pendingCount === 0} disabled={pendingCount === 0 || anyConflict || anyProbing}
> >
{pendingCount > 1 ? `Archive ${pendingCount}` : 'Archive'} {pendingCount > 1 ? `Archive ${pendingCount}` : 'Archive'}
</button> </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) const inputRef = useRef(null)
useEffect(() => { useEffect(() => {
@ -554,6 +682,52 @@ function CaptureRow({ item, autoFocus, onLocatorChange, onQualityChange, onRemov
// Quality control shown right of the input // Quality control shown right of the input
const qualityEl = (() => { 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 (!isVideoSource(item.locator)) return null
if (item.probeState === 'probing') { if (item.probeState === 'probing') {
return <span className="capture-quality-probing" aria-label="Checking available qualities"></span> 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> return <span className="capture-quality-hint">No media detected</span>
} }
if (qualities.length === 0 && hasAudio) { 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 ( return (
<select <select
className="capture-quality" className="capture-quality"
@ -594,6 +766,13 @@ function CaptureRow({ item, autoFocus, onLocatorChange, onQualityChange, onRemov
return null // probeState === 'idle', debounce not yet fired 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 ( return (
<div className="capture-row"> <div className="capture-row">
<div className="capture-row-main"> <div className="capture-row-main">
@ -618,6 +797,33 @@ function CaptureRow({ item, autoFocus, onLocatorChange, onQualityChange, onRemov
{item.error && ( {item.error && (
<p className="capture-row-error">{item.error}</p> <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> </div>
) )
} }

View file

@ -2866,3 +2866,77 @@ body.has-audio-bar { padding-bottom: 56px; }
font-size: 0.85em; font-size: 0.85em;
opacity: 0.55; opacity: 0.55;
} }
/* ── Playlist quality expansion ──────────────────────────── */
.capture-playlist-toggle {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 0.75rem;
color: var(--muted);
background: none;
border: none;
cursor: pointer;
padding: 2px 6px;
}
.capture-playlist-toggle:hover { color: var(--text); }
.capture-conflict-badge {
font-size: 0.7rem;
color: #c07000;
background: #fff3cd;
border: 1px solid #e0a000;
border-radius: 4px;
padding: 1px 6px;
margin-left: 6px;
}
.capture-playlist-items {
border-top: 1px solid var(--line-soft);
padding: 4px 0;
max-height: 300px;
overflow-y: auto;
}
.capture-playlist-item {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 12px;
font-size: 0.8rem;
}
.capture-playlist-item--conflict {
background: #fff8f0;
}
.capture-playlist-item-title {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text);
}
.capture-item-quality {
font-size: 0.75rem;
padding: 2px 4px;
border: 1px solid var(--line);
border-radius: 4px;
background: var(--bg);
color: var(--text);
}
.capture-playlist-conflict-badge {
font-size: 0.7rem;
color: #c07000;
white-space: nowrap;
}
.capture-sync-row {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
font-size: 0.78rem;
color: var(--muted);
border-top: 1px solid var(--line-soft);
cursor: pointer;
}
.capture-sync-row input[type=checkbox] { cursor: pointer; }