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:
+118
-20
@@ -1281,6 +1281,10 @@
|
|||||||
licenseActivating: false,
|
licenseActivating: false,
|
||||||
licenseActivationError: null,
|
licenseActivationError: null,
|
||||||
licenseActivationKey: "",
|
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"];
|
const MODELS = ["gemini-3.1-pro-preview", "gemini-3-pro-preview", "gemini-3-flash-preview"];
|
||||||
@@ -1320,23 +1324,54 @@
|
|||||||
// ── Process ──────────────────────────────────────────────────────────────
|
// ── Process ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function handleSubmit() {
|
async function handleSubmit() {
|
||||||
const hasKey = state.apiKey.trim() || state.hasServerKey;
|
// Free tier requires BYO Gemini key — bundled key is licensed-only.
|
||||||
if (!state.url.trim() || !hasKey) return;
|
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();
|
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 (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 });
|
state.queue.push({ id: Date.now().toString(), url, status: "queued", error: null });
|
||||||
render();
|
render();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start processing immediately
|
state.url = "";
|
||||||
await processUrl(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) {
|
function extractVideoId(url) {
|
||||||
const patterns = [
|
const patterns = [
|
||||||
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/|youtube\.com\/v\/)([a-zA-Z0-9_-]{11})/,
|
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/|youtube\.com\/v\/)([a-zA-Z0-9_-]{11})/,
|
||||||
@@ -1586,11 +1621,11 @@
|
|||||||
return `
|
return `
|
||||||
<div class="activation-screen">
|
<div class="activation-screen">
|
||||||
<div class="activation-card">
|
<div class="activation-card">
|
||||||
<h1>Activate YouTube Summarizer</h1>
|
<h1>YouTube Summarizer</h1>
|
||||||
<p class="activation-sub">
|
<p class="activation-sub">
|
||||||
${loading
|
${loading
|
||||||
? "Checking license…"
|
? "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)."
|
||||||
}
|
}
|
||||||
</p>
|
</p>
|
||||||
${loading ? "" : `
|
${loading ? "" : `
|
||||||
@@ -1606,6 +1641,11 @@
|
|||||||
${state.licenseActivating ? "Activating…" : "Activate"}
|
${state.licenseActivating ? "Activating…" : "Activate"}
|
||||||
</button>
|
</button>
|
||||||
<a class="activation-link" href="${escHtml(buyUrl)}" target="_blank" rel="noopener">Buy a key →</a>
|
<a class="activation-link" href="${escHtml(buyUrl)}" target="_blank" rel="noopener">Buy a key →</a>
|
||||||
|
<button class="activation-link"
|
||||||
|
style="background:none;border:none;color:#94a3b8;cursor:pointer;padding:0;font-size:13px;"
|
||||||
|
onclick="dismissActivation()">
|
||||||
|
Skip — use free mode
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="activation-meta">
|
<div class="activation-meta">
|
||||||
Product: <strong>${escHtml(lic.productSlug || "youtube-summarizer")}</strong>
|
Product: <strong>${escHtml(lic.productSlug || "youtube-summarizer")}</strong>
|
||||||
@@ -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 `
|
||||||
|
<div class="free-banner" style="
|
||||||
|
margin: 8px 0 12px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: linear-gradient(90deg, rgba(168,85,247,0.12), rgba(99,102,241,0.10));
|
||||||
|
border: 1px solid rgba(168,85,247,0.35);
|
||||||
|
border-radius: 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
color: #e2e8f0;
|
||||||
|
font-size: 13px;
|
||||||
|
">
|
||||||
|
<span style="flex:1; min-width: 220px;">
|
||||||
|
<strong style="color:#c4b5fd;">Free mode</strong>
|
||||||
|
· one video at a time, bring your own Gemini key ·
|
||||||
|
no library, no subscriptions
|
||||||
|
</span>
|
||||||
|
<a href="${escHtml(buyUrl)}" target="_blank" rel="noopener"
|
||||||
|
style="background:#a855f7;color:#fff;border:none;padding:6px 12px;border-radius:6px;text-decoration:none;font-size:12px;font-weight:600;">
|
||||||
|
Upgrade
|
||||||
|
</a>
|
||||||
|
<button onclick="showActivationScreen()"
|
||||||
|
style="background:transparent;color:#94a3b8;border:1px solid #334155;padding:6px 10px;border-radius:6px;cursor:pointer;font-size:12px;">
|
||||||
|
I have a key
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
function render() {
|
||||||
// Hard-gate the entire UI behind a valid license (matches the server's
|
// Initial paint while license-status is still in-flight: show the
|
||||||
// activation-screen flavor). Once licensed + has core, fall through to
|
// activation card in its loading skeleton state rather than a flash of
|
||||||
// the normal app render below.
|
// the underlying app.
|
||||||
if (state.license.loaded && !isLicensed()) {
|
if (!state.license.loaded) {
|
||||||
const app = document.getElementById("app");
|
const app = document.getElementById("app");
|
||||||
app.className = "container";
|
app.className = "container";
|
||||||
app.innerHTML = renderActivationScreen();
|
app.innerHTML = renderActivationScreen();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Initial paint while license-status is still in-flight: show the
|
// Show the activation screen on first launch for unlicensed users so
|
||||||
// activation card in its loading skeleton state rather than a flash of
|
// they discover the upgrade path. Once they hit "Skip — use free mode"
|
||||||
// the underlying app.
|
// (which sets activationSkipped = true) they fall through to the
|
||||||
if (!state.license.loaded) {
|
// normal app, which renders an upgrade banner instead.
|
||||||
|
if (!isLicensed() && !state.activationSkipped) {
|
||||||
const app = document.getElementById("app");
|
const app = document.getElementById("app");
|
||||||
app.className = "container";
|
app.className = "container";
|
||||||
app.innerHTML = renderActivationScreen();
|
app.innerHTML = renderActivationScreen();
|
||||||
@@ -1646,11 +1733,20 @@
|
|||||||
// Preserve library sidebar scroll position across full re-renders
|
// Preserve library sidebar scroll position across full re-renders
|
||||||
const __prevHistoryListEl = document.querySelector(".history-list");
|
const __prevHistoryListEl = document.querySelector(".history-list");
|
||||||
const __prevHistoryScroll = __prevHistoryListEl ? __prevHistoryListEl.scrollTop : 0;
|
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 = `
|
app.innerHTML = `
|
||||||
<!-- Top bar: title + action icons -->
|
<!-- Top bar: title + action icons -->
|
||||||
<div class="top-bar">
|
<div class="top-bar">
|
||||||
<div class="top-left-actions">
|
<div class="top-left-actions">
|
||||||
<button class="icon-btn ${state.historyOpen ? "active" : ""}" onclick="toggleHistory()" title="Library">
|
<button class="icon-btn ${state.historyOpen && !free ? "active" : ""}" onclick="handleLibraryClick()"
|
||||||
|
title="${free ? "Library (upgrade to unlock)" : "Library"}"
|
||||||
|
style="${free ? "opacity:0.55;" : ""}">
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline>
|
<circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline>
|
||||||
</svg>
|
</svg>
|
||||||
@@ -1658,13 +1754,13 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="top-bar-input">
|
<div class="top-bar-input">
|
||||||
<input type="text" class="url-input"
|
<input type="text" class="url-input"
|
||||||
placeholder="${state.loading ? "Paste another URL to queue it..." : "Paste a YouTube video, channel, or podcast RSS URL..."}"
|
placeholder="${state.loading ? (free ? "Wait for the current video — free mode is one at a time" : "Paste another URL to queue it...") : "Paste a YouTube video, channel, or podcast RSS URL..."}"
|
||||||
value="${escHtml(state.url)}"
|
value="${escHtml(state.url)}"
|
||||||
oninput="state.url=this.value; updateInputMode()"
|
oninput="state.url=this.value; updateInputMode()"
|
||||||
onkeydown="if(event.key==='Enter'){ isSubscribeUrl(state.url) ? addSubscriptionFromInput() : handleSubmit() }" />
|
onkeydown="if(event.key==='Enter'){ isSubscribeUrl(state.url) ? handleSubscribeClick() : handleSubmit() }" />
|
||||||
<button class="submit-btn"
|
<button class="submit-btn"
|
||||||
onclick="${isSubscribeUrl(state.url) ? "addSubscriptionFromInput()" : "handleSubmit()"}"
|
onclick="${isSubscribeUrl(state.url) ? "handleSubscribeClick()" : "handleSubmit()"}"
|
||||||
${!state.url.trim() || (!isSubscribeUrl(state.url) && !state.apiKey.trim() && !state.hasServerKey) ? "disabled" : ""}
|
${submitDisabled ? "disabled" : ""}
|
||||||
style="${isSubscribeUrl(state.url) ? "background:#6366f1" : ""}">
|
style="${isSubscribeUrl(state.url) ? "background:#6366f1" : ""}">
|
||||||
${isSubscribeUrl(state.url) ? (state.addingSubLoading ? "Subscribing..." : "Subscribe") : (state.loading ? "Queue" : "Summarize")}
|
${isSubscribeUrl(state.url) ? (state.addingSubLoading ? "Subscribing..." : "Subscribe") : (state.loading ? "Queue" : "Summarize")}
|
||||||
</button>
|
</button>
|
||||||
@@ -1738,6 +1834,8 @@
|
|||||||
<!-- Settings modal -->
|
<!-- Settings modal -->
|
||||||
${state.settingsOpen ? renderSettingsModal() : ""}
|
${state.settingsOpen ? renderSettingsModal() : ""}
|
||||||
|
|
||||||
|
${free ? renderFreeBanner() : ""}
|
||||||
|
|
||||||
${isSubscribeUrl(state.url) ? `<div id="subscribe-prompt">${renderSubscribePrompt()}</div>` : ""}
|
${isSubscribeUrl(state.url) ? `<div id="subscribe-prompt">${renderSubscribePrompt()}</div>` : ""}
|
||||||
${state.queue.length > 0 ? renderQueue() : ""}
|
${state.queue.length > 0 ? renderQueue() : ""}
|
||||||
|
|
||||||
|
|||||||
+42
-5
@@ -144,6 +144,9 @@ setInterval(() => {
|
|||||||
}, VALIDATE_INTERVAL_MS);
|
}, VALIDATE_INTERVAL_MS);
|
||||||
|
|
||||||
// Endpoints reachable without a license — kept intentionally minimal.
|
// 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([
|
const LICENSE_OPEN_PATHS = new Set([
|
||||||
"/api/health",
|
"/api/health",
|
||||||
"/api/heartbeat",
|
"/api/heartbeat",
|
||||||
@@ -151,6 +154,7 @@ const LICENSE_OPEN_PATHS = new Set([
|
|||||||
"/api/license-status",
|
"/api/license-status",
|
||||||
"/api/license/activate",
|
"/api/license/activate",
|
||||||
"/api/license/deactivate",
|
"/api/license/deactivate",
|
||||||
|
"/api/process",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Activation-screen gate: any /api/* request without a valid license is
|
// Activation-screen gate: any /api/* request without a valid license is
|
||||||
@@ -165,7 +169,7 @@ app.use((req, res, next) => {
|
|||||||
message:
|
message:
|
||||||
LIC.state === "licensed"
|
LIC.state === "licensed"
|
||||||
? "Your license is missing the 'core' entitlement. Contact the seller."
|
? "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,
|
state: LIC.state,
|
||||||
reason: LIC.reason,
|
reason: LIC.reason,
|
||||||
activate_url: "/#activate",
|
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
|
// Pro-tier feature gates. Each entry maps URL prefixes → required
|
||||||
// entitlement; first match wins. A licensed user without the right
|
// entitlement; first match wins. A licensed user without the right
|
||||||
// entitlement gets a clean 402 feature_not_in_tier (vs. the generic
|
// 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) => {
|
app.post("/api/process", async (req, res) => {
|
||||||
const { url, apiKey: clientKey, model, type: itemType, title: itemTitle, uploadDate: itemUploadDate, episodeId } = req.body;
|
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);
|
const apiKey = resolveApiKey(clientKey);
|
||||||
|
|
||||||
if (!url) {
|
if (!url) {
|
||||||
|
if (isFreeUser) freeJobInFlight = false;
|
||||||
return res.status(400).json({ error: "Missing url" });
|
return res.status(400).json({ error: "Missing url" });
|
||||||
}
|
}
|
||||||
if (!apiKey) {
|
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." });
|
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);
|
const videoId = isPodcast ? (episodeId || url) : extractVideoId(url);
|
||||||
|
|
||||||
if (!isPodcast && !videoId) {
|
if (!isPodcast && !videoId) {
|
||||||
|
if (isFreeUser) freeJobInFlight = false;
|
||||||
return res.status(400).json({ error: "Invalid YouTube URL" });
|
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();
|
const totalTokens = (txCost.totalTokens + anaCost.totalTokens).toLocaleString();
|
||||||
log(3, `Pipeline finished in ${totalTime}s — total cost: ${totalCostDisplay} (${totalTokens} tokens)`);
|
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 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 });
|
sendEvent(res, "result", { videoId, videoTitle, entries, chunks, historyId, type: contentType });
|
||||||
res.end();
|
res.end();
|
||||||
@@ -2635,6 +2671,7 @@ Return ONLY the timestamped transcript, nothing else.`;
|
|||||||
res.end();
|
res.end();
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
if (isFreeUser) freeJobInFlight = false;
|
||||||
// Clean up temp files
|
// Clean up temp files
|
||||||
try {
|
try {
|
||||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||||
|
|||||||
Reference in New Issue
Block a user