1
Fork 0
mirror of https://github.com/thegeneralist01/archivr synced 2026-10-09 21:03:17 +02:00

Add YouTube subtitles, local transcription, self-updating yt-dlp/Deno, X Article and thread titles

- Capture YouTube subtitles by default (opt-out in UI, API, CLI --no-subtitles)
- Summarize YouTube videos from subtitles; fetch on demand, then local transcription, then error
- Local transcription fallback: Whisper, Parakeet, Phonon-2 (English only)
- Runtime-resolved, self-updating yt-dlp and Deno JS runtime (fixes YouTube 403s)
- Settings > Instance > yt-dlp: status and in-app update without restart
- X Article titles from article.title, with idempotent startup backfill
- Thread title generation (single and bulk) with per-provider cheap models
- Per-provider title model settings in Settings > Instance
- Docs, mental model, AGENTS.md and transcription spec updated
This commit is contained in:
TheGeneralist 2026-10-05 00:30:39 +02:00
parent 4f3b2968b6
commit 253f779216
35 changed files with 11377 additions and 583 deletions

View file

@ -37,18 +37,31 @@ export async function fetchEntrySummary(archiveId, entryUid, { signal } = {}) {
return getJson(`/api/archives/${archiveId}/entries/${entryUid}/summary`, { signal });
}
// Local transcription engines that are enabled and configured on this server
// ([{ kind, label, english_only, languages }]); an empty list means the
// feature is off. Logged-in users only; callers treat errors as [].
export async function fetchTranscriptionEngines({ signal } = {}) {
return getJson('/api/summary/transcription-engines', { signal });
}
// Kicks off generation. Resolves to either an existing completed summary (200)
// or a freshly claimed pending row (202) — both carry a summary_uid, so the
// caller polls fetchEntrySummary either way.
// The server returns 400 with the exact missing env var name when a provider is
// unconfigured, so its body is surfaced verbatim rather than replaced.
export async function requestEntrySummary(archiveId, entryUid, { provider, force = false, includeImages = false, signal } = {}) {
// `transcribeEngine` (a kind from fetchTranscriptionEngines) is sent only when
// non-empty; the server uses it only for YouTube videos without subtitles.
export async function requestEntrySummary(archiveId, entryUid, { provider, force = false, includeImages = false, transcribeEngine, signal } = {}) {
const payload = { provider, force, include_images: includeImages };
if (typeof transcribeEngine === 'string' && transcribeEngine.trim()) {
payload.transcribe_engine = transcribeEngine.trim();
}
const resp = await fetch(
`/api/archives/${archiveId}/entries/${entryUid}/summary`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ provider, force, include_images: includeImages }),
body: JSON.stringify(payload),
signal,
}
);
@ -130,6 +143,28 @@ export async function updateEntryTitle(archiveId, entryUid, title) {
if (!res.ok) throw new Error(await res.text());
}
// Names an X thread with a cheap model on the server and saves it as the title.
// Resolves to { entry_uid, title }; the server's { error } text is surfaced verbatim
// (e.g. a missing API-key variable or the provider's failure).
export async function generateThreadTitle(archiveId, entryUid, { provider }) {
const res = await fetch(`/api/archives/${archiveId}/entries/${entryUid}/thread-title`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ provider }),
});
if (!res.ok) {
const detail = await res.text();
let message = detail.trim();
try {
message = JSON.parse(detail).error || message;
} catch {
// non-JSON body
}
throw new Error(message || `Title generation failed (${res.status})`);
}
return res.json();
}
export async function fetchEntryTags(archiveId, entryUid) {
return getJson(`/api/archives/${archiveId}/entries/${entryUid}/tags`);
}
@ -214,13 +249,14 @@ export async function fetchTags(archiveId) {
export async function submitCapture(archiveId, locator, quality = null, extensions = null) {
const payload = { locator }
if (quality && quality !== 'best') payload.quality = quality
// extensions: { ublock_enabled?: bool, reader_mode?: bool, cookie_ext_enabled?: bool, modal_closer_enabled?: bool, via_freedium?: bool }
// extensions: { ublock_enabled?: bool, reader_mode?: bool, cookie_ext_enabled?: bool, modal_closer_enabled?: bool, via_freedium?: bool, download_subtitles?: bool }
if (extensions) {
if (typeof extensions.ublock_enabled === 'boolean') payload.ublock_enabled = extensions.ublock_enabled
if (typeof extensions.reader_mode === 'boolean') payload.reader_mode = extensions.reader_mode
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 (typeof extensions.download_subtitles === 'boolean') payload.download_subtitles = extensions.download_subtitles
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
}
@ -382,6 +418,23 @@ export async function updateInstanceSettings(patch) {
if (!res.ok) { const b = await res.json().catch(() => ({})); throw new Error(b.error || `HTTP ${res.status}`); }
}
export async function getYtDlpStatus({ signal } = {}) {
return getJson('/api/admin/yt-dlp', { signal });
}
// Runs the yt-dlp + Deno update server-side (same as `archivr yt-dlp update`); can take minutes.
// err.status carries the HTTP status (409 = another update is running).
export async function updateYtDlp() {
const res = await fetch('/api/admin/yt-dlp/update', { method: 'POST' });
if (!res.ok) {
const b = await res.json().catch(() => ({}));
const err = new Error(b.error || `HTTP ${res.status}`);
err.status = res.status;
throw err;
}
return res.json();
}
// ── Admin helpers ─────────────────────────────────────────────────────────────
export async function listAdminUsers() {

View file

@ -279,6 +279,7 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
const [cookieExtEnabled, setCookieExtEnabled] = useState(true)
const [modalCloserEnabled, setModalCloserEnabled] = useState(true)
const [freediumEnabled, setFreediumEnabled] = useState(true)
const [downloadSubtitles, setDownloadSubtitles] = useState(true)
// Load global settings from server once on mount
useEffect(() => {
@ -485,6 +486,7 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
cookie_ext_enabled: cookieExtEnabled,
modal_closer_enabled: modalCloserEnabled,
via_freedium: freediumEnabled,
download_subtitles: downloadSubtitles,
...extraExtensions,
}
try {
@ -957,6 +959,22 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
<span className="ext-toggle-knob" />
</button>
</label>
<label className="capture-ext-row" style={{ marginTop: 8 }}>
<span className="capture-ext-label">
<span className="capture-ext-name">Download subtitles</span>
<span className="capture-ext-desc">Save YouTube subtitles (manual preferred, auto-generated fallback) so videos can be summarized</span>
</span>
<button
type="button"
role="switch"
aria-checked={downloadSubtitles}
className={`ext-toggle ext-toggle--sm${downloadSubtitles ? ' ext-toggle--on' : ''}`}
onClick={() => setDownloadSubtitles(v => !v)}
aria-label="Toggle subtitle download for this capture"
>
<span className="ext-toggle-knob" />
</button>
</label>
</div>
)}
</div>

View file

@ -1,5 +1,5 @@
import { useState, useEffect, useLayoutEffect, useRef } from 'react'
import { fetchEntryTags, assignTag, removeTag, listEntryCollections, listCollections, addEntryToCollection, updateEntryTitle, deleteEntry, rearchiveEntry, pollCaptureJob, fetchEntrySummary, requestEntrySummary } from '../api'
import { fetchEntryTags, assignTag, removeTag, listEntryCollections, listCollections, addEntryToCollection, updateEntryTitle, generateThreadTitle, deleteEntry, rearchiveEntry, pollCaptureJob, fetchEntrySummary, requestEntrySummary, fetchTranscriptionEngines } from '../api'
import { formatTimestamp, formatBytes, valueText, sourceIconSvg, displayPath } from '../utils'
const VIS_LABEL = { 0: 'Private', 1: 'Public', 2: 'Users only', 3: 'Public' }
@ -14,9 +14,10 @@ const SUMMARY_PROVIDERS = [
]
const PROVIDER_LABEL = Object.fromEntries(SUMMARY_PROVIDERS.map(p => [p.value, p.label]))
const SUMMARY_PROVIDER_KEY = 'archivr:summary:provider'
const SUMMARY_TRANSCRIBE_ENGINE_KEY = 'archivr:summary:transcribe-engine'
const SUMMARY_POLL_MS = 1500
const UNSUPPORTED_SUMMARY_CONTENT_HEADING = 'This entry can’t be summarized yet.'
const UNSUPPORTED_SUMMARY_CONTENT_DETAIL = 'It doesn’t contain archived text that a summary provider can read. Summaries currently support text notes, web pages, X posts and threads, and X Articles. Video, audio, and image-only entries need a transcript or text source.'
const UNSUPPORTED_SUMMARY_CONTENT_DETAIL = 'It doesn’t contain archived text that a summary provider can read. Summaries currently support text notes, web pages, X posts and threads, X Articles, and YouTube videos with subtitles. Other video, audio, and image-only entries need a transcript or text source.'
const UNSUPPORTED_SUMMARY_CONTENT_MESSAGE = `${UNSUPPORTED_SUMMARY_CONTENT_HEADING}\n\n${UNSUPPORTED_SUMMARY_CONTENT_DETAIL}`
// Summaries are stored as the raw JSON string the model produced (normalized
@ -57,6 +58,8 @@ export default function ContextRail({ archiveId, selectedEntry, selectedUids, se
const [rearchiveState, setRearchiveState] = useState('idle') // 'idle' | 'running' | 'done' | 'error'
const [rearchiveError, setRearchiveError] = useState('')
const rearchivePollRef = useRef(null)
const [titleGenState, setTitleGenState] = useState('idle') // 'idle' | 'running' | 'done' | 'error'
const [titleGenError, setTitleGenError] = useState('')
const [fontsOpen, setFontsOpen] = useState(false)
useEffect(() => { setFontsOpen(false) }, [detail?.summary?.entry_uid])
@ -73,6 +76,14 @@ export default function ContextRail({ archiveId, selectedEntry, selectedUids, se
} catch { return SUMMARY_PROVIDERS[0].value }
})
const [includeSummaryImages, setIncludeSummaryImages] = useState(false)
// Local transcription engines offered by the server ([] = feature off) and
// the one picked for YouTube videos without subtitles ('' = none).
const [transcriptionEngines, setTranscriptionEngines] = useState([])
const [transcribeEngine, setTranscribeEngine] = useState(() => {
try {
return sessionStorage.getItem(SUMMARY_TRANSCRIBE_ENGINE_KEY) || ''
} catch { return '' }
})
const summaryPollRef = useRef(null)
const summaryPollAbortRef = useRef(null)
const summaryGenerateAbortRef = useRef(null)
@ -97,6 +108,21 @@ export default function ContextRail({ archiveId, selectedEntry, selectedUids, se
const [singleCollUid, setSingleCollUid] = useState('')
const [singleCollState, setSingleCollState] = useState('idle')
const [singleCollError, setSingleCollError] = useState('')
// Batch thread-title generation; the seq ref invalidates a run whenever the
// selection (as a set of uids) or archive changes.
const [bulkTitleState, setBulkTitleState] = useState('idle') // 'idle'|'running'|'done'
const [bulkTitleProgress, setBulkTitleProgress] = useState({ done: 0, total: 0 })
const [bulkTitleSummary, setBulkTitleSummary] = useState({ updated: 0, failed: 0, firstError: '' })
const bulkTitleSeqRef = useRef(0)
const bulkThreads = (selectedEntries || []).filter(e => e.entity_kind === 'tweet_thread')
const bulkSkipped = (selectedUids?.size || 0) - bulkThreads.length
const bulkSelectionKey = selectedUids ? [...selectedUids].sort().join(',') : ''
useEffect(() => {
bulkTitleSeqRef.current++
setBulkTitleState('idle')
setBulkTitleProgress({ done: 0, total: 0 })
setBulkTitleSummary({ updated: 0, failed: 0, firstError: '' })
}, [bulkSelectionKey, archiveId])
useEffect(() => {
const seq = ++selectSeqRef.current
@ -128,6 +154,17 @@ export default function ContextRail({ archiveId, selectedEntry, selectedUids, se
}).catch(() => {})
}, [selectedEntry, archiveId, isPublicSession])
// F4 state is keyed on entry identity, not the entry object: a successful
// title generation replaces `selectedEntry` (new title) and must not reset
// its own "Title updated." confirmation.
const titleGenSeqRef = useRef(0)
const selectedEntryUid = selectedEntry?.entry_uid
useEffect(() => {
titleGenSeqRef.current++
setTitleGenState('idle')
setTitleGenError('')
}, [selectedEntryUid, archiveId])
useEffect(() => {
return () => {
clearInterval(rearchivePollRef.current)
@ -201,6 +238,33 @@ export default function ContextRail({ archiveId, selectedEntry, selectedUids, se
summaryGenerateAbortRef.current?.abort()
}, [])
useEffect(() => {
if (isPublicSession) {
setTranscriptionEngines([])
return
}
const controller = new AbortController()
fetchTranscriptionEngines({ signal: controller.signal }).then(list => {
if (controller.signal.aborted) return
const engines = Array.isArray(list) ? list : []
setTranscriptionEngines(engines)
setTranscribeEngine(current => {
if (!current || engines.some(t => t.kind === current)) return current
try { sessionStorage.removeItem(SUMMARY_TRANSCRIBE_ENGINE_KEY) } catch { /* private mode */ }
return ''
})
}).catch(() => {
if (!controller.signal.aborted) setTranscriptionEngines([])
})
return () => controller.abort()
}, [isPublicSession])
// The engine is only offered (and sent) for YouTube videos, so a stale
// session choice never reaches the server for other entries.
const transcribeAvailable = transcriptionEngines.length > 0 &&
detail?.summary?.source_kind === 'youtube' &&
detail?.summary?.entity_kind === 'video'
async function handleGenerateSummary(force = false) {
if (!archiveId || !detail?.summary?.entry_uid || summaryBusy) return
const entryUid = detail.summary.entry_uid
@ -216,6 +280,7 @@ export default function ContextRail({ archiveId, selectedEntry, selectedUids, se
provider: summaryProvider,
force,
includeImages: includeSummaryImages,
transcribeEngine: transcribeAvailable ? transcribeEngine : '',
signal: controller.signal,
})
if (controller.signal.aborted || summarySelectionRef.current !== selectionKey) return
@ -245,6 +310,11 @@ export default function ContextRail({ archiveId, selectedEntry, selectedUids, se
try { sessionStorage.setItem(SUMMARY_PROVIDER_KEY, value) } catch { /* private mode */ }
}
function handleTranscribeEngineChange(value) {
setTranscribeEngine(value)
try { sessionStorage.setItem(SUMMARY_TRANSCRIBE_ENGINE_KEY, value) } catch { /* private mode */ }
}
// Fetch available collections whenever archiveId is available
useEffect(() => {
if (!archiveId) { setCollections([]); return }
@ -436,6 +506,58 @@ export default function ContextRail({ archiveId, selectedEntry, selectedUids, se
}
}
async function handleGenerateThreadTitle() {
if (!selectedEntry || !archiveId || titleGenState === 'running') return
const startSeq = titleGenSeqRef.current
const entryUid = selectedEntry.entry_uid
setTitleGenState('running')
setTitleGenError('')
try {
const { title } = await generateThreadTitle(archiveId, entryUid, { provider: summaryProvider })
// Saved server-side regardless of selection; App's caches are keyed by uid.
// Done state first (if still on the same entry), then notify App.
if (titleGenSeqRef.current === startSeq) setTitleGenState('done')
onEntryTitleChange?.(entryUid, title)
} catch (e) {
if (titleGenSeqRef.current !== startSeq) return
setTitleGenState('error')
setTitleGenError(e.message || 'Title generation failed.')
}
}
async function handleBulkGenerateTitles() {
if (!archiveId || bulkTitleState === 'running' || bulkThreads.length === 0) return
const startSeq = ++bulkTitleSeqRef.current
const uids = bulkThreads.map(e => e.entry_uid)
const provider = summaryProvider
let done = 0, updated = 0, failed = 0, firstError = ''
setBulkTitleState('running')
setBulkTitleProgress({ done: 0, total: uids.length })
setBulkTitleSummary({ updated: 0, failed: 0, firstError: '' })
let next = 0
async function worker() {
while (next < uids.length) {
if (bulkTitleSeqRef.current !== startSeq) return
const uid = uids[next++]
try {
const { title } = await generateThreadTitle(archiveId, uid, { provider })
updated++
// Saved server-side; App's caches are keyed by uid, so always propagate.
onEntryTitleChange?.(uid, title)
} catch (e) {
failed++
if (!firstError) firstError = e.message || 'Title generation failed.'
}
done++
if (bulkTitleSeqRef.current === startSeq) setBulkTitleProgress({ done, total: uids.length })
}
}
await Promise.all([worker(), worker()])
if (bulkTitleSeqRef.current !== startSeq) return
setBulkTitleState('done')
setBulkTitleSummary({ updated, failed, firstError })
}
const metaRows = detail ? [
['Added', formatTimestamp(detail.summary.archived_at)],
['Source', detail.summary.source_kind],
@ -529,6 +651,32 @@ export default function ContextRail({ archiveId, selectedEntry, selectedUids, se
</div>
)}
{bulkThreads.length > 0 && (
<div className="rail-section">
<div className="rail-section-heading">Thread titles</div>
<button
className="rail-rearchive-btn"
onClick={handleBulkGenerateTitles}
disabled={bulkTitleState === 'running'}
title={`Names each thread with a small model via ${PROVIDER_LABEL[summaryProvider] || summaryProvider} (the Summary provider)`}
>
{bulkTitleState === 'running'
? `Generating titles\u2026 ${bulkTitleProgress.done}/${bulkTitleProgress.total}`
: `Generate titles for ${bulkThreads.length} thread${bulkThreads.length === 1 ? '' : 's'}`}
</button>
{bulkSkipped > 0 && (
<p className="bulk-title-note">
{`${bulkSkipped} non-thread entr${bulkSkipped === 1 ? 'y' : 'ies'} will be skipped.`}
</p>
)}
{bulkTitleState === 'done' && (
<p className={`form-msg ${bulkTitleSummary.failed ? 'form-msg--err' : 'form-msg--ok'} bulk-title-note`} role="status">
{`${bulkTitleSummary.updated} updated${bulkTitleSummary.failed ? `, ${bulkTitleSummary.failed} failed: ${bulkTitleSummary.firstError}` : ''}`}
</p>
)}
</div>
)}
<div className="rail-delete-zone">
<button
className="rail-delete-btn"
@ -676,6 +824,22 @@ export default function ContextRail({ archiveId, selectedEntry, selectedUids, se
<option key={p.value} value={p.value}>{p.label}</option>
))}
</select>
{transcribeAvailable && (
<>
<select
className="rail-summary-select"
value={transcribeEngine}
onChange={e => handleTranscribeEngineChange(e.target.value)}
aria-label="Local transcription if no subtitles"
>
<option value="">No local transcription</option>
{transcriptionEngines.map(t => (
<option key={t.kind} value={t.kind}>{t.label}{t.english_only ? ' (English only)' : ''}</option>
))}
</select>
<p className="rail-summary-transcribe-note">Used only if this video has no subtitles. Transcription runs on this server and can take several minutes.</p>
</>
)}
<div className={`rail-summary-image-option${summaryProvider === 'claude_cli' ? ' rail-summary-image-option--disabled' : ''}`}>
<label className="rail-summary-image-option__label">
<input
@ -865,6 +1029,25 @@ export default function ContextRail({ archiveId, selectedEntry, selectedUids, se
{rearchiveState === 'error' && (
<p className="form-msg form-msg--err" style={{ marginTop: '6px' }}>{rearchiveError}</p>
)}
{detail.summary.entity_kind === 'tweet_thread' && (
<>
<button
className="rail-rearchive-btn"
style={{ marginTop: '8px' }}
onClick={handleGenerateThreadTitle}
disabled={titleGenState === 'running'}
title={`Names this thread with a small model via ${PROVIDER_LABEL[summaryProvider] || summaryProvider} (the Summary provider)`}
>
{titleGenState === 'running' ? 'Generating title\u2026' : 'Generate title'}
</button>
{titleGenState === 'done' && (
<p className="form-msg form-msg--ok" style={{ marginTop: '6px' }}>Title updated.</p>
)}
{titleGenState === 'error' && (
<p className="form-msg form-msg--err" style={{ marginTop: '6px' }}>{titleGenError}</p>
)}
</>
)}
</div>
)}

View file

@ -1,4 +1,4 @@
import { useState, useEffect, useContext, useCallback } from 'react'
import { useState, useEffect, useContext, useCallback, useRef } from 'react'
import { AuthContext } from '../App.jsx'
import {
updateProfile, changePassword, patchMe,
@ -7,6 +7,7 @@ import {
scanOrphanBlobs, deleteOrphanBlobs,
listCookieRules, createCookieRule, updateCookieRule, deleteCookieRule,
listRoles, fetchMe,
getYtDlpStatus, updateYtDlp,
} from '../api.js'
const ROLE_ADMIN = 4
@ -35,7 +36,12 @@ export default function SettingsView({ tab, onTabChange, archiveId }) {
{tab === 'profile' && <ProfileTab currentUser={currentUser} setCurrentUser={setCurrentUser} />}
{tab === 'tokens' && <TokensTab />}
{tab === 'instance' && isAdmin && <InstanceTab isOwner={isOwner} setCurrentUser={setCurrentUser} />}
{tab === 'instance' && isAdmin && (
<>
<InstanceTab isOwner={isOwner} setCurrentUser={setCurrentUser} />
<YtDlpSection />
</>
)}
{tab === 'cookies' && isAdmin && <CookiesTab />}
{tab === 'extensions' && isAdmin && <ExtensionsTab />}
{tab === 'storage' && isAdmin && <StorageTab archiveId={archiveId} />}
@ -242,6 +248,13 @@ function TokensTab() {
)
}
const TITLE_MODEL_PROVIDERS = [
['anthropic_http', 'Anthropic API'],
['openai_compatible', 'OpenAI-compatible API'],
['claude_cli', 'Claude CLI'],
['codex_cli', 'Codex CLI'],
]
function InstanceTab({ isOwner, setCurrentUser }) {
const [settings, setSettings] = useState(null)
const [loading, setLoading] = useState(true)
@ -290,8 +303,9 @@ function InstanceTab({ isOwner, setCurrentUser }) {
e.preventDefault()
setSaving(true); setSaveMsg(null)
try {
const { reorder_children_role_bits: _mask, ...rest } = settings
const { reorder_children_role_bits: _mask, title_models: _models, ...rest } = settings
await updateInstanceSettings(rest)
setSettings(await getInstanceSettings()) // server-trimmed models + effective sources
setSaveMsg({ ok: true, text: 'Saved.' })
} catch (err) {
setSaveMsg({ ok: false, text: err.message })
@ -329,6 +343,23 @@ function InstanceTab({ isOwner, setCurrentUser }) {
<option value={3}>Public</option>
</select>
</div>
<div className="form-field" style={{ marginTop: 4 }}>
<label className="form-label">Thread title models</label>
{TITLE_MODEL_PROVIDERS.map(([kind, label]) => {
const key = `title_model_${kind}`
const info = settings.title_models?.[kind]
const placeholder = info ? `${info.fallback_model} (${info.fallback_source})` : ''
return (
<div key={kind} className="form-field">
<label className="form-label" htmlFor={key}>{label}</label>
<input id={key} className="field-input" type="text" maxLength={100}
value={settings[key] ?? ''} placeholder={placeholder}
onChange={e => setSettings(s => ({ ...s, [key]: e.target.value }))} />
</div>
)
})}
<p className="form-hint">Cheap model used for Generate title. Leave blank to use the default.</p>
</div>
{saveMsg && <div className={`form-msg form-msg--${saveMsg.ok ? 'ok' : 'err'}`}>{saveMsg.text}</div>}
<button className="btn-primary" type="submit" disabled={saving}>
{saving ? 'Saving\u2026' : 'Save Settings'}
@ -859,4 +890,212 @@ function ExtensionsTab() {
</div>
</div>
)
}
const YT_DLP_SOURCE_LABELS = {
force: 'Forced (ARCHIVR_YT_DLP_FORCE)',
env: 'Pinned (ARCHIVR_YT_DLP)',
'state-dir': 'Managed install',
path: 'System PATH',
}
const JS_SOURCE_LABELS = {
force: 'Forced (ARCHIVR_JS_RUNTIME)',
env: 'Pinned (ARCHIVR_DENO)',
'state-dir': 'Managed install',
path: 'System PATH',
}
function sourceLabel(labels, row) {
return (row?.role && labels[row.role]) || row?.label || 'unknown source'
}
function YtDlpCandidateTable({ caption, rows, labels }) {
return (
<div className="form-field">
<div className="form-label">{caption}</div>
<div className="ytdlp-table-wrap">
<table className="admin-table ytdlp-table">
<thead>
<tr><th>Source</th><th>Path</th><th>Version</th><th></th></tr>
</thead>
<tbody>
{rows.map(row => (
<tr key={row.role} className={row.chosen ? 'ytdlp-row--chosen' : undefined}>
<td>{sourceLabel(labels, row)}</td>
<td className="ytdlp-path">
{row.path ?? <span className="muted">not set</span>}
</td>
<td>
{row.invalid
? <span className="form-msg--err">invalid: {row.invalid}</span>
: (row.path ? (row.version ?? '\u2014') : '\u2014')}
</td>
<td>{row.chosen && <span className="ytdlp-badge">in use</span>}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
function YtDlpSection() {
const [status, setStatus] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [updating, setUpdating] = useState(false)
const [result, setResult] = useState(null)
const [updateError, setUpdateError] = useState(null)
const loadCtrl = useRef(null)
const alive = useRef(true)
// Probing every candidate takes seconds, so this loads separately from the settings form.
// A newer load or unmounting aborts the previous request. Resolves to the status or null.
async function load() {
loadCtrl.current?.abort()
const ctrl = new AbortController()
loadCtrl.current = ctrl
setLoading(true)
setError(null)
try {
const s = await getYtDlpStatus({ signal: ctrl.signal })
if (!ctrl.signal.aborted) setStatus(s)
return s
} catch (e) {
if (!ctrl.signal.aborted) setError(e.message)
return null
} finally {
if (!ctrl.signal.aborted) setLoading(false)
}
}
useEffect(() => {
alive.current = true
load()
return () => {
alive.current = false
loadCtrl.current?.abort()
}
}, [])
async function handleUpdate() {
setUpdating(true)
setResult(null)
setUpdateError(null)
try {
const r = await updateYtDlp()
setResult(r)
setStatus(r.status)
} catch (err) {
// A proxy may time out (e.g. 504) while the update keeps running server-side.
const s = err.status !== 409 && alive.current ? await load() : null
setUpdateError(s?.update_running
? 'Update still running on the server — refresh in a minute.'
: err.message)
} finally {
setUpdating(false)
}
}
const ytChosen = status?.yt_dlp_chosen
const ytChosenRow = (status?.yt_dlp ?? []).find(r => r.chosen)
const jsChosen = status?.js_runtime_chosen
const inUse = status?.js_runtime_in_use
const invalidRows = (status?.js_runtime ?? []).filter(r => r.invalid)
return (
<div className="form-section ytdlp-section">
<h2>yt-dlp</h2>
{loading && !status && <div className="muted">Loading{'\u2026'}</div>}
{error && <div className="form-msg form-msg--err">{error}</div>}
{status && (
<>
<dl className="ytdlp-summary">
<dt>yt-dlp</dt>
<dd>
{ytChosen?.version ?? 'unknown'}
<span className="muted"> · {ytChosen?.role ? YT_DLP_SOURCE_LABELS[ytChosen.role] : 'unlisted path'}</span>
{ytChosen?.path && <div className="ytdlp-path">{ytChosen.path}</div>}
</dd>
<dt>JS runtime</dt>
<dd>
{jsChosen ? (
<>
{jsChosen.kind} {jsChosen.version ?? ''}
<span className="muted"> · {JS_SOURCE_LABELS[jsChosen.role] ?? jsChosen.role}</span>
{jsChosen.path && <div className="ytdlp-path">{jsChosen.path}</div>}
</>
) : <span className="form-msg--err">none</span>}
</dd>
<dt>In use by this server</dt>
<dd>
{inUse ? (
<>
{inUse.kind}{jsChosen && jsChosen.path === inUse.path && jsChosen.version ? ` ${jsChosen.version}` : ''}
{inUse.path && <div className="ytdlp-path">{inUse.path}</div>}
</>
) : <span className="muted">no JS runtime</span>}
</dd>
</dl>
{ytChosenRow?.invalid && (
<div className="form-msg form-msg--err">
The yt-dlp in use does not run: {ytChosenRow.invalid}
</div>
)}
{!jsChosen && (
<div className="form-msg form-msg--err">
No JS runtime resolved — YouTube downloads may fail with HTTP 403. Update below to install Deno.
</div>
)}
{invalidRows.map(r => (
<div key={r.role} className="form-msg form-msg--err">
ARCHIVR_JS_RUNTIME={r.path} is invalid ({r.invalid}) and is ignored.
</div>
))}
{status.state_dir && <p className="form-hint">State directory: <span className="ytdlp-path">{status.state_dir}</span></p>}
{!status.yt_dlp_installed && status.yt_dlp_target && (
<p className="form-hint">No managed yt-dlp yet — the update installs it to {status.yt_dlp_target}.</p>
)}
{!status.deno_installed && status.deno_target && (
<p className="form-hint">No managed Deno yet — the update installs it to {status.deno_target}.</p>
)}
<YtDlpCandidateTable caption="yt-dlp candidates" rows={status.yt_dlp} labels={YT_DLP_SOURCE_LABELS} />
<YtDlpCandidateTable caption="JS runtime candidates" rows={status.js_runtime} labels={JS_SOURCE_LABELS} />
</>
)}
{(status || !loading) && (
<div className="ytdlp-actions">
<button className="btn-primary" type="button"
disabled={updating || status?.update_running} onClick={handleUpdate}>
{updating ? 'Updating\u2026 (can take a few minutes)' : 'Update yt-dlp & Deno'}
</button>
<button className="btn-ghost" type="button" disabled={updating || loading} onClick={load}>
Refresh
</button>
{status?.update_running && !updating && <span className="form-hint">An update is already running.</span>}
</div>
)}
{result && (
<>
{[['yt-dlp', result.yt_dlp], ['Deno', result.deno]].map(([name, o]) => (
<div key={name} className={`form-msg form-msg--${o.ok ? 'ok' : 'err'}`}>
{name}: {o.ok ? o.message : `failed — ${o.message}`}
</div>
))}
{(result.yt_dlp.ok || result.deno.ok) && (
<p className="form-hint">New binaries are used for the next capture — no restart needed.</p>
)}
</>
)}
{updateError && <div className="form-msg form-msg--err">{updateError}</div>}
</div>
)
}

View file

@ -638,6 +638,9 @@ select {
}
.bulk-coll-select:focus { border-color: var(--accent); }
.tag-add-btn:disabled { opacity: 0.45; cursor: default; }
.bulk-title-note { font-size: 12px; color: var(--muted); margin: 6px 0 0; }
.bulk-title-note.form-msg--ok { color: var(--link); }
.bulk-title-note.form-msg--err { color: var(--accent); }
/* ── Rail delete zone ───────────────────────────────────────────────────── */
.rail-delete-zone {
@ -2097,6 +2100,16 @@ select {
}
.admin-table td:first-child { padding-left: 16px; }
.admin-table tr:hover td { background: var(--paper-2); }
/* ── Settings › yt-dlp ── */
.ytdlp-section { max-width: 760px; }
.ytdlp-summary { display: grid; grid-template-columns: max-content 1fr; gap: 6px 16px; margin: 0 0 12px; font-size: 14px; }
.ytdlp-summary dt { font-weight: 700; color: var(--muted); }
.ytdlp-summary dd { margin: 0; }
.ytdlp-path { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; word-break: break-all; }
.ytdlp-table-wrap { overflow-x: auto; margin: 6px 0 14px; border: 1px solid var(--line-soft); }
.ytdlp-row--chosen td { background: var(--paper-2); }
.ytdlp-badge { font-size: 11px; font-weight: 700; color: var(--paper); background: var(--link); border-radius: 3px; padding: 1px 6px; white-space: nowrap; }
.ytdlp-actions { display: flex; gap: 8px; align-items: center; margin: 8px 0; }
.admin-row-disabled td { opacity: 0.45; }
.admin-section { margin-bottom: 36px; max-width: 860px; }
.admin-section h2 {
@ -3384,6 +3397,12 @@ body.has-audio-bar { padding-bottom: 56px; }
font-size: 11px;
line-height: 1.4;
}
.rail-summary-transcribe-note {
margin: 0;
font-size: 11px;
line-height: 1.4;
color: var(--muted);
}
.rail-summary-image-option--disabled {
color: var(--muted-2);
background: var(--paper);