import { useRef, useEffect, useState, useCallback } from 'react' import { submitCapture, pollCaptureJob, probeCapture, getInstanceSettings } from '../api' let nextItemId = 1 // Returns true only for locators that determine_source() routes to yt-dlp download. // Mirrors the exact conditions in capture.rs — playlist/channel shorthands are excluded // (they return an "not yet implemented" error), as are tweet/thread shorthands. function isVideoSource(locator) { const l = locator.trim() const ll = l.toLowerCase() // yt: / youtube: shorthands — video/short/shorts only; playlist and channel are unsupported for (const scheme of ['yt:', 'youtube:']) { if (ll.startsWith(scheme)) { const after = ll.slice(scheme.length) return after.startsWith('video/') || after.startsWith('short/') || after.startsWith('shorts/') } } // ytm: shorthand — track only; playlist is not yet implemented if (ll.startsWith('ytm:')) { return !ll.slice(4).startsWith('playlist/') } // x: / twitter: / tweet: shorthands — only x:media:ID routes to yt-dlp (Source::X) for (const scheme of ['x:', 'twitter:', 'tweet:']) { if (ll.startsWith(scheme)) { return ll.slice(scheme.length).startsWith('media:') } } // spotify: shorthands — all will fail with a clear error; no probe needed if (ll.startsWith('spotify:')) return false // Other platform shorthands — all go to yt-dlp if (ll.startsWith('instagram:') || ll.startsWith('facebook:') || ll.startsWith('tiktok:') || ll.startsWith('reddit:') || ll.startsWith('snapchat:')) return true // HTTP/HTTPS URLs — match the same regexes and prefix checks as determine_source if (ll.startsWith('http://') || ll.startsWith('https://')) { // YouTube Music track (watch) — before generic YouTube check if (/^https?:\/\/music\.youtube\.com\/watch/.test(ll)) return true // YouTube video (watch, youtu.be, shorts) — not playlist or channel if (/^https?:\/\/(?:www\.)?(?:youtu\.be\/[0-9A-Za-z_-]+|youtube\.com\/watch\?v=[0-9A-Za-z_-]+|youtube\.com\/shorts\/[0-9A-Za-z_-]+)/.test(l)) return true // x.com → Source::X → yt-dlp (note: twitter.com URLs fall through to Source::Url, not yt-dlp) if (ll.startsWith('https://x.com/') || ll.startsWith('http://x.com/')) return true // Instagram if (/^https?:\/\/(?:www\.)?instagram\.com\//.test(ll)) return true // Facebook + fb.watch if (/^https?:\/\/(?:www\.)?facebook\.com\//.test(ll) || ll.startsWith('https://fb.watch/') || ll.startsWith('http://fb.watch/')) return true // TikTok if (/^https?:\/\/(?:www\.)?tiktok\.com\//.test(ll)) return true // Reddit + redd.it if (/^https?:\/\/(?:www\.)?reddit\.com\//.test(ll) || ll.startsWith('https://redd.it/') || ll.startsWith('http://redd.it/')) return true // Snapchat if (/^https?:\/\/(?:www\.)?snapchat\.com\//.test(ll)) return true // Spotify — all will fail with a clear error; no probe needed if (ll.startsWith('https://open.spotify.com/') || ll.startsWith('http://open.spotify.com/')) return false } return false } function makeItem(locator = '') { return { id: nextItemId++, locator, quality: 'best', // probe: tracks yt-dlp metadata fetch for the locator probeState: 'idle', // 'idle' | 'probing' | 'done' probeQualities: null, // null | string[] when done, e.g. ["1080p","720p","480p"] probeHasAudio: false, // true when probe confirms at least one audio track status: 'idle', error: null, jobUid: null, archiveId: null, } } function hasActiveJobs(items) { return items.some(it => it.status === 'submitting' || it.status === 'running') } export default function CaptureDialog({ open, archiveId, onClose, onCaptured, onToast }) { const dialogRef = useRef(null) const isFirstRenderRef = useRef(true) // jobUid → intervalId; survives dialog close since component stays mounted const pollIntervals = useRef(new Map()) // itemId → debounce timeoutId for probe calls const probeTimers = useRef(new Map()) // batchId → { total, archived, warnings, failed }; only populated for multi-URL submits const batchRef = useRef(new Map()) // stable ref so probe callbacks always see the current archiveId const archiveIdRef = useRef(archiveId) useEffect(() => { archiveIdRef.current = archiveId }, [archiveId]) // Stable refs so polling callbacks always use the latest prop values const onCapturedRef = useRef(onCaptured) const onToastRef = useRef(onToast) useEffect(() => { onCapturedRef.current = onCaptured }, [onCaptured]) useEffect(() => { onToastRef.current = onToast }, [onToast]) const [items, setItems] = useState(() => { try { const saved = JSON.parse(sessionStorage.getItem('captureItems') || 'null') if (Array.isArray(saved) && saved.length > 0) { // Ensure nextItemId stays ahead of restored ids saved.forEach(it => { if (it.id >= nextItemId) nextItemId = it.id + 1 }) return saved } } catch {} return [makeItem()] }) // Persist items to sessionStorage on every change useEffect(() => { sessionStorage.setItem('captureItems', JSON.stringify(items)) }, [items]) // Advanced options panel state const [advancedOpen, setAdvancedOpen] = useState(false) // null = use server default; true/false = per-session override const [ublockOverride, setUblockOverride] = useState(null) // Server-side global settings (loaded on mount, null until loaded) const [globalSettings, setGlobalSettings] = useState(null) // Cookie consent: session-level only, initialized from server default const [cookieExtEnabled, setCookieExtEnabled] = useState(true) // Load global settings from server once on mount useEffect(() => { getInstanceSettings() .then(s => { setGlobalSettings(s) setCookieExtEnabled(s.cookie_ext_enabled ?? true) }) .catch(() => setGlobalSettings({})) }, []) // Effective uBlock for this session const ublockEnabled = ublockOverride !== null ? ublockOverride : (globalSettings?.ublock_enabled ?? true) // Reader mode: off by default, per-session only const [readerMode, setReaderMode] = useState(false) // On mount: clean up old single-locator sessionStorage keys; reconnect running jobs useEffect(() => { ;['captureDialogLocator','captureDialogError','captureDialogBusy', 'captureDialogJobStatus','captureDialogJobUid'].forEach(k => sessionStorage.removeItem(k)) setItems(prev => prev.map(it => // 'submitting' means page was refreshed mid-fetch — reset to idle so user can retry it.status === 'submitting' ? { ...it, status: 'idle', error: null } : it )) // Reconnect polling for any jobs still running from a previous session items.forEach(it => { if (it.status === 'running' && it.jobUid && it.archiveId && !pollIntervals.current.has(it.jobUid)) { startPolling(it.id, it.jobUid, it.locator, it.archiveId) } }) // eslint-disable-next-line react-hooks/exhaustive-deps }, []) // intentionally runs once on mount with initial values // Handle native dialog 'close' event (Escape key or programmatic .close()) useEffect(() => { const dialog = dialogRef.current if (!dialog) return const handler = () => { probeTimers.current.forEach(id => clearTimeout(id)) probeTimers.current.clear() onClose() } dialog.addEventListener('close', handler) return () => dialog.removeEventListener('close', handler) }, [onClose]) // Open/close driven by parent; don't reset if active jobs are in flight useEffect(() => { const dialog = dialogRef.current if (!dialog) return if (open) { if (!isFirstRenderRef.current && !hasActiveJobs(items)) { setItems([makeItem()]) } isFirstRenderRef.current = false if (!dialog.open) dialog.showModal() } else { probeTimers.current.forEach(id => clearTimeout(id)) probeTimers.current.clear() if (dialog.open) dialog.close() } }, [open]) // eslint-disable-line react-hooks/exhaustive-deps // Clear all intervals and probe timers on unmount (component teardown) useEffect(() => { return () => { pollIntervals.current.forEach(id => clearInterval(id)) probeTimers.current.forEach(id => clearTimeout(id)) } }, []) function startPolling(itemId, jobUid, locator, aid, batchId = null) { if (pollIntervals.current.has(jobUid)) return // already polling const intervalId = setInterval(async () => { try { const updated = await pollCaptureJob(aid, jobUid) if (updated.status === 'completed') { clearInterval(pollIntervals.current.get(jobUid)) pollIntervals.current.delete(jobUid) setItems(prev => prev.map(it => it.id === itemId ? { ...it, status: 'completed' } : it)) setTimeout(() => { setItems(prev => { const next = prev.filter(it => it.id !== itemId) return next.length === 0 ? [makeItem()] : next }) }, 1400) onCapturedRef.current() try { const notes = updated.notes_json ? JSON.parse(updated.notes_json) : null if (notes?.ublock_skipped || notes?.cookie_ext_skipped) { const both = notes.ublock_skipped && notes.cookie_ext_skipped const msg = both ? 'Captured without ad-blocking or cookie-consent extension. Check ARCHIVR_UBLOCK_EXT and ARCHIVR_COOKIE_EXT in your server config.' : notes.ublock_skipped ? 'Captured without ad-blocking. ARCHIVR_UBLOCK=true but ARCHIVR_UBLOCK_EXT is not set or the path is invalid.' : 'Captured without cookie-consent extension. ARCHIVR_COOKIE_EXT is not set or the path is invalid.' onToastRef.current(msg, locator, 'warning') settleBatch(batchId, 'warning', locator) } else { if (!batchId) onToastRef.current(null, locator, 'success') settleBatch(batchId, 'archived') } } catch { if (!batchId) onToastRef.current(null, locator, 'success') settleBatch(batchId, 'archived') } } else if (updated.status === 'failed') { clearInterval(pollIntervals.current.get(jobUid)) pollIntervals.current.delete(jobUid) const errText = updated.error_text || 'Capture failed.' setItems(prev => prev.map(it => it.id === itemId ? { ...it, status: 'failed', error: errText } : it )) onToastRef.current(errText, locator) settleBatch(batchId, 'failed', locator) } // 'pending' / 'running': keep polling } catch (e) { clearInterval(pollIntervals.current.get(jobUid)) pollIntervals.current.delete(jobUid) const msg = e.message || 'Network error' setItems(prev => prev.map(it => it.id === itemId ? { ...it, status: 'failed', error: msg } : it )) onToastRef.current(msg, locator) settleBatch(batchId, 'failed', locator) } }, 500) pollIntervals.current.set(jobUid, intervalId) } // Increments the batch counter for the given outcome and emits a summary // toast once all jobs in the batch have settled. // 'warning' counts as archived (succeeded with caveats); locator recorded for detail text. function settleBatch(batchId, outcome, locator = null) { if (!batchId) return const batch = batchRef.current.get(batchId) if (!batch) return if (outcome === 'archived' || outcome === 'warning') { batch.archived++ if (outcome === 'warning') { batch.warnings++; if (locator) batch.warningLocators.push(locator) } } else { batch.failed++ if (locator) batch.failedLocators.push(locator) } if (batch.archived + batch.failed < batch.total) return // All settled — build headline + detail text, then emit summary and clean up. batchRef.current.delete(batchId) const { archived, warnings, failed, failedLocators, warningLocators } = batch let headline if (archived === 0) { headline = `${failed} failed` } else { const archivedStr = warnings > 0 ? `${archived} archived (${warnings} with warnings)` : `${archived} archived` headline = failed > 0 ? `${archivedStr}, ${failed} failed` : archivedStr } const type = archived === 0 ? 'error' : (failed > 0 || warnings > 0) ? 'warning' : 'success' const parts = [] if (failedLocators.length > 0) parts.push(`Failed:\n${failedLocators.map(l => ` ${l}`).join('\n')}`) if (warningLocators.length > 0) parts.push(`With warnings:\n${warningLocators.map(l => ` ${l}`).join('\n')}`) const text = parts.length > 0 ? parts.join('\n') : null onToastRef.current(text, null, type, headline) } async function submitItem(item, batchId = null) { if (!item.locator.trim()) return const aid = archiveId const loc = item.locator.trim() const qual = item.quality || 'best' setItems(prev => prev.map(it => it.id === item.id ? { ...it, status: 'submitting', error: null } : it)) try { const extensions = { ublock_enabled: ublockEnabled, reader_mode: readerMode, cookie_ext_enabled: cookieExtEnabled } const job = await submitCapture(aid, loc, qual, extensions) setItems(prev => prev.map(it => it.id === item.id ? { ...it, status: 'running', jobUid: job.job_uid, archiveId: aid } : it )) startPolling(item.id, job.job_uid, loc, aid, batchId) } catch (e) { const msg = e.message || 'Submission failed.' setItems(prev => prev.map(it => it.id === item.id ? { ...it, status: 'failed', error: msg } : it)) onToastRef.current(msg, loc) settleBatch(batchId, 'failed', loc) } } function handleArchive() { const toSubmit = items.filter(it => it.status === 'idle' && it.locator.trim()) if (toSubmit.length === 0) return const batchId = toSubmit.length > 1 ? (crypto.randomUUID?.() ?? `batch-${Date.now()}`) : null if (batchId) { batchRef.current.set(batchId, { total: toSubmit.length, archived: 0, warnings: 0, failed: 0, failedLocators: [], warningLocators: [] }) } toSubmit.forEach(it => submitItem(it, batchId)) dialogRef.current?.close() } function addRow() { setItems(prev => [...prev, makeItem()]) } function removeRow(id) { clearTimeout(probeTimers.current.get(id)) probeTimers.current.delete(id) setItems(prev => { const next = prev.filter(it => it.id !== id) return next.length === 0 ? [makeItem()] : next }) } function resetRow(id) { setItems(prev => prev.map(it => it.id === id ? { ...it, status: 'idle', error: null } : it )) } 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. clearTimeout(probeTimers.current.get(id)) setItems(prev => prev.map(it => it.id === id ? { ...it, locator: val, probeState: 'idle', probeQualities: null, probeHasAudio: false, quality: 'best' } : it )) if (!isVideoSource(val)) return // Schedule a fresh probe after the user stops typing const timer = setTimeout(async () => { probeTimers.current.delete(id) setItems(prev => prev.map(it => it.id === id ? { ...it, probeState: 'probing' } : it)) try { const result = await probeCapture(archiveIdRef.current, val.trim()) setItems(prev => prev.map(it => { if (it.id !== id || it.locator !== val) return it // stale — locator changed again const qualities = result.qualities ?? [] const hasAudio = result.has_audio ?? false // Audio-only source: no video heights but audio confirmed — force audio mode const quality = (qualities.length === 0 && hasAudio) ? 'audio' : 'best' return { ...it, probeState: 'done', probeQualities: qualities, probeHasAudio: hasAudio, quality } })) } catch { // Probe failed (network error, etc.) — clear silently; user can still submit setItems(prev => prev.map(it => it.id === id ? { ...it, probeState: 'idle', probeQualities: null } : it )) } }, 600) probeTimers.current.set(id, timer) } function updateQuality(id, val) { setItems(prev => prev.map(it => it.id === id ? { ...it, quality: val } : it)) } const pendingCount = items.filter(it => it.status === 'idle' && it.locator.trim()).length const anyActive = hasActiveJobs(items) return ( ) } function CaptureRow({ item, autoFocus, onLocatorChange, onQualityChange, onRemove, onReset, onSubmit }) { const inputRef = useRef(null) const isActive = item.status === 'submitting' || item.status === 'running' useEffect(() => { if (autoFocus && item.status === 'idle') { inputRef.current?.focus() } }, [autoFocus]) // eslint-disable-line react-hooks/exhaustive-deps // Quality control shown right of the input (hidden when active or completed) const qualityEl = (() => { if (item.status === 'completed' || isActive) return null if (!isVideoSource(item.locator)) return null if (item.probeState === 'probing') { return … } if (item.probeState === 'done') { const qualities = item.probeQualities ?? [] const hasAudio = item.probeHasAudio ?? false if (qualities.length === 0 && !hasAudio) { return No media detected } 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 ( ) } return null // probeState === 'idle', debounce not yet fired })() return (
{item.error}
)}