diff --git a/Dockerfile b/Dockerfile index 3978bed..c4e5b4e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -53,11 +53,13 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ COPY --from=builder /app/vendor ./vendor/ COPY --from=builder /app/server/node_modules ./server/node_modules/ COPY server/package.json ./server/ -# Top-level *.js files only — picks up index.js, license.js, util.js, -# gemini-helpers.js, audio.js, ytdlp.js, cookies.js, config.js, -# license-middleware.js, history.js, library.js, and any future -# extractions automatically. (Glob skips subdirs like server/test/.) +# Top-level *.js files (index.js, license.js, util.js, gemini-helpers.js, +# audio.js, ytdlp.js, cookies.js, config.js, license-middleware.js, +# history.js, library.js, admin-auth.js, …) PLUS the providers/ +# subdirectory (multi-provider AI adapters). Anything new added in +# `server//` needs its own COPY line — the glob does not recurse. COPY server/*.js ./server/ +COPY server/providers/ ./server/providers/ COPY public/ ./public/ COPY assets/ ./assets/ diff --git a/bin/bump-version.sh b/bin/bump-version.sh index 9d9c190..6c4c590 100755 --- a/bin/bump-version.sh +++ b/bin/bump-version.sh @@ -9,8 +9,8 @@ # # If the file .release-notes-pending.txt exists in the project root, its # contents are shown as the default release notes (just press Enter to accept). -# This is the convention Claude uses after making code changes. The file is -# deleted on a successful bump. +# Drop a note in that file before running this script to pre-fill the +# prompt. The file is deleted on a successful bump. # # Flags: # --from-deploy Treat the absence of .release-notes-pending.txt as the @@ -84,8 +84,8 @@ fi NEW_VAR="v_$(echo "$NEW_VERSION" | tr '.' '_')" # --- Prompt for release notes --- -# If Claude (or you) left suggested notes in .release-notes-pending.txt, show -# them as the default. Press Enter to accept, or type something different. +# If suggested notes are sitting in .release-notes-pending.txt, show them +# as the default. Press Enter to accept, or type something different. SUGGESTED_NOTES="" if [ -f "$PENDING_NOTES_FILE" ]; then # Read the file, trim leading/trailing whitespace, collapse interior newlines diff --git a/public/index.html b/public/index.html index 14bf974..bddcdbe 100644 --- a/public/index.html +++ b/public/index.html @@ -247,6 +247,33 @@ padding: 9px 18px; font-size: 13px; border-radius: 8px; } + /* Inline status + upgrade slot between the URL input and the right + icons. Wraps on narrow screens; hidden on mobile alongside + .top-actions. */ + .top-bar-status { + display: flex; align-items: center; gap: 6px; margin-right: 8px; + flex-wrap: wrap; + } + .top-bar-status .status-pill { + font-size: 11px; font-weight: 600; padding: 6px 10px; + border-radius: 8px; border: 1px solid transparent; + white-space: nowrap; + } + .top-bar-status .upgrade-btn { + background: #a855f7; color: #fff; border: none; + padding: 7px 12px; border-radius: 8px; text-decoration: none; + font-size: 11px; font-weight: 700; cursor: pointer; + white-space: nowrap; + } + .top-bar-status .upgrade-btn:hover { background: #c084fc; } + .top-bar-status .have-key-btn { + background: transparent; color: #94a3b8; + border: 1px solid #334155; padding: 6px 10px; border-radius: 8px; + cursor: pointer; font-size: 11px; font-weight: 600; + white-space: nowrap; + } + .top-bar-status .have-key-btn:hover { color: #cbd5e1; border-color: #475569; } + /* Top-right icon buttons */ .top-actions { display: flex; gap: 8px; justify-content: flex-end; } .icon-btn { @@ -1208,11 +1235,299 @@ audio.addEventListener("ended", stopPodcastSync); } + // ── Provider catalog ───────────────────────────────────────────────────── + // Static metadata about each backend Recap can talk to. Drives the + // picker UI: which providers appear in each dropdown, which models + // each lists by default, and which key/URL fields the Settings panel + // shows. Server-side adapter behavior is independently authoritative + // — this catalog is purely UX scaffolding. + const PROVIDERS = [ + { + id: "gemini", + name: "Google Gemini", + canTranscribe: true, + canAnalyze: true, + // Transcription uses Flash tier (best speed/cost on long audio). + // Older Flash generations are included so users on rate-limited + // or quota-restricted keys can fall back manually when 3-flash + // overloads. Order = newest first; the server's fallback chain + // walks the same list automatically if a 503 is returned. + transcriptionModels: [ + "gemini-3-flash-preview", + "gemini-2.5-flash", + "gemini-2.0-flash", + ], + analysisModels: [ + "gemini-3.1-pro-preview", + "gemini-3-pro-preview", + "gemini-3-flash-preview", + "gemini-2.5-flash", + ], + keyField: { key: "apiKey", label: "Gemini API Key", placeholder: "AIza...", masked: true, helpUrl: "https://aistudio.google.com/apikey" }, + }, + { + id: "anthropic", + name: "Anthropic (Claude)", + canTranscribe: false, + canAnalyze: true, + transcriptionModels: [], + analysisModels: [ + "claude-opus-4-7", + "claude-opus-4-6", + "claude-sonnet-4-6", + "claude-haiku-4-5", + ], + keyField: { key: "apiKey", label: "Anthropic API Key", placeholder: "sk-ant-...", masked: true, helpUrl: "https://console.anthropic.com" }, + }, + { + id: "openai", + name: "OpenAI", + canTranscribe: true, + canAnalyze: true, + transcriptionModels: ["whisper-1"], + analysisModels: ["gpt-4o", "gpt-4o-mini", "gpt-4-turbo", "o3-mini"], + keyField: { key: "apiKey", label: "OpenAI API Key", placeholder: "sk-...", masked: true, helpUrl: "https://platform.openai.com/api-keys" }, + }, + { + id: "whisper", + name: "OpenAI/Whisper-Compatible Endpoint", + canTranscribe: true, + canAnalyze: false, + // No fixed catalog — different Whisper backends use different + // model names (whisper-1 / whisper-large-v3 / base.en / etc.), + // so users define their model list in the credentials block. + transcriptionModels: [], + analysisModels: [], + urlField: { key: "baseURL", label: "Base URL", placeholder: "http://whisper.startos:8000" }, + keyField: { key: "apiKey", label: "API Key (optional)", placeholder: "(leave blank if self-hosted with no auth)", masked: true }, + modelsField: { + key: "models", + label: "Models", + placeholder: "whisper-1, whisper-large-v3", + hint: "Comma-separated. Common values: whisper-1 (OpenAI standard), whisper-large-v3 (whisper.cpp / faster-whisper / Groq).", + }, + }, + { + id: "openai-compatible", + name: "OpenAI-Compatible (DeepSeek, Together, Groq, …)", + canTranscribe: false, + canAnalyze: true, + transcriptionModels: [], + // No hardcoded catalog — the modelsField in credentials lets + // users define their model list, which then surfaces as the + // picker dropdown options. If the list is empty the picker + // falls back to a free-text input. + analysisModels: [], + analysisModelDefault: "deepseek-chat", + keyField: { key: "apiKey", label: "API Key", placeholder: "sk-...", masked: true }, + urlField: { key: "baseURL", label: "Base URL", placeholder: "https://api.deepseek.com/v1" }, + modelsField: { + key: "models", + label: "Models", + placeholder: "deepseek-chat, deepseek-reasoner", + hint: "Comma-separated. These appear in the model dropdown above.", + }, + }, + { + id: "ollama", + name: "Ollama (local)", + canTranscribe: false, + canAnalyze: true, + transcriptionModels: [], + analysisModels: [], + analysisModelDefault: "llama3.1", + urlField: { key: "baseURL", label: "Ollama Base URL", placeholder: "http://localhost:11434" }, + modelsField: { + key: "models", + label: "Models", + placeholder: "llama3.1, mistral, qwen2", + hint: "Comma-separated. Installed models are auto-detected from your Ollama server, so this is only needed if you want a subset.", + }, + }, + { + // Operator-side relay. Recap doesn't pick a model — relay + // chooses internally based on tier + cost. No credentials UI + // because the install-id + license proof are auto-attached + // server-side; users don't configure anything here. + id: "relay", + name: "Relay (comped credits)", + canTranscribe: true, + canAnalyze: true, + transcriptionModels: ["relay-default"], + analysisModels: ["relay-default"], + // No keyField / urlField / modelsField — the relay's baseURL + // is operator-configured server-side via the "Set Relay URL" + // StartOS action, and identity is auto-managed. + }, + ]; + const PROVIDER_BY_ID = Object.fromEntries(PROVIDERS.map((p) => [p.id, p])); + const TRANSCRIBE_PROVIDERS = PROVIDERS.filter((p) => p.canTranscribe); + const ANALYZE_PROVIDERS = PROVIDERS.filter((p) => p.canAnalyze); + + // ── Provider opts persistence ─────────────────────────────────────────── + // Shape: { gemini: {apiKey}, anthropic: {apiKey}, openai: {apiKey}, + // "openai-compatible": {apiKey, baseURL}, ollama: {baseURL} }. + // Migrates the legacy single-key localStorage entry the first time + // it's seen so users with a saved Gemini key don't lose it. + function loadProviderOpts() { + let opts = {}; + try { + const raw = localStorage.getItem("recap-provider-opts"); + if (raw) opts = JSON.parse(raw) || {}; + } catch {} + const legacyGemini = localStorage.getItem("recap-gemini-key") || ""; + if (legacyGemini && !opts.gemini?.apiKey) { + opts.gemini = { ...(opts.gemini || {}), apiKey: legacyGemini }; + } + // Ensure every provider has an entry so accesses like + // state.providerOpts.anthropic.apiKey don't error. + for (const p of PROVIDERS) { + if (!opts[p.id]) opts[p.id] = {}; + } + return opts; + } + + function saveProviderOpts() { + try { + localStorage.setItem("recap-provider-opts", JSON.stringify(state.providerOpts)); + } catch {} + // Mirror the gemini key into the legacy storage slot so other code + // paths (and a future rollback) keep working. + const gk = state.providerOpts.gemini?.apiKey || ""; + try { + if (gk) localStorage.setItem("recap-gemini-key", gk); + else localStorage.removeItem("recap-gemini-key"); + } catch {} + // Keep the live `state.apiKey` synonym (Gemini key) in sync with + // the canonical providerOpts entry. Existing checks against + // state.apiKey continue to work without modification. + state.apiKey = gk; + } + + function loadProviderSelection() { + let sel = {}; + try { + const raw = localStorage.getItem("recap-providers"); + if (raw) sel = JSON.parse(raw) || {}; + } catch {} + // For fresh installs with no saved preference, default to the + // relay provider — that's the "works out of the box with no + // setup" choice. Anyone who'd rather use their own API key or + // a self-hosted model just picks a different provider from the + // dropdown; the new pick gets saved to localStorage so it + // sticks across sessions. + const desiredTrans = sel.transcriptionProvider || "relay"; + const desiredAna = sel.analysisProvider || "relay"; + const tp = + TRANSCRIBE_PROVIDERS.find((p) => p.id === desiredTrans) || + TRANSCRIBE_PROVIDERS[0]; + const ap = + ANALYZE_PROVIDERS.find((p) => p.id === desiredAna) || + ANALYZE_PROVIDERS[0]; + return { + transcriptionProvider: tp.id, + transcriptionModel: sel.transcriptionModel || tp.transcriptionModels[0] || "", + analysisProvider: ap.id, + analysisModel: sel.analysisModel || ap.analysisModels[0] || ap.analysisModelDefault || "", + }; + } + + // Hard reset of the picker selection back to relay/relay. Useful + // when the user has stale selections from a previous session + // (e.g. they configured Whisper at home and now want comped + // credits) and wants a one-click way back to the defaults. + function resetProvidersToRelay() { + state.transcriptionProvider = "relay"; + state.transcriptionModel = "relay-default"; + state.analysisProvider = "relay"; + state.analysisModel = "relay-default"; + saveProviderSelection(); + render(); + } + + function saveProviderSelection() { + try { + localStorage.setItem("recap-providers", JSON.stringify({ + transcriptionProvider: state.transcriptionProvider, + transcriptionModel: state.transcriptionModel, + analysisProvider: state.analysisProvider, + analysisModel: state.analysisModel, + })); + } catch {} + } + + // ── Activity-log persistence ───────────────────────────────────────────── + // Logs are kept in localStorage so a browser refresh doesn't drop the + // user's record of what just happened (or what's still happening). + // Bounded at MAX_LOG_ENTRIES so a long-running browser session won't + // grow the localStorage payload without limit. Cleared explicitly via + // the "Clear" button in the activity-log drawer. + const LOG_STORAGE_KEY = "recap-activity-log-v1"; + const MAX_LOG_ENTRIES = 2000; + + function loadLogsFromStorage() { + try { + const raw = localStorage.getItem(LOG_STORAGE_KEY); + if (!raw) return []; + const arr = JSON.parse(raw); + return Array.isArray(arr) ? arr : []; + } catch { + return []; + } + } + + function saveLogsToStorage() { + try { + if (state.logs.length > MAX_LOG_ENTRIES) { + state.logs.splice(0, state.logs.length - MAX_LOG_ENTRIES); + } + localStorage.setItem(LOG_STORAGE_KEY, JSON.stringify(state.logs)); + } catch {} + } + + // Single push site used by every log mutation in the app — keeps the + // localStorage mirror in sync without sprinkling save calls at six + // different push sites in handleSSE / processUrl / etc. + function pushLog(entry) { + state.logs.push(entry); + saveLogsToStorage(); + } + + function clearLogHistory() { + // Cheap confirm dialog — the action is destructive and there's no + // undo. Users typically clear once per long session, not constantly, + // so the extra click is unintrusive. + if (state.logs.length === 0) return; + if (!confirm(`Clear ${state.logs.length} activity-log entries? This can't be undone.`)) return; + state.logs = []; + state.collapsedLogGroups = new Set(); + saveLogsToStorage(); + render(); + } + // ── State ──────────────────────────────────────────────────────────────── const state = { url: "", apiKey: localStorage.getItem("recap-gemini-key") || "", hasServerKey: false, // will be set by health check + // Persistent per-install UUID minted by the server on first boot. + // Populated from /api/health. Shown in Settings → Install ID for + // verification; will be sent to the upcoming relay backend as + // the owner of comped/paid relay credits. + installId: null, + // Last-known relay credit balance + tier. Populated from + // /api/relay/status on boot and after every relay call. + // { creditsRemaining, tier, lastUpdated, lastError, configured } + // configured=false means the operator hasn't wired up a relay + // base URL yet — the picker still shows "Relay (comped credits)" + // but the option is disabled. + relayStatus: { creditsRemaining: null, tier: null, lastUpdated: null, lastError: null, configured: false }, + // Live tier-quota policy from the relay, fetched on boot. + // Drives dynamic copy (e.g. activation screen's credit count + // updates without a Recap release when the operator tunes the + // Core lifetime cap via the relay's Adjust Tier Quotas action). + // Shape: { configured: bool, tiers: {...} | null, core_total_credits, core_gemini_credits, error? } + relayPolicy: { configured: false, tiers: null, core_total_credits: null, core_gemini_credits: null }, lanMode: null, // null = unknown, true = home, false = traveling serverStatus: "connecting", // "connected" | "sleeping" | "disconnected" | "connecting" model: "gemini-3.1-pro-preview", @@ -1227,8 +1542,14 @@ chunks: [], expandAll: false, expandedChunks: new Set(), - logs: [], + logs: loadLogsFromStorage(), logOpen: false, + // Set of separator-entry indices the user has collapsed. Each + // separator (── title ──) anchors one group of log entries that + // follow it until the next separator. Adding state here (rather + // than DOM-only) keeps collapse state stable across re-renders + + // refreshes. + collapsedLogGroups: new Set(), // history historyOpen: true, historySessions: {}, // id → session summary @@ -1285,6 +1606,49 @@ // hard-gates the UI. Persisted so returning unlicensed users land // straight in the app. activationSkipped: localStorage.getItem("recap-activation-skipped") === "1", + // Admin login gate (set via the StartOS "Set Admin Password" action). + // When `enabled`, no API call works without a valid session cookie, + // and the login screen takes priority over the activation screen. + admin: { + loaded: false, + enabled: false, + authed: false, + username: null, + }, + adminLoginUsername: "", + adminLoginPassword: "", + adminLoginError: null, + adminLoggingIn: false, + // Server-tracked in-flight job (free-tier only today). Populated + // on boot from /api/process/current and refreshed via polling, so + // the user still sees what's running after a browser refresh. + currentJob: null, + cancellingJob: false, + // Test-connection state per provider id. providerTesting flips + // true while a request is in flight; providerTestResults stores + // the most recent { ok, text|error, latencyMs }. + providerTesting: {}, + providerTestResults: {}, + // Whether each provider's per-field server config is populated. + // Shape: { [providerId]: { [fieldName]: bool } }. Populated from + // /api/providers/credentials-status on boot + after Save/Delete. + // Drives "✓ Server-configured" hints and whether the Delete + // button is shown when localStorage is empty. + providerServerStatus: {}, + // YouTube captions fast-path toggle. When on (default), Recap + // uses YouTube's own captions when available and skips audio + // download + AI transcription entirely. Off forces a full + // transcription pass (better for speaker labels — captions + // don't have them). + useYouTubeCaptions: localStorage.getItem("recap-use-yt-captions") !== "0", + // Per-provider client-side opts (apiKey + baseURL where applicable). + // Sent verbatim in the request body as `providerOpts`. Server + // overlays them on top of values set via the StartOS actions. + providerOpts: loadProviderOpts(), + // Per-pipeline provider + model selection. Persisted so a user's + // mix-and-match (e.g. Gemini transcribe → Claude analyze) sticks + // across sessions. + ...loadProviderSelection(), }; const MODELS = ["gemini-3.1-pro-preview", "gemini-3-pro-preview", "gemini-3-flash-preview"]; @@ -1324,15 +1688,25 @@ // ── Process ────────────────────────────────────────────────────────────── async function handleSubmit() { - // Both tiers need a Gemini key — either entered in the web UI - // (state.apiKey, localStorage) or set on the server via the StartOS - // configuration action (state.hasServerKey). The free-vs-paid line - // is concurrency + features, not key sourcing. - const hasKey = state.apiKey.trim() || state.hasServerKey; - if (!state.url.trim() || !hasKey) { - if (!hasKey && state.url.trim()) { - showToast("Add your Gemini API key in Settings (or via the StartOS configuration action) to start summarizing.", "🔑"); - } + // The selected transcription + analysis providers must each have + // a usable config. Relay counts when the relay URL is reachable + // (the operator-controlled hardcoded URL); other providers count + // when a key/URL is set in localStorage or in the StartOS config. + // See providerCanRun() for the per-provider rules. + if (!state.url.trim()) return; + if (!providersCanRun()) { + const tp = PROVIDER_BY_ID[state.transcriptionProvider]?.name || state.transcriptionProvider; + const ap = PROVIDER_BY_ID[state.analysisProvider]?.name || state.analysisProvider; + const missing = []; + if (!providerCanRun(state.transcriptionProvider)) missing.push(tp); + if (!providerCanRun(state.analysisProvider) && state.analysisProvider !== state.transcriptionProvider) missing.push(ap); + const what = missing.join(" + "); + showToast( + `${what} ${missing.length === 1 ? "isn't" : "aren't"} configured. ` + + `Add a key/endpoint in Settings (or use the operator's StartOS actions), ` + + `or pick Relay from the picker for comped credits.`, + "🔑" + ); return; } @@ -1356,13 +1730,7 @@ } function handleLibraryClick() { - // Library / history is a paid feature (history entitlement). For - // free-tier users, surface the upgrade prompt instead of opening an - // empty sidebar. - if (!hasEntitlement("history")) { - showToast("Library requires a paid license — every summary you process gets saved.", "📚"); - return; - } + // Library is free for everyone — every summary gets saved. toggleHistory(); } @@ -1430,22 +1798,42 @@ state.chunks = []; state.currentStep = 1; state.status = "Starting..."; + // Pre-populate the in-flight banner client-side so the user + // immediately sees what's running + the Cancel button. The server + // ground-truth gets pulled in by the poll loop shortly after. + state.currentJob = { + url, + title: opts.title || "", + startedAt: Date.now(), + elapsedMs: 0, + aborted: false, + }; + startCurrentJobPoll(); state.settingsOpen = false; state.expandedChunks = new Set(); - // Accumulate logs across queue items — add separator for 2nd+ items + // Push a separator for every video — the activity log persists + // across browser sessions, so users may already have entries from + // prior runs in state.logs even before any push this turn. const title = opts.title || url; - if (state.logs.length > 0) { - state.logs.push({ elapsed: "—", message: `── ${title} ──`, detail: null, separator: true }); - } else { - state.logs.push({ elapsed: "—", message: `── ${title} ──`, detail: null, separator: true }); - } + pushLog({ elapsed: "—", message: `── ${title} ──`, detail: null, separator: true }); render(); try { const body = { url, + // Legacy single-key field — kept so older server builds keep + // working. New server resolves it as the gemini fallback. apiKey: state.apiKey.trim() || "USE_SERVER_KEY", model: state.model, + // Picker-UI fields: which provider + model handles each + // pipeline step, and per-provider client-side opts (apiKey, + // baseURL) the server overlays on top of its config. + transcriptionProvider: state.transcriptionProvider, + transcriptionModel: state.transcriptionModel, + analysisProvider: state.analysisProvider, + analysisModel: state.analysisModel, + providerOpts: state.providerOpts, + useYouTubeCaptions: state.useYouTubeCaptions, }; if (opts.type) body.type = opts.type; if (opts.title) body.title = opts.title; @@ -1460,7 +1848,17 @@ if (!res.ok && res.headers.get("content-type")?.includes("application/json")) { const err = await res.json(); - throw new Error(err.error || `Server error: ${res.status}`); + // If a free-tier job is already in flight, sync the local + // status banner to the server-reported job so the user + // immediately sees what's running + can cancel it. + if (err.error === "processing_in_progress" && err.currentJob) { + state.currentJob = err.currentJob; + render(); + } + // Prefer the server's human-readable `message` over the + // short error code so users see "A summary is already being + // processed (…)" rather than "processing_in_progress". + throw new Error(err.message || err.error || `Server error: ${res.status}`); } const reader = res.body.getReader(); @@ -1497,7 +1895,10 @@ } finally { state.loading = false; state.currentStep = 0; - render(); + // The server-tracked current-job slot is released by the server + // in its own finally; refresh our snapshot so the banner + // disappears immediately rather than waiting for the poll. + loadCurrentJob().finally(() => render()); // Process next item in queue processQueue(); } @@ -1585,7 +1986,7 @@ if (statusText) statusText.textContent = data.message; return; } else if (event === "log") { - state.logs.push({ elapsed: data.elapsed, message: data.message, detail: data.detail }); + pushLog({ elapsed: data.elapsed, message: data.message, detail: data.detail }); renderLog(); } else if (event === "result") { const videoChanged = state.videoId !== data.videoId; @@ -1599,15 +2000,137 @@ render(); // Refresh history list and re-render when loaded loadHistory().then(() => render()); + // Refresh the relay balance — if this job went through the + // relay, the server-side cache was just updated by the + // transcribe/analyze responses, and we want the picker banner + // to reflect the new count. + loadRelayStatus().then(() => render()).catch(() => {}); } else if (event === "error") { state.error = data.message; - state.logs.push({ elapsed: "---", message: "ERROR: " + data.message, error: true }); + pushLog({ elapsed: "---", message: "ERROR: " + data.message, error: true }); + render(); + } else if (event === "cancelled") { + // Server confirmed the cancellation went through. The fetch + // reader's finally clause clears state.loading and the banner + // poll catches the slot release a moment later; just log the + // acknowledgement here so the user sees "Cancelled by user" + // in the activity log. + pushLog({ elapsed: "---", message: data.message || "Cancelled by user" }); render(); } } // ── Render ─────────────────────────────────────────────────────────────── + function renderAdminLoginScreen() { + // Reuses .activation-screen / .activation-card styling so the gate + // looks consistent with the activation screen that follows it. + return ` +
+
+

Recap

+

Sign in to continue.

+ + + + + ${state.adminLoginError ? `
${escHtml(state.adminLoginError)}
` : ""} +
+ +
+
+ The admin password is set on the server via the StartOS + Set Admin Password action. +
+
+
+ `; + } + + function canSubmitAdminLogin() { + return !!(state.adminLoginUsername.trim() && state.adminLoginPassword && !state.adminLoggingIn); + } + + async function loadAdminStatus() { + try { + const res = await fetch(`${API_BASE}/api/admin/status`, { credentials: "same-origin" }); + const data = await res.json(); + state.admin = { + loaded: true, + enabled: !!data.enabled, + authed: !!data.authed, + username: data.username || null, + }; + if (state.admin.enabled && state.adminLoginUsername === "" && data.username) { + state.adminLoginUsername = data.username; + } + } catch { + state.admin = { + loaded: true, + enabled: false, + authed: true, + username: null, + }; + } + } + + async function submitAdminLogin() { + if (!canSubmitAdminLogin()) return; + state.adminLoggingIn = true; + state.adminLoginError = null; + render(); + try { + const res = await fetch(`${API_BASE}/api/admin/login`, { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + username: state.adminLoginUsername.trim(), + password: state.adminLoginPassword, + }), + }); + const data = await res.json().catch(() => ({})); + if (res.ok && data.ok) { + state.adminLoginPassword = ""; + state.admin.authed = true; + state.admin.enabled = !!data.enabled; + state.admin.username = data.username || state.admin.username; + // Now that we're authed, kick off the loads that were skipped + // while gated. + await initAfterAdminAuth(); + } else { + state.adminLoginError = data.message || "Sign-in failed."; + } + } catch { + state.adminLoginError = "Could not reach the server."; + } finally { + state.adminLoggingIn = false; + render(); + } + } + + async function submitAdminLogout() { + try { + await fetch(`${API_BASE}/api/admin/logout`, { method: "POST", credentials: "same-origin" }); + } catch {} + state.admin.authed = false; + state.adminLoginPassword = ""; + state.adminLoginError = null; + render(); + } + function renderActivationScreen() { const lic = state.license; const reasonHints = { @@ -1628,7 +2151,19 @@

${loading ? "Checking license…" - : "Activate a Recap license to unlock the full app — saved library, channel & podcast subscriptions, and auto-queue. Or skip to use free mode (one video at a time, no library)." + : (() => { + // Pull the credit count live from the relay so this + // line stays accurate when the operator tunes the + // Core lifetime cap without redeploying Recap. + // Falls back to "free" wording if the relay is + // unreachable on first paint. + const credits = state.relayPolicy?.core_total_credits; + const creditsText = + typeof credits === "number" && credits > 0 + ? `${credits} relay credit${credits === 1 ? "" : "s"}` + : "free relay credits"; + return `Activate a Recap license to unlock channel & podcast subscriptions and auto-queue. Or skip to use free mode — ${creditsText} to process recaps of videos and podcasts on us, plus unlimited recaps when you bring your own AI provider keys or self-hosted model URL.`; + })() }

${loading ? "" : ` @@ -1675,24 +2210,437 @@ return !isProTier(); } + // ── In-flight job banner ───────────────────────────────────────────────── + // Shown when the server reports an active free-tier job (server-tracked + // so a browser refresh doesn't hide it). Includes a Cancel button that + // hits /api/process/cancel. + function renderCurrentJobBanner() { + if (!state.currentJob) return ""; + const job = state.currentJob; + const what = job.title || job.url || "a video"; + const elapsedStr = formatInflightElapsed(job); + const aborted = job.aborted || state.cancellingJob; + return ` +
+ + ${aborted ? "Cancelling…" : "Processing"} + · ${escHtml(what)} + · ${elapsedStr} elapsed + + +
+ `; + } + + // Server-discovered per-provider connection defaults (today: the + // StartOS-hosted Ollama URL + list of installed Ollama models, when + // Recap and Ollama are co-installed). Used as placeholder/default + // in the picker UI when the user hasn't typed a value — fields + // remain editable. + let __providerDiscovery = {}; + async function loadProviderDiscovery() { + try { + const res = await fetch(`${API_BASE}/api/providers/discover`, { credentials: "same-origin" }); + if (!res.ok) return; + __providerDiscovery = (await res.json()) || {}; + } catch {} + } + + // Pull the relay's current tier-quota policy so activation copy + // (and any other dynamic credit-count text) reflects whatever the + // operator has the relay configured for, without needing a Recap + // update each time the policy changes. Best-effort: if the relay + // is unreachable, state.relayPolicy keeps its defaults and the + // copy falls back to a hardcoded number. + async function loadRelayPolicy() { + try { + const res = await fetch(`${API_BASE}/api/relay/policy`, { + credentials: "same-origin", + }); + if (!res.ok) return; + const data = await res.json(); + state.relayPolicy = { + configured: !!data.configured, + tiers: data.tiers || null, + core_total_credits: data.core_total_credits ?? null, + core_gemini_credits: data.core_gemini_credits ?? null, + error: data.error || null, + }; + } catch {} + } + + // Per-provider, per-field boolean indicating whether the server's + // startos-config.json has a value for that slot. Booleans only — + // never receives the actual key, so screenshots stay safe. Loaded + // on boot and refreshed after every Save / Delete so the picker + // UI's "✓ Server-configured" hints and Delete-button visibility + // stay in sync with what the StartOS actions have written. + async function loadProviderServerStatus() { + try { + const res = await fetch( + `${API_BASE}/api/providers/credentials-status`, + { credentials: "same-origin" } + ); + if (!res.ok) return; + const data = await res.json(); + state.providerServerStatus = data.status || {}; + } catch {} + } + + // Pull the last-known relay credit balance + tier from the server's + // in-process cache. Cheap (no relay round-trip — server returns + // whatever it cached from the most recent /relay/* call). UI polls + // this on boot and after each successful summarize so the balance + // banner stays in sync without an extra relay hit. + async function loadRelayStatus() { + try { + const res = await fetch(`${API_BASE}/api/relay/status`, { credentials: "same-origin" }); + if (!res.ok) return; + const data = await res.json(); + state.relayStatus = { + creditsRemaining: data.creditsRemaining ?? null, + tier: data.tier || null, + lastUpdated: data.lastUpdated || null, + lastError: data.lastError || null, + configured: !!data.configured, + }; + } catch {} + } + function discoveredUrlFor(providerId) { + const entry = __providerDiscovery[providerId]; + return (entry && entry.baseURL) || ""; + } + function discoveredModelsFor(providerId) { + const entry = __providerDiscovery[providerId]; + return (entry && Array.isArray(entry.models)) ? entry.models : []; + } + + // Parse the user-supplied "Models" credentials field (a free-text + // string the user types in Settings) into a deduplicated array of + // trimmed model names. Accepts comma- and newline-separated input. + function parseUserModels(raw) { + if (!raw || typeof raw !== "string") return []; + const seen = new Set(); + return raw + .split(/[,\n]/) + .map((s) => s.trim()) + .filter((s) => { + if (!s) return false; + if (seen.has(s)) return false; + seen.add(s); + return true; + }); + } + + // Resolved list of analysis models for a provider, in priority + // order: user-defined (from credentials) → server-discovered → + // catalog default. Used by the picker dropdown. + function resolvedAnalysisModelsFor(provider) { + if (provider.analysisModels && provider.analysisModels.length > 0) { + return provider.analysisModels; + } + const userList = parseUserModels(state.providerOpts[provider.id]?.models); + if (userList.length > 0) return userList; + const discovered = discoveredModelsFor(provider.id); + if (discovered.length > 0) return discovered; + return []; + } + + // Same idea but for transcription. Whisper-compatible / future + // transcription providers with dynamic catalogs (no fixed model + // list) use the user's Models field as their picker source. + function resolvedTranscriptionModelsFor(provider) { + if (provider.transcriptionModels && provider.transcriptionModels.length > 0) { + return provider.transcriptionModels; + } + const userList = parseUserModels(state.providerOpts[provider.id]?.models); + if (userList.length > 0) return userList; + const discovered = discoveredModelsFor(provider.id); + if (discovered.length > 0) return discovered; + return []; + } + + function formatInflightElapsed(job) { + if (!job) return ""; + const startedAt = job.startedAt || (Date.now() - (job.elapsedMs || 0)); + const elapsedSec = Math.max(0, Math.round((Date.now() - startedAt) / 1000)); + return elapsedSec >= 60 + ? `${Math.floor(elapsedSec / 60)}m ${elapsedSec % 60}s` + : `${elapsedSec}s`; + } + + // Surgical update of just the elapsed-time text inside the + // in-flight banner. Lets the poll loop keep the counter ticking + // without re-rendering the whole app (which would wipe the + // activity-log sidebar, YouTube embed iframe, etc.). + function updateInflightElapsedDOM() { + const el = document.querySelector(".inflight-elapsed"); + if (el && state.currentJob) { + el.textContent = formatInflightElapsed(state.currentJob); + } + } + + async function loadCurrentJob({ withLogs = false } = {}) { + try { + const url = withLogs + ? `${API_BASE}/api/process/current?logs=1` + : `${API_BASE}/api/process/current`; + const res = await fetch(url, { credentials: "same-origin" }); + if (!res.ok) return; + const data = await res.json(); + state.currentJob = data.job || null; + // Rehydrate activity log from server buffer (boot path only). + // Logs in localStorage already cover entries the client saw + // before the refresh — merge in only the server-side entries + // that aren't already present so the user picks up everything + // emitted after their SSE stream dropped. Dedup key is + // elapsed+message which is unique within a single job. + if (withLogs && state.currentJob && Array.isArray(data.job.logs)) { + const title = state.currentJob.title || state.currentJob.url || "Processing…"; + const sepMsg = `── ${title} ──`; + const hasSeparator = state.logs.some( + (l) => l.separator && l.message === sepMsg + ); + if (!hasSeparator) { + state.logs.push({ elapsed: "—", message: sepMsg, detail: null, separator: true }); + } + const seen = new Set(state.logs.map((l) => `${l.elapsed}|${l.message}`)); + let added = 0; + for (const e of data.job.logs) { + const key = `${e.elapsed}|${e.message}`; + if (!seen.has(key)) { + state.logs.push(e); + seen.add(key); + added++; + } + } + if (!hasSeparator || added > 0) saveLogsToStorage(); + } + } catch {} + } + + async function cancelCurrentJob() { + if (!state.currentJob || state.cancellingJob) return; + state.cancellingJob = true; + render(); + try { + await fetch(`${API_BASE}/api/process/cancel`, { method: "POST", credentials: "same-origin" }); + } catch {} + // The pipeline polls for the abort flag and bails at the next + // checkpoint — give it ~2s, then refresh the banner state. + setTimeout(() => { + loadCurrentJob().finally(() => { + state.cancellingJob = false; + render(); + }); + }, 2000); + } + + // Poll loop: while a job is in flight, ping the server every 5s to + // keep the banner accurate (elapsed time + did-it-finish detection). + // Stops itself when the job disappears. + let __currentJobPollTimer = null; + let __inflightTickTimer = null; + function startCurrentJobPoll() { + if (!__currentJobPollTimer) { + __currentJobPollTimer = setInterval(async () => { + const prevHas = !!state.currentJob; + const prevAborted = !!(state.currentJob && state.currentJob.aborted); + await loadCurrentJob(); + const has = !!state.currentJob; + const aborted = !!(state.currentJob && state.currentJob.aborted); + // Only do a full render() on presence/state transitions + // (job appears, disappears, or its aborted flag flips). + // The elapsed counter ticks via updateInflightElapsedDOM() + // — a surgical text-node update that leaves the rest of + // the DOM (activity log, YouTube embed, results panel) + // intact between polls. + const transitioned = prevHas !== has || prevAborted !== aborted; + if (transitioned) { + render(); + } else if (has) { + updateInflightElapsedDOM(); + } + if (!has) { + clearInterval(__currentJobPollTimer); + __currentJobPollTimer = null; + } + }, 5000); + } + // Local tick: refresh the elapsed text once per second so the + // counter never freezes between server polls. Pure DOM update, + // never triggers render(). + if (!__inflightTickTimer) { + __inflightTickTimer = setInterval(() => { + if (!state.currentJob) { + clearInterval(__inflightTickTimer); + __inflightTickTimer = null; + return; + } + updateInflightElapsedDOM(); + }, 1000); + } + } + + // Returns true when the given provider id has enough configuration + // to actually be usable. Used by submit-disabled logic to gate the + // Summarize button so users don't click it with nothing wired up. + // - Relay: usable when the relay URL is reachable + // (state.relayStatus.configured, which reflects the + // operator-controlled hardcoded URL at build time). Credits + // availability is handled server-side — we don't gate the + // click on it, just surface errors after the call lands. + // - Other providers: usable when at least one of the required + // fields (apiKey / baseURL) has a value in either localStorage + // or the StartOS server config. Auto-detected Ollama (via the + // StartOS dependency) counts as configured. + // - Providers with no required fields (currently just relay) + // fall through to the relay branch above. + function providerCanRun(providerId) { + const provider = PROVIDER_BY_ID[providerId]; + if (!provider) return false; + if (providerId === "relay") { + // The relay URL is hardcoded into this Recap build, so from + // the client's POV it's always "runnable" the moment the + // user picks it. We DON'T gate on state.relayStatus.configured + // here — that field depends on an async /api/relay/status + // round-trip, which races against the user typing a URL and + // clicking Summarize. If the relay turns out to be unreachable + // at submit time (StartTunnel down, etc.) the SSE response + // surfaces the error inline — much clearer than a silently + // disabled button. + return true; + } + const opts = state.providerOpts[providerId] || {}; + const serverFields = state.providerServerStatus?.[providerId] || {}; + if (provider.keyField) { + const local = (opts[provider.keyField.key] || "").trim(); + if (local) return true; + if (serverFields[provider.keyField.key]) return true; + } + if (provider.urlField) { + const local = (opts[provider.urlField.key] || "").trim(); + if (local) return true; + if (serverFields[provider.urlField.key]) return true; + if (providerId === "ollama" && discoveredUrlFor("ollama")) return true; + } + if (!provider.keyField && !provider.urlField) return true; + return false; + } + + // Both pipelines (transcription + analysis) must have usable + // configuration for the request to succeed. Per-pipeline check + // because a user can mix providers (e.g. Whisper transcribe + + // Relay analyze). + function providersCanRun() { + return ( + providerCanRun(state.transcriptionProvider) && + providerCanRun(state.analysisProvider) + ); + } + + // True when the user has at least one AI provider credential set + // (localStorage OR server-side). Used by the toolbar status pill + // to surface "BYO AI keys configured" when relay credits are + // exhausted or unavailable but the user can still summarize via + // their own keys. Excludes the relay provider itself (its identity + // is server-managed, not a BYO credential). + function hasAnyBYOConfigured() { + for (const id of Object.keys(state.providerOpts || {})) { + if (id === "relay") continue; + const opts = state.providerOpts[id] || {}; + for (const k of Object.keys(opts)) { + if (typeof opts[k] === "string" && opts[k].trim()) return true; + } + } + for (const id of Object.keys(state.providerServerStatus || {})) { + if (id === "relay") continue; + const fields = state.providerServerStatus[id] || {}; + for (const k of Object.keys(fields)) { + if (fields[k]) return true; + } + } + return false; + } + + // Compact toolbar status slot — sits between the URL input and + // the right-side icon buttons in the top bar. Shows whichever is + // most useful right now: relay credit balance, "BYO configured", + // or nothing if neither applies. Pairs Upgrade + "I have a key" + // buttons inline so the user has a one-click path to either tier. + function renderToolbarStatus() { + const free = !isLicensed(); + const rs = state.relayStatus || {}; + const credits = rs.creditsRemaining; + const byo = hasAnyBYOConfigured(); + const showUpgrade = !isProTier(); + const showIHaveKey = free; + const buyUrl = upgradeToProUrl(); + + // Pick the pill. Relay-credit count beats BYO badge when relay + // is configured — that's the actionable number the user cares + // about. Falls back to BYO badge when relay isn't configured + // (or returned an error) but the user has their own keys. + let pillHtml = ""; + if (rs.configured && credits != null) { + if (credits < 0) { + pillHtml = `Unlimited relay`; + } else { + const color = credits === 0 ? "#fca5a5" : (credits <= 3 ? "#fbbf24" : "#a5b4fc"); + const bg = credits === 0 ? "rgba(252,165,165,0.10)" : (credits <= 3 ? "rgba(251,191,36,0.10)" : "rgba(99,102,241,0.10)"); + const border = credits === 0 ? "rgba(252,165,165,0.30)" : (credits <= 3 ? "rgba(251,191,36,0.30)" : "rgba(99,102,241,0.30)"); + pillHtml = `${credits} relay credit${credits === 1 ? "" : "s"}`; + } + } else if (byo) { + pillHtml = `BYO AI keys configured`; + } else if (rs.configured && rs.lastError) { + // Relay configured but unreachable AND no BYO fallback — make + // sure the user sees something so they know summarize will fail. + pillHtml = `Relay unreachable`; + } + + if (!pillHtml && !showUpgrade && !showIHaveKey) return ""; + return ` +
+ ${pillHtml} + ${showUpgrade ? `Upgrade` : ""} + ${showIHaveKey ? `` : ""} +
+ `; + } + function renderUpgradeBanner() { const buyUrl = upgradeToProUrl(); const free = !isLicensed(); - const partialCore = isLicensed() && (!hasEntitlement("history") || !hasEntitlement("library")); - const fullCore = isLicensed() && hasEntitlement("history") && hasEntitlement("library") && !isProTier(); - + // After the library-for-everyone change, the only meaningful tier + // distinction surfaced in the banner is "Free → upgrade for + // auto-queue + relay credits". Partial-license states no longer + // exist (library + history are universally available). let label, descr; if (free) { label = "Free mode"; - descr = "one video at a time · no library, no subscriptions"; - } else if (partialCore) { - label = "Limited license"; - descr = "your license is missing some Core features — contact the seller or upgrade"; - } else if (fullCore) { - label = "Core tier"; - descr = "upgrade to Pro for channel & podcast subscriptions, auto-queue, and clips"; + descr = "one video at a time · bring your own API key · upgrade for auto-queue, clips, and relay credits"; + } else if (!isProTier()) { + label = "Paid license"; + descr = "your license is missing some paid features — contact the seller"; } else { - return ""; // shouldn't reach + return ""; // Pro tier (or above) — no banner } return ` @@ -1735,6 +2683,23 @@ } function render() { + // Admin login gate (set via the StartOS "Set Admin Password" + // action) takes priority over everything: nobody — licensed or + // not — sees the activation screen or the app until they've + // signed in. + if (state.admin.loaded && state.admin.enabled && !state.admin.authed) { + const app = document.getElementById("app"); + app.className = "container"; + app.innerHTML = renderAdminLoginScreen(); + const pwd = document.getElementById("admin-login-password"); + const usr = app.querySelector("input[autocomplete='username']"); + if (state.adminLoginUsername && pwd) { + pwd.focus(); + } else if (usr) { + usr.focus(); + } + return; + } // Initial paint while license-status is still in-flight: show the // activation card in its loading skeleton state rather than a flash of // the underlying app. @@ -1765,19 +2730,24 @@ const __prevHistoryListEl = document.querySelector(".history-list"); const __prevHistoryScroll = __prevHistoryListEl ? __prevHistoryListEl.scrollTop : 0; const free = !isLicensed(); - // Same key requirement for both tiers today — either web-UI key or - // a server-side key set via the StartOS config action. + // Submit is disabled when there's no URL, or when the selected + // providers don't have any usable configuration. Relay counts as + // configured whenever the relay URL is reachable (operator-set + // at build time); per-provider keys are accepted from either + // localStorage (browser-side picker entry) or the server-side + // StartOS config. This logic replaces the legacy "must have a + // Gemini key" check which prevented Summarize when Relay was + // selected with no Gemini key entered. const submitDisabled = !state.url.trim() - || (!isSubscribeUrl(state.url) && !state.apiKey.trim() && !state.hasServerKey); + || (!isSubscribeUrl(state.url) && !providersCanRun()); let __renderedHtml; try { __renderedHtml = `
-
+ ${renderToolbarStatus()}
${state.clipCollection.length > 0 && hasEntitlement("clips") ? ` `; + return ` + + ${renderRelayStatusPill()} +
+ ${resetLink} +
+ Transcription +
+ ${renderProviderSelect("transcription", TRANSCRIBE_PROVIDERS, state.transcriptionProvider)} + ${renderModelInput("transcription", tp, state.transcriptionModel)} +
+ ${escHtml(tp.canTranscribe ? "Audio → text" : "(provider does not support audio — pick gemini or openai)")} +
+
+ Analysis +
+ ${renderProviderSelect("analysis", ANALYZE_PROVIDERS, state.analysisProvider)} + ${renderModelInput("analysis", ap, state.analysisModel)} +
+ Topic structuring (text → JSON) · falls back through remaining models if your chosen one fails +
+ +
+ + +
+ ${PROVIDERS.map(renderProviderCredentials).join("")} +

+ Keys typed here are saved locally in this browser. Keys set via the Set ${escHtml('')} API Key StartOS actions are saved on the server (shared across devices); look for the green “✓ Server-configured” hint under a field. Delete clears both at once. +

+
+ `; + } + + function renderProviderSelect(pipeline, providers, selectedId) { + // pipeline = "transcription" | "analysis" — drives the change handler. + const options = providers.map((p) => + `` + ).join(""); + return ``; + } + + function renderModelInput(pipeline, provider, currentModel) { + const onchange = pipeline === "transcription" + ? "setTranscriptionModel(this.value)" + : "setAnalysisModel(this.value)"; + const list = pipeline === "transcription" + ? resolvedTranscriptionModelsFor(provider) + : resolvedAnalysisModelsFor(provider); + if (!list || list.length === 0) { + // No list anywhere → free-text input. Happens for openai- + // compatible / ollama before the user defines their models in + // credentials and before we've fetched any from the server. + const placeholder = pipeline === "transcription" + ? (provider.canTranscribe ? "model name" : "—") + : (provider.analysisModelDefault || "model name"); + return ``; + } + // If the saved model isn't in the resolved list, surface it as + // an extra entry so the dropdown can show what's currently + // selected (e.g. a model the user typed before defining their + // list, or a stale value from an older session). + const fullList = currentModel && !list.includes(currentModel) + ? [currentModel, ...list] + : list; + const options = fullList.map((m) => + `` + ).join(""); + return ``; + } + + // Default-expanded set: only the providers currently SELECTED for + // either pipeline (transcription or analysis). Everything else + // collapses by default — even providers that have saved + // credentials, because seeing all of them sprawled open made the + // settings panel hard to scan and obscured the active pair. + // Users can click the chevron to expand any provider on demand. + function isProviderExpandedByDefault(providerId) { + if (providerId === state.transcriptionProvider) return true; + if (providerId === state.analysisProvider) return true; + return false; + } + + function isProviderExpanded(providerId) { + if (state.providerExpanded && providerId in state.providerExpanded) { + return !!state.providerExpanded[providerId]; + } + return isProviderExpandedByDefault(providerId); + } + + // Surgical toggle — no full render. Flips state.providerExpanded + // and mutates just the section's content + chevron icon DOM in + // place, so nothing else on the settings panel redraws. + function toggleProviderSection(providerId) { + const expanded = !isProviderExpanded(providerId); + if (!state.providerExpanded) state.providerExpanded = {}; + state.providerExpanded[providerId] = expanded; + const section = document.querySelector(`[data-provider-section="${providerId}"]`); + if (!section) return; + const body = section.querySelector('[data-provider-body]'); + const chevron = section.querySelector('[data-provider-chevron]'); + if (body) body.style.display = expanded ? "" : "none"; + if (chevron) chevron.textContent = expanded ? "▾" : "▸"; + } + + function renderProviderCredentials(provider) { + const opts = state.providerOpts[provider.id] || {}; + const inputType = state.showKey ? "text" : "password"; + const expanded = isProviderExpanded(provider.id); + let inner = `
+ + ${expanded ? "▾" : "▸"} + ${escHtml(provider.name)} + +
+ ${renderProviderTestControl(provider)} + ${renderProviderSaveControl(provider)} +
+
+
`; + if (provider.urlField) { + // If the server auto-discovered a URL for this provider (e.g. + // Ollama installed alongside us on StartOS), use it as the + // placeholder + add a hint underneath. Empty saved value will + // still let the server fall back to the discovered URL. + const discovered = discoveredUrlFor(provider.id); + const ph = discovered || provider.urlField.placeholder; + const localUrl = opts[provider.urlField.key] || ""; + const urlOnServer = providerFieldOnServer(provider.id, provider.urlField.key); + inner += ` + `; + if (discovered) { + inner += `
Auto-detected on this StartOS server — leave blank to use it
`; + } else if (!localUrl && urlOnServer) { + inner += `
✓ Server-configured via StartOS action — leave blank to use it
`; + } + } + if (provider.keyField) { + const t = provider.keyField.masked ? inputType : "text"; + const localValue = opts[provider.keyField.key] || ""; + const onServer = providerFieldOnServer(provider.id, provider.keyField.key); + inner += ` + `; + if (!localValue && onServer) { + inner += `
✓ Server-configured via StartOS action — leave blank to use it
`; + } + } + if (provider.modelsField) { + const discoveredModels = discoveredModelsFor(provider.id); + const ph = provider.modelsField.placeholder; + const hintParts = [provider.modelsField.hint]; + if (discoveredModels.length > 0) { + hintParts.push(`Detected on your server: ${escHtml(discoveredModels.join(", "))}`); + } + inner += ` + +
${hintParts.join(" · ")} · click Save to refresh the model dropdown above
`; + } + if (!provider.urlField && !provider.keyField) { + inner += `
No configuration needed.
`; + } + // Inline test result lands here when the user hits Test. + const test = state.providerTestResults?.[provider.id]; + if (test) { + const colour = test.ok ? "#86efac" : "#fca5a5"; + const icon = test.ok ? "✓" : "✗"; + const body = test.ok + ? `${escHtml(test.text || "(empty response)")} · ${test.latencyMs}ms` + : escHtml(test.error || "failed"); + inner += `
${icon} ${body}
`; + } + // Close the data-provider-body div opened in the header block. + inner += `
`; + return `
${inner}
`; + } + + // The small "Test" button + spinner shown next to each provider's + // name in the credentials section. Disabled when the provider has + // no analysis capability (i.e. nothing meaningful to test). + function renderProviderTestControl(provider) { + if (!provider.canAnalyze) return ""; + const testing = state.providerTesting?.[provider.id]; + if (testing) { + return `Testing…`; + } + return ``; + } + + // Returns true when the provider has any user-configurable field + // (key, URL, models). Used to decide whether to render the + // Save/Delete buttons at all — providers like Relay have no + // user-editable fields (identity + URL are server-side), so the + // buttons would be no-ops. + function providerHasConfigurableFields(provider) { + return !!(provider.keyField || provider.urlField || provider.modelsField); + } + + // Save button shown next to each provider's name. Click flips it + // to a green "✓ Saved" pill for ~2.5s, then back to "Save". This + // is the only place we re-render the providers block after the + // user types — keystrokes update state silently (via + // setProviderOpt, no render()) so typing doesn't flash the + // screen. Save triggers the one render needed to refresh the + // model picker dropdown above with any newly-typed model names. + function renderProviderSaveControl(provider) { + if (!providerHasConfigurableFields(provider)) return ""; + const saved = state.providerSaveState?.[provider.id] === "saved"; + if (saved) { + return `✓ Saved`; + } + const hasAnyValue = providerHasAnyStoredValue(provider); + const deleteBtn = hasAnyValue + ? `` + : ""; + return `${deleteBtn}`; + } + + // Returns true if this provider has a stored value in EITHER the + // localStorage opts OR the server-side StartOS config. Drives + // whether the Delete button is visible — clicking it clears both, + // so we want to show it as long as anything is set on either side. + function providerHasAnyStoredValue(provider) { + const opts = state.providerOpts[provider.id] || {}; + for (const k of Object.keys(opts)) { + if (typeof opts[k] === "string" && opts[k].trim() !== "") return true; + } + const serverFields = state.providerServerStatus?.[provider.id] || {}; + for (const k of Object.keys(serverFields)) { + if (serverFields[k]) return true; + } + return false; + } + + // True when the server has a non-empty value for this specific + // (providerId, fieldName) pair. Used to render the inline + // "✓ Server-configured" hint under an empty input field so the + // user can tell the provider is already wired up via the StartOS + // action even though the local input is blank. + function providerFieldOnServer(providerId, fieldName) { + const fields = state.providerServerStatus?.[providerId] || {}; + return !!fields[fieldName]; + } + + // Delete a provider's credentials from BOTH localStorage and the + // StartOS config. Confirms first — this can't be undone (the user + // has to re-enter via the picker or re-run the StartOS action). + async function deleteProviderSection(providerId) { + const provider = PROVIDER_BY_ID[providerId]; + if (!provider) return; + const proceed = confirm( + `Delete ${provider.name} credentials?\n\n` + + "This clears them from BOTH this browser AND the server. " + + "To use this provider again you'll need to re-enter them in Settings or via the StartOS \"Set " + + provider.name + + " API Key\" action." + ); + if (!proceed) return; + // Local wipe + state.providerOpts[providerId] = {}; + saveProviderOpts(); + // Server wipe (best-effort — local is already gone if this fails) + try { + await fetch(`${API_BASE}/api/providers/${providerId}/clear`, { + method: "POST", + credentials: "same-origin", + }); + } catch {} + // Pull any newly-empty server-discovered URL/models for fresh + // placeholder rendering, refresh the per-field server-config + // status so the Delete button + "✓ Server-configured" hints + // reflect the cleared state, then re-render. + await Promise.all([ + loadProviderDiscovery().catch(() => {}), + loadProviderServerStatus().catch(() => {}), + ]); + render(); + } + + // Confirms a provider's credentials by re-persisting (already + // happened on every keystroke, but defensive), flashing a green + // ✓ Saved pill for 2.5s, and triggering the one render() that + // refreshes the model-picker dropdown above with any user-defined + // models. This is the visible "save" the user sees — auto-save + // to localStorage happens silently in the background to prevent + // data loss on a stray browser refresh. + function saveProviderSection(providerId) { + saveProviderOpts(); + if (!state.providerSaveState) state.providerSaveState = {}; + state.providerSaveState[providerId] = "saved"; + // Surgical update: swap the save-button slot for this provider + // into the green "✓ Saved" pill, and refresh the model-picker + // dropdowns at the top so any newly-typed models appear. No + // full render — typing/scroll state on the rest of the page + // stays intact (this is what the user complained about). + const slot = document.querySelector(`[data-save-slot="${providerId}"]`); + if (slot) slot.innerHTML = renderProviderSaveControl(PROVIDER_BY_ID[providerId]); + refreshModelPickersSurgical(); + setTimeout(() => { + if (state.providerSaveState) delete state.providerSaveState[providerId]; + const slotNow = document.querySelector(`[data-save-slot="${providerId}"]`); + if (slotNow) slotNow.innerHTML = renderProviderSaveControl(PROVIDER_BY_ID[providerId]); + }, 2500); + } + + // Replace the transcription + analysis model dropdown options + // in place when the user's Models field changes. Doesn't touch + // anything else in the settings panel. + function refreshModelPickersSurgical() { + const tp = PROVIDER_BY_ID[state.transcriptionProvider] || PROVIDERS[0]; + const ap = PROVIDER_BY_ID[state.analysisProvider] || PROVIDERS[0]; + const tSlot = document.querySelector('[data-model-slot="transcription"]'); + const aSlot = document.querySelector('[data-model-slot="analysis"]'); + if (tSlot) tSlot.innerHTML = renderModelInput("transcription", tp, state.transcriptionModel); + if (aSlot) aSlot.innerHTML = renderModelInput("analysis", ap, state.analysisModel); + } + + // Pings the provider with a tiny 3-word prompt. Uses whichever model + // is currently selected in the Analysis picker for that provider — + // or, if a different provider is selected analysis-side, the first + // entry from the resolved model list. + async function testProvider(providerId) { + const provider = PROVIDER_BY_ID[providerId]; + if (!provider) return; + // Auto-expand this provider's section so the inline test result + // (which renders inside the body div) is visible. Without this, + // clicking Test on a collapsed section caused a "screen flash + // with no apparent answer" — the result was rendering inside + // the hidden body. + if (!state.providerExpanded) state.providerExpanded = {}; + state.providerExpanded[providerId] = true; + let model = ""; + if (state.analysisProvider === providerId) { + model = state.analysisModel; + } + if (!model) { + const list = resolvedAnalysisModelsFor(provider); + model = list[0] || provider.analysisModelDefault || ""; + } + if (!model) { + state.providerTestResults = state.providerTestResults || {}; + state.providerTestResults[providerId] = { + ok: false, + error: "No model selected. Pick or type one above.", + }; + render(); + return; + } + state.providerTesting = state.providerTesting || {}; + state.providerTesting[providerId] = true; + state.providerTestResults = state.providerTestResults || {}; + delete state.providerTestResults[providerId]; + render(); + try { + const res = await fetch(`${API_BASE}/api/providers/test`, { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + providerId, + model, + opts: state.providerOpts[providerId] || {}, + }), + }); + const data = await res.json().catch(() => ({ ok: false, error: `HTTP ${res.status}` })); + state.providerTestResults[providerId] = data; + } catch (e) { + state.providerTestResults[providerId] = { ok: false, error: e.message }; + } finally { + state.providerTesting[providerId] = false; + render(); + } + } + + function setProvider(pipeline, providerId) { + const provider = PROVIDER_BY_ID[providerId]; + if (!provider) return; + if (pipeline === "transcription") { + state.transcriptionProvider = providerId; + // Snap the model to a sensible default for this provider: + // catalog → user-defined Models field → server-discovered. + // This is what makes "switch to Whisper, see my Parakeet + // model name pre-filled" actually work. + const list = resolvedTranscriptionModelsFor(provider); + state.transcriptionModel = list[0] || ""; + } else { + state.analysisProvider = providerId; + const list = resolvedAnalysisModelsFor(provider); + state.analysisModel = list[0] || provider.analysisModelDefault || ""; + } + saveProviderSelection(); + render(); + } + + function setTranscriptionModel(model) { + state.transcriptionModel = (model || "").trim(); + saveProviderSelection(); + } + + function setAnalysisModel(model) { + state.analysisModel = (model || "").trim(); + saveProviderSelection(); + } + + function setProviderOpt(providerId, field, value) { + if (!state.providerOpts[providerId]) state.providerOpts[providerId] = {}; + state.providerOpts[providerId][field] = (value || "").trim(); + saveProviderOpts(); + } + + function setUseYouTubeCaptions(checked) { + // No render() — the checkbox's visual state is already correct + // (user just clicked it), and state.useYouTubeCaptions is only + // read when submitting a URL. A full re-render here flashed the + // entire settings screen for no UI benefit. + state.useYouTubeCaptions = !!checked; + try { localStorage.setItem("recap-use-yt-captions", checked ? "1" : "0"); } catch {} + } + function renderProUpsell(featureName, description) { return `
@@ -1986,32 +3457,8 @@
${renderLicenseBlock()} - - ${state.hasServerKey ? ` -
- Server key configured -
- ` : ""} -
- - -
-

${state.hasServerKey - ? "A shared key is set on the server. Enter one here to override it on this device only." - : "Saved locally in your browser. Or set GEMINI_API_KEY in the .env file to share across all devices." - }

- -
- ${MODELS.map(m => ` - - `).join("")} -
-

Transcription always uses Flash for speed. This model handles topic analysis.

+ ${renderProvidersBlock()} ${renderYtdlpStatus()} @@ -2021,9 +3468,17 @@ ? renderSubscriptions() : `${renderProUpsell("Channel subscriptions", "Subscribe to YouTube channels and podcast feeds, then auto-process new uploads on a schedule. Available on the Pro tier.")}`} - ${hasEntitlement("library") - ? renderLibraryTransfer() - : `${renderProUpsell("Library import/export", "Bulk-export your full library (summaries, folders, subscriptions) and re-import it on another instance. Available on the Pro tier.")}`} + ${renderLibraryTransfer()} + + ${state.admin.enabled && state.admin.authed ? ` + +
+ + Signed in as ${escHtml(state.admin.username || "admin")}. The password is set on the server via the Set Admin Password StartOS action. + + +
+ ` : ""}
@@ -2097,7 +3552,6 @@ const btn = document.querySelector(".top-bar-input .submit-btn"); const promptEl = document.getElementById("subscribe-prompt"); const isCh = isSubscribeUrl(state.url); - const hasKey = state.apiKey.trim() || state.hasServerKey; if (btn) { if (isCh) { @@ -2107,7 +3561,14 @@ btn.onclick = () => addSubscriptionFromInput(); } else { btn.textContent = state.loading ? "Queue" : "Summarize"; - btn.disabled = !state.url.trim() || !hasKey; + // Match the full-render submit-disabled rule exactly so the + // button doesn't flicker between "enabled (full render)" and + // "disabled (surgical update)". providersCanRun() returns + // true when both the selected transcription and analysis + // providers have usable config (relay-configured, an API + // key in localStorage, server-side config, or auto-detected + // Ollama). + btn.disabled = !state.url.trim() || !providersCanRun(); btn.style.background = ""; btn.onclick = () => handleSubmit(); } @@ -2512,6 +3973,37 @@ } catch { return false; } } + // When processing errors out mid-stream, state.loading flips to + // false and the loading-split view (which had the YouTube embed + + // progress bar) unmounts — leaving just the error box and a blank + // panel. This branch puts the embed back so the user can still + // click through to watch on YouTube, and offers a one-click retry. + function renderErroredVideoPlaceholder() { + return ` +
+
+
+
+
+ ${renderWatchOnYouTubeLink()} + ${state.videoTitle ? `
${escHtml(state.videoTitle)}
` : ""} +
+
+
+
Processing failed before summary was ready.
+
+ You can still watch the video using the link below the player, or try again — sometimes a different model in the Analysis dropdown gets through when one is overloaded. +
+ +
+
+
+ `; + } + function renderLoadingSplit() { const steps = [ { num: 1, label: "Download", icon: "\u2B07" }, @@ -2559,6 +4051,7 @@
+ ${renderWatchOnYouTubeLink()}
@@ -2669,6 +4162,7 @@
${state.videoTitle ? `
${escHtml(state.videoTitle)}
` : ""}
${state.chunks.length} topics · ${totalEntries} segments · ${formatTime(totalDuration)} total
+ ${renderWatchOnYouTubeLink()} ` : ""}
@@ -2695,6 +4189,28 @@ `; } + // Always-visible "Watch on YouTube" link — channels can disable + // third-party embedding, and when they do, YouTube's iframe just + // shows a "Video unavailable" error. This link gives users a + // one-click fallback regardless. Only renders when we actually + // have a YouTube videoId (not for podcasts). + function renderWatchOnYouTubeLink() { + if (!state.videoId || state.currentType === "podcast") return ""; + const url = `https://www.youtube.com/watch?v=${encodeURIComponent(state.videoId)}`; + return ` + + Watch on YouTube + + + + + + + `; + } + function renderChunk(chunk, index) { const isExpanded = state.expandAll || state.expandedChunks.has(index); const startSec = Math.floor(chunk.startTime); @@ -2763,6 +4279,13 @@
`; } + // Read-only display of the per-install UUID minted by the server. + // The install-id is intentionally NOT surfaced in the UI — showing + // it would advertise the "uninstall + reinstall to reset credits" + // workaround. It's still generated server-side on first boot and + // sent to the relay as X-Recap-Install-Id for credit accounting, + // just not displayed anywhere user-visible. + function renderCookieStatus() { const uploadBtn = '