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",
|
"/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
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 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>
|
||||||
|
|
|
||||||
|
|
@ -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}`);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,45 +416,92 @@ 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)
|
setItems(prev => prev.map(it => it.id === id ? { ...it, probeState: 'probing' } : it))
|
||||||
setItems(prev => prev.map(it => it.id === id ? { ...it, probeState: 'probing' } : it))
|
try {
|
||||||
try {
|
const result = await probeCapture(archiveIdRef.current, val.trim())
|
||||||
const result = await probeCapture(archiveIdRef.current, val.trim())
|
setItems(prev => prev.map(it => {
|
||||||
setItems(prev => prev.map(it => {
|
if (it.id !== id || it.locator !== val) return it // stale — locator changed again
|
||||||
if (it.id !== id || it.locator !== val) return it // stale — locator changed again
|
const qualities = result.qualities ?? []
|
||||||
const qualities = result.qualities ?? []
|
const hasAudio = result.has_audio ?? false
|
||||||
const hasAudio = result.has_audio ?? false
|
// Audio-only source: no video heights but audio confirmed — force audio mode
|
||||||
// Audio-only source: no video heights but audio confirmed — force audio mode
|
const quality = (qualities.length === 0 && hasAudio) ? 'audio' : 'best'
|
||||||
const quality = (qualities.length === 0 && hasAudio) ? 'audio' : 'best'
|
return { ...it, probeState: 'done', probeQualities: qualities, probeHasAudio: hasAudio, quality }
|
||||||
return { ...it, probeState: 'done', probeQualities: qualities, probeHasAudio: hasAudio, quality }
|
}))
|
||||||
}))
|
} catch {
|
||||||
} catch {
|
// Probe failed (network error, etc.) — clear silently; user can still submit
|
||||||
// Probe failed (network error, etc.) — clear silently; user can still submit
|
setItems(prev => prev.map(it =>
|
||||||
setItems(prev => prev.map(it =>
|
it.id === id ? { ...it, probeState: 'idle', probeQualities: null } : it
|
||||||
it.id === id ? { ...it, probeState: 'idle', probeQualities: null } : it
|
))
|
||||||
))
|
}
|
||||||
}
|
}, 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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue