Add free tier (unlicensed users get one-at-a-time summarization)
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.
This commit is contained in:
+42
-5
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user