From 25b1c3a366f41f81cf1a9d8a2481195e8d70dbfe Mon Sep 17 00:00:00 2001 From: Keysat Date: Fri, 8 May 2026 11:16:02 -0500 Subject: [PATCH] Add free tier (unlicensed users get one-at-a-time summarization) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unlicensed users can now summarize a single video at a time using their own Gemini API key. The result renders in the UI exactly like a paid summary, but is not persisted — there's no library entry, no history, and a second submission while one is in flight is rejected. Server (server/index.js): • /api/process is now in LICENSE_OPEN_PATHS. The route handler distinguishes free users (state !== "licensed" || no "core") and: - rejects USE_SERVER_KEY / empty key with 402 byo_key_required (so the bundled Gemini key stays paid-only) - rejects a second concurrent job with 409 processing_in_progress via a module-level freeJobInFlight flag, released in finally - skips saveToHistory so the host's library stays clean • Pro feature gates (history/library/subscriptions) unchanged — still return 402 feature_not_in_tier for unlicensed callers. Frontend (public/index.html): • New state.activationSkipped flag (persisted to localStorage). The activation screen still appears on first launch, but now offers a "Skip — use free mode" button alongside Activate / Buy a key. Once skipped, the main app renders normally. • Free-mode upgrade banner under the top bar with Upgrade and "I have a key" buttons (the latter routes back to the activation screen). • handleLibraryClick / handleSubscribeClick wrappers — for unlicensed users, the library (clock) icon and the channel-URL Subscribe submission show a toast explaining the upgrade rather than opening an empty sidebar / hitting a 402. • Submit button enforces BYO key for unlicensed users (the bundled state.hasServerKey doesn't enable submit). handleSubmit shows a toast when an unlicensed user tries to queue a second video. --- public/index.html | 138 +++++++++++++++++++++++++++++++++++++++------- server/index.js | 47 ++++++++++++++-- 2 files changed, 160 insertions(+), 25 deletions(-) diff --git a/public/index.html b/public/index.html index 94cb33e..c331e91 100644 --- a/public/index.html +++ b/public/index.html @@ -1281,6 +1281,10 @@ licenseActivating: false, licenseActivationError: null, licenseActivationKey: "", + // Free tier: once dismissed, the activation screen no longer + // hard-gates the UI. Persisted so returning unlicensed users land + // straight in the app. + activationSkipped: localStorage.getItem("yt-summarizer-activation-skipped") === "1", }; const MODELS = ["gemini-3.1-pro-preview", "gemini-3-pro-preview", "gemini-3-flash-preview"]; @@ -1320,23 +1324,54 @@ // ── Process ────────────────────────────────────────────────────────────── async function handleSubmit() { - const hasKey = state.apiKey.trim() || state.hasServerKey; - if (!state.url.trim() || !hasKey) return; + // Free tier requires BYO Gemini key — bundled key is licensed-only. + const free = !isLicensed(); + const hasKey = free ? !!state.apiKey.trim() : (state.apiKey.trim() || state.hasServerKey); + if (!state.url.trim() || !hasKey) { + if (free && !state.apiKey.trim() && state.url.trim()) { + showToast("Free mode needs your own Gemini API key — open Settings to enter one.", "🔑"); + } + return; + } const url = state.url.trim(); - state.url = ""; - // If already processing, add to queue + // If already processing — free tier blocks, paid tier queues. if (state.loading) { + if (free) { + showToast("Free mode handles one video at a time. Wait for the current one to finish.", "⏳"); + return; + } + state.url = ""; state.queue.push({ id: Date.now().toString(), url, status: "queued", error: null }); render(); return; } - // Start processing immediately + state.url = ""; await processUrl(url); } + 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 is a paid feature — keep every summary you process. Tap upgrade to unlock.", "📚"); + return; + } + toggleHistory(); + } + + function handleSubscribeClick() { + // Subscriptions / channel auto-queue is Pro-only. + if (!hasEntitlement("subscriptions")) { + showToast("Channel & podcast subscriptions are a Pro feature. Upgrade to auto-summarize new uploads.", "📡"); + return; + } + addSubscriptionFromInput(); + } + function extractVideoId(url) { const patterns = [ /(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/|youtube\.com\/v\/)([a-zA-Z0-9_-]{11})/, @@ -1586,11 +1621,11 @@ return `
-

Activate YouTube Summarizer

+

YouTube Summarizer

${loading ? "Checking license…" - : "Paste your Keysat license key to unlock this app. Buy a key from the seller, then come back here." + : "Activate a Keysat license to unlock the full app — library, subscriptions, channel auto-queue, and the bundled API key. Or skip to use free mode (one video at a time, bring your own Gemini key)." }

${loading ? "" : ` @@ -1606,6 +1641,11 @@ ${state.licenseActivating ? "Activating…" : "Activate"} Buy a key → +
Product: ${escHtml(lic.productSlug || "youtube-summarizer")} @@ -1617,20 +1657,67 @@ `; } + function dismissActivation() { + state.activationSkipped = true; + try { localStorage.setItem("yt-summarizer-activation-skipped", "1"); } catch {} + render(); + } + + function renderFreeBanner() { + const buyUrl = upgradeToProUrl(); + return ` +
+ + Free mode + · one video at a time, bring your own Gemini key · + no library, no subscriptions + + + Upgrade + + +
+ `; + } + + function showActivationScreen() { + // Take user back to the activation modal (e.g. from an upgrade banner). + state.activationSkipped = false; + try { localStorage.removeItem("yt-summarizer-activation-skipped"); } catch {} + render(); + } + function render() { - // Hard-gate the entire UI behind a valid license (matches the server's - // activation-screen flavor). Once licensed + has core, fall through to - // the normal app render below. - if (state.license.loaded && !isLicensed()) { + // 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. + if (!state.license.loaded) { const app = document.getElementById("app"); app.className = "container"; app.innerHTML = renderActivationScreen(); 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. - if (!state.license.loaded) { + // Show the activation screen on first launch for unlicensed users so + // they discover the upgrade path. Once they hit "Skip — use free mode" + // (which sets activationSkipped = true) they fall through to the + // normal app, which renders an upgrade banner instead. + if (!isLicensed() && !state.activationSkipped) { const app = document.getElementById("app"); app.className = "container"; app.innerHTML = renderActivationScreen(); @@ -1646,11 +1733,20 @@ // Preserve library sidebar scroll position across full re-renders const __prevHistoryListEl = document.querySelector(".history-list"); const __prevHistoryScroll = __prevHistoryListEl ? __prevHistoryListEl.scrollTop : 0; + const free = !isLicensed(); + const submitNeedsBYO = free; // bundled key is licensed-only + const submitDisabled = !state.url.trim() + || (!isSubscribeUrl(state.url) + && (submitNeedsBYO + ? !state.apiKey.trim() + : (!state.apiKey.trim() && !state.hasServerKey))); app.innerHTML = `
-
+ onkeydown="if(event.key==='Enter'){ isSubscribeUrl(state.url) ? handleSubscribeClick() : handleSubmit() }" /> @@ -1738,6 +1834,8 @@ ${state.settingsOpen ? renderSettingsModal() : ""} + ${free ? renderFreeBanner() : ""} + ${isSubscribeUrl(state.url) ? `
${renderSubscribePrompt()}
` : ""} ${state.queue.length > 0 ? renderQueue() : ""} diff --git a/server/index.js b/server/index.js index 380b97c..70f800d 100644 --- a/server/index.js +++ b/server/index.js @@ -144,6 +144,9 @@ setInterval(() => { }, VALIDATE_INTERVAL_MS); // Endpoints reachable without a license — kept intentionally minimal. +// /api/process is open so unlicensed (free-tier) users can summarize one +// video at a time with their own Gemini key. The route handler enforces +// BYO-key + a one-at-a-time concurrency lock for free users. const LICENSE_OPEN_PATHS = new Set([ "/api/health", "/api/heartbeat", @@ -151,6 +154,7 @@ const LICENSE_OPEN_PATHS = new Set([ "/api/license-status", "/api/license/activate", "/api/license/deactivate", + "/api/process", ]); // Activation-screen gate: any /api/* request without a valid license is @@ -165,7 +169,7 @@ app.use((req, res, next) => { message: LIC.state === "licensed" ? "Your license is missing the 'core' entitlement. Contact the seller." - : "This service requires a Keysat license. Activate to continue.", + : "This feature requires a Keysat license. Upgrade to unlock.", state: LIC.state, reason: LIC.reason, activate_url: "/#activate", @@ -174,6 +178,11 @@ app.use((req, res, next) => { }); }); +// Free-tier concurrency lock. Unlicensed users may process one video at +// a time — second submission while another is in flight returns 409. +// Released in a finally block at the bottom of /api/process. +let freeJobInFlight = false; + // 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 @@ -2000,14 +2009,36 @@ app.get("/api/processing/log", (req, res) => { app.post("/api/process", async (req, res) => { const { url, apiKey: clientKey, model, type: itemType, title: itemTitle, uploadDate: itemUploadDate, episodeId } = req.body; - // BYO Gemini key is a Core-tier feature; the activation gate already - // ensures the caller is licensed, so no further check is needed here. + + // Free tier: unlicensed users can summarize, but only with their own + // Gemini key (no riding on the bundled key) and only one job at a time. + const isFreeUser = !(LIC.state === "licensed" && LIC.entitlements.has("core")); + if (isFreeUser) { + if (!clientKey || clientKey === "USE_SERVER_KEY") { + return res.status(402).json({ + error: "byo_key_required", + message: + "Free mode requires your own Gemini API key. Open Settings to enter one, or upgrade to use the bundled key.", + }); + } + if (freeJobInFlight) { + return res.status(409).json({ + error: "processing_in_progress", + message: + "A summary is already being processed. Free mode handles one video at a time — wait for the current one to finish.", + }); + } + freeJobInFlight = true; + } + const apiKey = resolveApiKey(clientKey); if (!url) { + if (isFreeUser) freeJobInFlight = false; return res.status(400).json({ error: "Missing url" }); } if (!apiKey) { + if (isFreeUser) freeJobInFlight = false; return res.status(400).json({ error: "No API key provided. Set GEMINI_API_KEY in .env or enter one in Settings." }); } @@ -2016,6 +2047,7 @@ app.post("/api/process", async (req, res) => { const videoId = isPodcast ? (episodeId || url) : extractVideoId(url); if (!isPodcast && !videoId) { + if (isFreeUser) freeJobInFlight = false; return res.status(400).json({ error: "Invalid YouTube URL" }); } @@ -2619,9 +2651,13 @@ Return ONLY the timestamped transcript, nothing else.`; const totalTokens = (txCost.totalTokens + anaCost.totalTokens).toLocaleString(); log(3, `Pipeline finished in ${totalTime}s — total cost: ${totalCostDisplay} (${totalTokens} tokens)`); - // Save to history + // Save to history — skipped for free-tier users so we don't pollute + // the host's library with anonymous summaries. The result still streams + // back so the UI can render it; it just isn't persisted. const contentType = isPodcast ? "podcast" : "youtube"; - const historyId = await saveToHistory(videoId, url, videoTitle, chunks, entries, logHistory, videoUploadDate, contentType).catch(() => null); + const historyId = isFreeUser + ? null + : await saveToHistory(videoId, url, videoTitle, chunks, entries, logHistory, videoUploadDate, contentType).catch(() => null); sendEvent(res, "result", { videoId, videoTitle, entries, chunks, historyId, type: contentType }); res.end(); @@ -2635,6 +2671,7 @@ Return ONLY the timestamped transcript, nothing else.`; res.end(); } } finally { + if (isFreeUser) freeJobInFlight = false; // Clean up temp files try { await fs.rm(tmpDir, { recursive: true, force: true });