0ae59f3550
Introduces RECAP_MODE=multi alongside single-mode self-host: - Tenant auth + accounts (magic-link via System SMTP), per-tenant credit pool, anonymous trial minting with per-IP/-64 caps - Self-serve Pro/Max purchase: inline Lightning (BTCPay) + card (Zaprite), prepaid 30-day periods, expiry-reminder emails - Core-decoupling: relay owns cloud tier/expiry keyed by Recaps user-id - SQLite (better-sqlite3) schema for multi-mode; filesystem unchanged for single - StartOS actions/versions through 0.2.155
286 lines
10 KiB
JavaScript
286 lines
10 KiB
JavaScript
// Subscription storage + discovery helpers, keyed by scope.
|
|
//
|
|
// Today the subscription feature is operator-only in multi mode (gated in
|
|
// license-middleware), so the only active scope is "owner". But the storage,
|
|
// dedup, check-enumeration, and migration here are all scope-parameterized,
|
|
// so flipping the gate to per-tenant (see docs/per-tenant-subscriptions-plan.md)
|
|
// is a matter of passing each user's scope instead of "owner" — no storage
|
|
// rework. Step 4 of that plan (the background processor acting as each
|
|
// owning user) is the only remaining piece and needs on-device testing.
|
|
//
|
|
// Each scope's state lives under scopeDir(scope) = history/<scope>/:
|
|
// subscriptions.json — the user's channel/podcast subscriptions
|
|
// auto-queue.json — discovered videos awaiting approval / processing
|
|
// skip-list.json — videoIds the user declined (never re-offer)
|
|
// seen-list.json — videoIds already offered (don't re-surface)
|
|
|
|
import fs from "fs/promises";
|
|
import path from "path";
|
|
import { getScopeHistoryDir, getHistoryDir, ROOT_SIDECARS } from "./history.js";
|
|
|
|
// ── Per-scope file paths ─────────────────────────────────────────────────
|
|
function subsPath(scope) {
|
|
return path.join(getScopeHistoryDir(scope), "subscriptions.json");
|
|
}
|
|
function skipPath(scope) {
|
|
return path.join(getScopeHistoryDir(scope), "skip-list.json");
|
|
}
|
|
function seenPath(scope) {
|
|
return path.join(getScopeHistoryDir(scope), "seen-list.json");
|
|
}
|
|
function autoQueuePath(scope) {
|
|
return path.join(getScopeHistoryDir(scope), "auto-queue.json");
|
|
}
|
|
|
|
async function ensureScopeDir(scope) {
|
|
await fs.mkdir(getScopeHistoryDir(scope), { recursive: true }).catch(() => {});
|
|
}
|
|
|
|
// Serialize read-modify-write on a given file path so two concurrent
|
|
// handlers can't each load the same snapshot, mutate, and have the second
|
|
// write clobber the first. Keyed by absolute path → naturally per-scope.
|
|
const _fileLocks = new Map();
|
|
function withFileLock(key, fn) {
|
|
const prev = _fileLocks.get(key) || Promise.resolve();
|
|
const next = prev.then(fn, fn); // run fn whether prev resolved or rejected
|
|
_fileLocks.set(
|
|
key,
|
|
next.catch(() => {}),
|
|
);
|
|
return next;
|
|
}
|
|
|
|
// ── Subscriptions ────────────────────────────────────────────────────────
|
|
export async function loadSubscriptions(scope) {
|
|
try {
|
|
return (
|
|
JSON.parse(await fs.readFile(subsPath(scope), "utf-8")).subscriptions || []
|
|
);
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
export async function saveSubscriptions(scope, subs) {
|
|
return withFileLock(subsPath(scope), async () => {
|
|
await ensureScopeDir(scope);
|
|
await fs.writeFile(
|
|
subsPath(scope),
|
|
JSON.stringify({ subscriptions: subs }, null, 2),
|
|
);
|
|
});
|
|
}
|
|
|
|
// ── Skip list (declined videos — never re-add) ───────────────────────────
|
|
export async function loadSkipList(scope) {
|
|
try {
|
|
return new Set(
|
|
JSON.parse(await fs.readFile(skipPath(scope), "utf-8")).videoIds || [],
|
|
);
|
|
} catch {
|
|
return new Set();
|
|
}
|
|
}
|
|
export async function addToSkipList(scope, videoId) {
|
|
return withFileLock(skipPath(scope), async () => {
|
|
const ids = await loadSkipList(scope);
|
|
ids.add(videoId);
|
|
await ensureScopeDir(scope);
|
|
await fs.writeFile(skipPath(scope), JSON.stringify({ videoIds: [...ids] }));
|
|
});
|
|
}
|
|
|
|
// ── Seen list (already offered — don't re-surface) ───────────────────────
|
|
export async function loadSeenList(scope) {
|
|
try {
|
|
return new Set(
|
|
JSON.parse(await fs.readFile(seenPath(scope), "utf-8")).videoIds || [],
|
|
);
|
|
} catch {
|
|
return new Set();
|
|
}
|
|
}
|
|
export async function addToSeenList(scope, videoIds) {
|
|
return withFileLock(seenPath(scope), async () => {
|
|
const seen = await loadSeenList(scope);
|
|
for (const id of videoIds) seen.add(id);
|
|
await ensureScopeDir(scope);
|
|
await fs.writeFile(seenPath(scope), JSON.stringify({ videoIds: [...seen] }));
|
|
});
|
|
}
|
|
|
|
// ── Auto-queue ───────────────────────────────────────────────────────────
|
|
// Read-only load. For mutations use mutateAutoQueue so the read-modify-write
|
|
// is atomic per scope (the old in-memory global array gave this implicitly).
|
|
export async function loadAutoQueue(scope) {
|
|
try {
|
|
return JSON.parse(await fs.readFile(autoQueuePath(scope), "utf-8")).items || [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
export async function saveAutoQueue(scope, items) {
|
|
return withFileLock(autoQueuePath(scope), async () => {
|
|
await ensureScopeDir(scope);
|
|
await fs.writeFile(
|
|
autoQueuePath(scope),
|
|
JSON.stringify({ items }, null, 2),
|
|
);
|
|
});
|
|
}
|
|
// Atomic read-modify-write. `fn(items)` may mutate `items` in place and/or
|
|
// return a replacement array. Returns the saved array. Use for every
|
|
// status change / add / remove so concurrent handlers don't lose updates.
|
|
export async function mutateAutoQueue(scope, fn) {
|
|
return withFileLock(autoQueuePath(scope), async () => {
|
|
let items = [];
|
|
try {
|
|
items =
|
|
JSON.parse(await fs.readFile(autoQueuePath(scope), "utf-8")).items || [];
|
|
} catch {}
|
|
const result = await fn(items);
|
|
const toSave = Array.isArray(result) ? result : items;
|
|
await ensureScopeDir(scope);
|
|
await fs.writeFile(
|
|
autoQueuePath(scope),
|
|
JSON.stringify({ items: toSave }, null, 2),
|
|
);
|
|
return toSave;
|
|
});
|
|
}
|
|
|
|
// ── Dedup ────────────────────────────────────────────────────────────────
|
|
// All videoIds already summarized in a scope's library.
|
|
//
|
|
// CRITICAL: summaries live under scopeDir(scope) = history/<scope>/, NOT the
|
|
// top-level history dir. Scanning the top level (the historical bug) found
|
|
// zero processed videos, so the subscription check never deduped against the
|
|
// library and re-queued already-summarized videos every run.
|
|
export async function getProcessedVideoIds(scope = "owner") {
|
|
const ids = new Set();
|
|
const dir = getScopeHistoryDir(scope);
|
|
try {
|
|
const files = await fs.readdir(dir);
|
|
for (const file of files.filter(
|
|
(f) => f.endsWith(".json") && !ROOT_SIDECARS.has(f),
|
|
)) {
|
|
try {
|
|
const raw = await fs.readFile(path.join(dir, file), "utf-8");
|
|
const data = JSON.parse(raw);
|
|
if (data.videoId) ids.add(data.videoId);
|
|
} catch {}
|
|
}
|
|
} catch {}
|
|
return ids;
|
|
}
|
|
|
|
// Pure dedup predicate: is this discovered video already accounted for?
|
|
// Known = in the library (processed), already queued, declined (skip), or
|
|
// offered before (seen). Used identically by the podcast + YouTube branches.
|
|
export function isKnownVideo(
|
|
id,
|
|
{ processedIds, queuedIds, skippedIds, seenIds } = {},
|
|
) {
|
|
return !!(
|
|
(processedIds && processedIds.has(id)) ||
|
|
(queuedIds && queuedIds.has(id)) ||
|
|
(skippedIds && skippedIds.has(id)) ||
|
|
(seenIds && seenIds.has(id))
|
|
);
|
|
}
|
|
|
|
// ── Scope enumeration (for the periodic check loop) ──────────────────────
|
|
// Scopes that have at least one subscription. Always includes "owner" (the
|
|
// operator). Behind the operator-only gate this returns just ["owner"]; when
|
|
// per-tenant subscriptions ship it picks up each tenant scope automatically.
|
|
export async function listSubscriptionScopes() {
|
|
const root = getHistoryDir();
|
|
const scopes = new Set(["owner"]);
|
|
try {
|
|
const entries = await fs.readdir(root, { withFileTypes: true });
|
|
for (const e of entries) {
|
|
if (!e.isDirectory() || e.name === "owner") continue;
|
|
try {
|
|
const subs =
|
|
JSON.parse(
|
|
await fs.readFile(
|
|
path.join(root, e.name, "subscriptions.json"),
|
|
"utf-8",
|
|
),
|
|
).subscriptions || [];
|
|
if (subs.length > 0) scopes.add(e.name);
|
|
} catch {}
|
|
}
|
|
} catch {}
|
|
return [...scopes];
|
|
}
|
|
|
|
// Scopes that have a non-empty auto-queue. The background processor walks
|
|
// these to find approved items across all owners (a scope can have queued
|
|
// items even after its subscriptions were deleted, so this is a superset of
|
|
// listSubscriptionScopes for processing purposes).
|
|
export async function listAutoQueueScopes() {
|
|
const root = getHistoryDir();
|
|
const scopes = new Set(["owner"]);
|
|
try {
|
|
const entries = await fs.readdir(root, { withFileTypes: true });
|
|
for (const e of entries) {
|
|
if (!e.isDirectory() || e.name === "owner") continue;
|
|
try {
|
|
const items =
|
|
JSON.parse(
|
|
await fs.readFile(
|
|
path.join(root, e.name, "auto-queue.json"),
|
|
"utf-8",
|
|
),
|
|
).items || [];
|
|
if (items.length > 0) scopes.add(e.name);
|
|
} catch {}
|
|
}
|
|
} catch {}
|
|
return [...scopes];
|
|
}
|
|
|
|
// ── Migration: history-root globals → owner scope (one-time, idempotent) ──
|
|
// The pre-0.2.147 layout kept subscription state at the history root (one
|
|
// install-wide store). Move it under the operator's own scope so the
|
|
// storage is uniformly per-scope. Only moves a file if the source exists
|
|
// and the destination doesn't (never clobbers).
|
|
const SUB_FILES = [
|
|
"subscriptions.json",
|
|
"auto-queue.json",
|
|
"skip-list.json",
|
|
"seen-list.json",
|
|
];
|
|
export async function migrateGlobalSubscriptionsToOwner() {
|
|
const root = getHistoryDir();
|
|
const ownerDir = getScopeHistoryDir("owner");
|
|
let moved = 0;
|
|
for (const f of SUB_FILES) {
|
|
const from = path.join(root, f);
|
|
const to = path.join(ownerDir, f);
|
|
try {
|
|
await fs.access(from);
|
|
} catch {
|
|
continue; // source missing → nothing to move
|
|
}
|
|
try {
|
|
await fs.access(to);
|
|
continue; // dest already exists → don't clobber
|
|
} catch {}
|
|
try {
|
|
await fs.mkdir(ownerDir, { recursive: true });
|
|
await fs.rename(from, to);
|
|
moved++;
|
|
} catch {
|
|
// rename can fail across devices — fall back to copy + unlink.
|
|
try {
|
|
await fs.mkdir(ownerDir, { recursive: true });
|
|
await fs.copyFile(from, to);
|
|
await fs.unlink(from);
|
|
moved++;
|
|
} catch {}
|
|
}
|
|
}
|
|
return moved;
|
|
}
|