Initial commit: PodcastYes — AI podcast platform

This commit is contained in:
Leon Serfaty
2026-06-07 03:58:32 -04:00
commit 155507f21a
151 changed files with 19826 additions and 0 deletions
+20
View File
@@ -0,0 +1,20 @@
import { LocalStorageProvider } from "./local";
import type { StorageProvider } from "./types";
export * from "./types";
// Registry: today only local disk. To add S3/R2 later, implement StorageProvider
// in lib/storage/s3.ts and switch on an env flag here — no call-site changes.
let provider: StorageProvider | null = null;
export function storage(): StorageProvider {
if (!provider) provider = new LocalStorageProvider();
return provider;
}
/** Convenience for the worker / asset route which need the on-disk path. */
export function localStorage(): LocalStorageProvider {
const s = storage();
if (s instanceof LocalStorageProvider) return s;
throw new Error("Local filesystem path requested but active storage is not local");
}
+69
View File
@@ -0,0 +1,69 @@
import { promises as fs } from "node:fs";
import path from "node:path";
import type { StorageProvider } from "./types";
const STORAGE_DIR = path.resolve(process.env.STORAGE_DIR ?? "./storage");
const MEDIA_BASE = process.env.MEDIA_PUBLIC_BASE_URL ?? "/media";
// Cover art is the only asset class served publicly by nginx from /media.
const PUBLIC_PREFIXES = ["art/"];
function resolveSafe(key: string): string {
// Prevent path traversal: the resolved path must stay inside STORAGE_DIR.
const full = path.resolve(STORAGE_DIR, key);
if (full !== STORAGE_DIR && !full.startsWith(STORAGE_DIR + path.sep)) {
throw new Error(`Invalid storage key: ${key}`);
}
return full;
}
/** Local-disk storage for the single-VPS Plesk deployment. */
export class LocalStorageProvider implements StorageProvider {
async put(key: string, data: Buffer | Uint8Array): Promise<void> {
const full = resolveSafe(key);
await fs.mkdir(path.dirname(full), { recursive: true });
await fs.writeFile(full, data);
}
async get(key: string): Promise<Buffer> {
return fs.readFile(resolveSafe(key));
}
async exists(key: string): Promise<boolean> {
try {
await fs.access(resolveSafe(key));
return true;
} catch {
return false;
}
}
async delete(key: string): Promise<void> {
try {
await fs.unlink(resolveSafe(key));
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
}
}
async size(key: string): Promise<number | null> {
try {
const stat = await fs.stat(resolveSafe(key));
return stat.size;
} catch {
return null;
}
}
publicUrl(key: string): string | null {
if (PUBLIC_PREFIXES.some((p) => key.startsWith(p))) {
return `${MEDIA_BASE}/${key}`;
}
return null;
}
/** Absolute filesystem path for a key — used by the worker (ffmpeg) and the asset route. */
absolutePath(key: string): string {
return resolveSafe(key);
}
}
+31
View File
@@ -0,0 +1,31 @@
/**
* Storage abstraction. The app only ever references a relative `key`
* (e.g. "mp3/<episodeId>.mp3" or "art/<episodeId>.png"); the provider resolves
* where it actually lives. Swapping local disk for S3/R2 later means writing a
* new provider, with no changes at the call sites or in the database.
*/
export interface StorageProvider {
/** Write bytes at `key`, creating parent "directories" as needed. */
put(key: string, data: Buffer | Uint8Array, contentType?: string): Promise<void>;
/** Read the full object as a Buffer. */
get(key: string): Promise<Buffer>;
/** Whether an object exists at `key`. */
exists(key: string): Promise<boolean>;
/** Remove the object (no-op if missing). */
delete(key: string): Promise<void>;
/** Size in bytes, or null if missing. */
size(key: string): Promise<number | null>;
/**
* A directly-fetchable URL for *public* assets (e.g. cover art served by nginx
* from /media). Returns null for providers/keys that must go through the
* authenticated asset route (e.g. private MP3s).
*/
publicUrl(key: string): string | null;
}
export type AssetKind = "mp3" | "art" | "exports" | "tmp";
/** Build a conventional storage key for an asset. */
export function assetKey(kind: AssetKind, name: string): string {
return `${kind}/${name}`;
}