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:
parent
096c98a678
commit
7499deeab0
8 changed files with 447 additions and 87 deletions
|
|
@ -264,6 +264,10 @@ pub fn app_with_state(state: AppState) -> Router {
|
|||
"/api/archives/:archive_id/captures/probe",
|
||||
get(probe_handler),
|
||||
)
|
||||
.route(
|
||||
"/api/archives/:archive_id/captures/probe-playlist",
|
||||
post(probe_playlist_handler),
|
||||
)
|
||||
.route(
|
||||
"/api/archives/:archive_id/capture_jobs/:job_uid",
|
||||
get(get_capture_job_handler),
|
||||
|
|
@ -765,6 +769,13 @@ struct CaptureBody {
|
|||
modal_closer_enabled: Option<bool>,
|
||||
/// Route through Freedium mirror for WebPage captures. Absent = true (on by default).
|
||||
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)]
|
||||
|
|
@ -772,6 +783,11 @@ struct ProbeQuery {
|
|||
locator: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
struct ProbePlaylistBody {
|
||||
locator: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
struct LoginBody {
|
||||
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 archive_paths =
|
||||
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),
|
||||
reader_mode: body.reader_mode.unwrap_or(false),
|
||||
via_freedium: body.via_freedium.unwrap_or(true),
|
||||
per_item_quality: std::collections::HashMap::new(),
|
||||
sync: false,
|
||||
per_item_quality: body.per_item_quality.clone(),
|
||||
sync: body.sync,
|
||||
};
|
||||
|
||||
// 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(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
47
crates/archivr-server/static/assets/index-JerAE27R.js
Normal file
47
crates/archivr-server/static/assets/index-JerAE27R.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -4,8 +4,8 @@
|
|||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Archivr</title>
|
||||
<script type="module" crossorigin src="/assets/index-DILm4nQE.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Ba3TFwml.css">
|
||||
<script type="module" crossorigin src="/assets/index-JerAE27R.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-vCRGeW0A.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
|
|
@ -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.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 (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`, {
|
||||
method: "POST",
|
||||
|
|
@ -172,6 +174,19 @@ export async function probeCapture(archiveId, 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) {
|
||||
return getJson(`/api/archives/${archiveId}/capture_jobs/${jobUid}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,16 +416,16 @@ 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
|
||||
|
||||
if (isVideoSource(val)) {
|
||||
// Schedule a fresh probe after the user stops typing
|
||||
const timer = setTimeout(async () => {
|
||||
probeTimers.current.delete(id)
|
||||
|
|
@ -372,13 +448,60 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
|
|||
}
|
||||
}, 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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2866,3 +2866,77 @@ body.has-audio-bar { padding-bottom: 56px; }
|
|||
font-size: 0.85em;
|
||||
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; }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue