Pluggable AI providers, relay credit system, picker UX overhaul
Captures roughly forty version bumps (v0.2.6 → v0.2.47) of work that
accumulated without commits.
- Pluggable provider system under server/providers/: gemini, anthropic,
openai, openai-compatible, ollama, whisper-compatible, relay. Mix and
match transcription + analysis per request via the picker UI.
- Relay backend integration. Hardcoded relay URL in server/relay-default.js
(operator-controlled at build time, not user-configurable). New
/api/relay/{status,policy} endpoints proxy to the relay; balance pings
populate a cached credit display.
- Per-install identity in server/install-id.js for relay credit accounting.
Sent to the relay as X-Recap-Install-Id; persists across upgrades, lost
on a full uninstall + reinstall. Not surfaced in the UI.
- Admin login gate (server/admin-auth.js + setAdminPassword action). Scrypt
password hash + HMAC-signed session cookie.
- Entitlement scheme rename: pro / max (each paired with subscriptions and
relay_pro / relay_max), replacing the misleading "core" entitlement
that conflicted with the user-facing "Core" tier name.
- Activation screen: dynamic credit count pulled from /api/relay/policy,
"Skip — use free mode" button, accurate paid-feature list.
- Top toolbar: inline credit-balance pill (or "BYO configured" fallback),
Upgrade + "I have a key" buttons.
- Picker UI: per-provider sections with Save/Test/Delete buttons, sections
collapsible by chevron, default-collapsed unless currently selected,
"Use comped credits (reset to relay)" link when the user has strayed,
green hint under inputs whose values are server-configured.
- Activity log: chevron-collapsible groups per video, refresh-survival via
localStorage + a 500-entry server-side buffer, explicit Clear button.
- YouTube captions fast-path with user toggle (skips audio download + AI
transcription when captions are available — uncheck for speaker labels).
- Cancel button: AbortController plumbed through every provider SDK call;
retryAPI short-circuits on AbortError; cancellation events surface in
the activity log instead of silent retries.
- Long-video analysis: auto-coalesce transcript entries before building the
analysis prompt so local-model context windows (32k-ish) don't overflow.
Original entries preserved for transcript display via an index map; the
analyzer sees a coarser view but click-to-seek timestamps stay precise.
- StartOS action grouping (Setup / AI Providers) so the actions list is
navigable.
- Manifest description rewritten to reflect multi-provider support and
free-tier relay credits.
- Smaller fixes: summarize-button enablement no longer requires a Gemini
key when other providers are configured; analysis fallback chain handles
context-length and 503 capacity errors; single-segment expansion for
providers that don't return per-segment timestamps (Parakeet et al.);
many other UX polish items.
This commit is contained in:
+133
-27
@@ -20,9 +20,16 @@ console.log(
|
||||
|
||||
// Free-tier concurrency lock. Unlicensed users may process one video at
|
||||
// a time — second submission while another is in flight returns 409 from
|
||||
// /api/process. The /api/process handler calls tryAcquireFreeSlot() at
|
||||
// entry and releaseFreeSlot() in its finally block.
|
||||
let freeJobInFlight = false;
|
||||
// /api/process with details about what's running. The /api/process
|
||||
// handler calls tryAcquireFreeSlot() at entry and releaseFreeSlot() in
|
||||
// its finally block.
|
||||
//
|
||||
// The current-job object also drives:
|
||||
// - /api/process/current — UI status banner after a browser refresh
|
||||
// - /api/process/cancel — sets `aborted: true` AND fires the request's
|
||||
// AbortController so in-flight model API calls are interrupted
|
||||
// immediately (not just at the next pipeline checkpoint).
|
||||
let currentFreeJob = null; // { url, title, startedAt, aborted, abortController } | null
|
||||
|
||||
// ── Online validation tunables ──────────────────────────────────────────────
|
||||
// 30 min default scheduled cycle catches revocations / suspensions /
|
||||
@@ -103,22 +110,101 @@ export function startLicenseRefresh() {
|
||||
}
|
||||
|
||||
// ── Free-tier slot management ───────────────────────────────────────────────
|
||||
// Whether the current LIC counts as a "free" (unlicensed / no core) user.
|
||||
// Whether the current LIC counts as a paid user — i.e. holds either the
|
||||
// `pro` or `max` entitlement. Keysat policy cards mint Pro licenses with
|
||||
// `pro` and Max licenses with `max`; both unlock the same Recap-side
|
||||
// gates today (subscriptions, no free-tier concurrency lock), with the
|
||||
// relay layer responsible for the Pro-vs-Max quota split.
|
||||
export function isPaidUser() {
|
||||
if (LIC.state !== "licensed") return false;
|
||||
return LIC.entitlements.has("pro") || LIC.entitlements.has("max");
|
||||
}
|
||||
|
||||
// Inverse of isPaidUser — kept as a separate export because that's how
|
||||
// most callers phrase the check ("if free, apply rate limits / show
|
||||
// upgrade banner / etc.").
|
||||
export function isFreeUser() {
|
||||
return !(LIC.state === "licensed" && LIC.entitlements.has("core"));
|
||||
return !isPaidUser();
|
||||
}
|
||||
|
||||
// Returns true if the slot was acquired, false if another free job is in
|
||||
// flight. The /api/process handler must release via releaseFreeSlot()
|
||||
// in a finally block on every exit path.
|
||||
export function tryAcquireFreeSlot() {
|
||||
if (freeJobInFlight) return false;
|
||||
freeJobInFlight = true;
|
||||
//
|
||||
// `abortController` is the request's AbortController — abortCurrentFreeJob
|
||||
// calls .abort() on it so in-flight provider SDK calls are interrupted at
|
||||
// the network layer, not just at the next pipeline checkpoint.
|
||||
//
|
||||
// `logs` is a server-side buffer the pipeline appends to (via
|
||||
// appendCurrentJobLog) as each progress message is sent over SSE. After
|
||||
// a browser refresh the client re-fetches /api/process/current and uses
|
||||
// these to repopulate the activity log — without it, a refresh during
|
||||
// a long pipeline silently drops everything the user has already seen.
|
||||
export function tryAcquireFreeSlot({ url = "", title = "", abortController = null } = {}) {
|
||||
if (currentFreeJob) return false;
|
||||
currentFreeJob = {
|
||||
url,
|
||||
title,
|
||||
startedAt: Date.now(),
|
||||
aborted: false,
|
||||
abortController,
|
||||
logs: [],
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
// Push one entry onto the in-flight job's log buffer. No-op if there's
|
||||
// no current job (e.g. licensed user — no free-tier tracking). Kept
|
||||
// bounded so a multi-hour run doesn't grow the buffer without limit.
|
||||
const MAX_LIVE_LOG_ENTRIES = 500;
|
||||
export function appendCurrentJobLog(entry) {
|
||||
if (!currentFreeJob || !entry) return;
|
||||
currentFreeJob.logs.push(entry);
|
||||
if (currentFreeJob.logs.length > MAX_LIVE_LOG_ENTRIES) {
|
||||
currentFreeJob.logs.splice(0, currentFreeJob.logs.length - MAX_LIVE_LOG_ENTRIES);
|
||||
}
|
||||
}
|
||||
|
||||
export function releaseFreeSlot() {
|
||||
freeJobInFlight = false;
|
||||
currentFreeJob = null;
|
||||
}
|
||||
|
||||
// Returns a JSON-friendly snapshot of the in-flight free job, or null.
|
||||
// `includeLogs` is opt-in because the typical poll (banner refresh) only
|
||||
// cares about the small header fields — logs are only needed when the
|
||||
// client is rehydrating after a browser refresh.
|
||||
export function getCurrentFreeJob({ includeLogs = false } = {}) {
|
||||
if (!currentFreeJob) return null;
|
||||
const out = {
|
||||
url: currentFreeJob.url,
|
||||
title: currentFreeJob.title,
|
||||
startedAt: currentFreeJob.startedAt,
|
||||
elapsedMs: Date.now() - currentFreeJob.startedAt,
|
||||
aborted: currentFreeJob.aborted,
|
||||
};
|
||||
if (includeLogs) out.logs = [...currentFreeJob.logs];
|
||||
return out;
|
||||
}
|
||||
|
||||
// Mark the current job as cancelled AND fire its AbortController so any
|
||||
// in-flight provider SDK call rejects immediately. Pipeline code also
|
||||
// polls isFreeJobAborted() at major checkpoints — that handles the gaps
|
||||
// between awaitable calls (e.g. while looping over yt-dlp retry delays).
|
||||
// The handler's finally block runs releaseFreeSlot(), so we don't clear
|
||||
// currentFreeJob here — that avoids a race where a follow-up /api/process
|
||||
// request acquires the slot while the cancelled call is still cleaning up.
|
||||
// Returns true if there was a job to cancel.
|
||||
export function abortCurrentFreeJob() {
|
||||
if (!currentFreeJob) return false;
|
||||
currentFreeJob.aborted = true;
|
||||
try {
|
||||
currentFreeJob.abortController?.abort();
|
||||
} catch {}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function isFreeJobAborted() {
|
||||
return !!(currentFreeJob && currentFreeJob.aborted);
|
||||
}
|
||||
|
||||
// ── Endpoints reachable without a license ───────────────────────────────────
|
||||
@@ -132,34 +218,53 @@ const LICENSE_OPEN_PATHS = new Set([
|
||||
"/api/license-status",
|
||||
"/api/license/activate",
|
||||
"/api/license/deactivate",
|
||||
"/api/process",
|
||||
// Install identity — needed by the relay client before any license
|
||||
// exists, and by the UI's settings panel for verification.
|
||||
"/api/install-id",
|
||||
// Relay balance display — the UI needs to render credit counts even
|
||||
// for unlicensed (Core) users since they get free lifetime credits.
|
||||
"/api/relay/status",
|
||||
// Tier-policy lookup powers dynamic copy on the activation screen
|
||||
// (e.g. "N relay credits" pulled live from the relay). Unlicensed
|
||||
// users see the activation screen, so this must be open to them.
|
||||
"/api/relay/policy",
|
||||
]);
|
||||
|
||||
// Prefix-based open list: any /api/* path that startsWith one of these
|
||||
// is reachable without a license. Library + saved summaries are part of
|
||||
// the free experience (the app would feel broken without them — you'd
|
||||
// summarize a video and never be able to find it again). Subscriptions,
|
||||
// clips, and the relay remain paid. /api/providers/* is open so any
|
||||
// user (including unlicensed) can test connectivity to their LLM
|
||||
// providers before deciding whether to buy. /api/process is a prefix
|
||||
// (not an exact-match in LICENSE_OPEN_PATHS) because /api/process,
|
||||
// /api/process/current, and /api/process/cancel all need to be reachable
|
||||
// for the free-tier flow — without /current the in-flight banner can't
|
||||
// clear after the pipeline finishes, and without /cancel the Cancel
|
||||
// button silently fails for unlicensed users.
|
||||
const LICENSE_OPEN_PREFIXES = [
|
||||
"/api/history",
|
||||
"/api/library",
|
||||
"/api/providers",
|
||||
"/api/process",
|
||||
];
|
||||
|
||||
// ── Pro-tier feature gates ──────────────────────────────────────────────────
|
||||
// Each entry maps URL prefixes → required entitlement; first match wins.
|
||||
// A licensed user without the right entitlement gets a clean 402
|
||||
// feature_not_in_tier (vs. the generic activation gate above).
|
||||
//
|
||||
// History + library used to be gated here. They moved to the free tier
|
||||
// (see LICENSE_OPEN_PREFIXES above) — without saved summaries the app
|
||||
// feels broken on first use, and the real paid value is auto-queue +
|
||||
// relay credits.
|
||||
const PRO_FEATURE_GATES = [
|
||||
{
|
||||
prefixes: ["/api/subscriptions", "/api/auto-queue", "/api/sub-check-log"],
|
||||
entitlement: "subscriptions",
|
||||
feature: "subscriptions",
|
||||
message:
|
||||
"Channel subscriptions and auto-queue require a Pro license. Upgrade to unlock.",
|
||||
},
|
||||
{
|
||||
prefixes: ["/api/history"],
|
||||
entitlement: "history",
|
||||
feature: "history",
|
||||
message:
|
||||
"Summary history requires a Pro license. Upgrade to unlock.",
|
||||
},
|
||||
{
|
||||
prefixes: ["/api/library"],
|
||||
entitlement: "library",
|
||||
feature: "library",
|
||||
message:
|
||||
"Library import/export requires a Pro license. Upgrade to unlock.",
|
||||
"Channel subscriptions and auto-queue require a paid license. Upgrade to unlock.",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -174,12 +279,13 @@ export function setupLicenseMiddleware(app) {
|
||||
app.use((req, res, next) => {
|
||||
if (!req.path.startsWith("/api/")) return next();
|
||||
if (LICENSE_OPEN_PATHS.has(req.path)) return next();
|
||||
if (LIC.state === "licensed" && LIC.entitlements.has("core")) return next();
|
||||
if (LICENSE_OPEN_PREFIXES.some((p) => req.path.startsWith(p))) return next();
|
||||
if (isPaidUser()) return next();
|
||||
return res.status(402).json({
|
||||
error: "license_required",
|
||||
message:
|
||||
LIC.state === "licensed"
|
||||
? "Your license is missing the 'core' entitlement. Contact the seller."
|
||||
? "Your license is missing the 'pro' or 'max' entitlement. Contact the seller."
|
||||
: "This feature requires a Recap license. Upgrade to unlock.",
|
||||
state: LIC.state,
|
||||
reason: LIC.reason,
|
||||
|
||||
Reference in New Issue
Block a user