Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,19 @@ jobs:
- run: npm test
- run: npm run build

docs-drift:
name: Docs drift check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: "20"
- run: node scripts/gen-docs-manifest.mjs
- run: node scripts/check-docs-drift.mjs

python:
name: Python tests
runs-on: ubuntu-latest
Expand Down
10 changes: 10 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,8 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: write
env:
LANDING_DISPATCH_TOKEN: ${{ secrets.LANDING_DISPATCH_TOKEN }}
steps:
- uses: actions/download-artifact@v4
with:
Expand All @@ -156,3 +158,11 @@ jobs:
with:
files: dist/*
generate_release_notes: true
- name: Rebuild the landing site so the changelog picks up this release
if: ${{ env.LANDING_DISPATCH_TOKEN != '' }}
run: |
curl -sf -X POST \
-H "Authorization: Bearer $LANDING_DISPATCH_TOKEN" \
-H "Accept: application/vnd.github+json" \
https://api.github.com/repos/nmbrthirteen/podcli-landing/dispatches \
-d '{"event_type":"release"}'
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,6 @@ cli/internal/backend/files/
cli/internal/podstack/commands/
cli/podcli
podcli-clips/

# generated by scripts/gen-docs-manifest.mjs
docs-manifest.json
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

<p align="center">
<a href="https://podcli.com"><strong>podcli.com</strong></a> ·
<a href="https://podcli.com/docs">Docs</a> ·
<a href="#quick-start">Quick start</a> ·
<a href="#mcp-server-claude-integration">MCP</a> ·
<a href="#features">Features</a>
Expand All @@ -32,6 +33,8 @@ podcli process episode.mp4

One command transcribes, picks the best moments, crops to the face, and burns captions in. Nothing leaves your machine.

Full documentation lives at [podcli.com/docs](https://podcli.com/docs). The docs are open source, edit them at [nmbrthirteen/podcli-docs](https://github.com/nmbrthirteen/podcli-docs).

---

## What It Does
Expand Down Expand Up @@ -148,8 +151,8 @@ Both halves share the same **knowledge base** (`.podcli/knowledge/`) — your sh
- **Clip history** — tracks everything to avoid duplicates
- **Preset system** — save named configurations per show
- **Content studio** — generate titles, descriptions, tags, and hashtags in the web UI; regenerate any section with your own guidance, or ask for anything custom
- **MCP server** — 17 tools for Claude Desktop / Claude Code integration
- **Web UI** — single-page flow at `localhost:3847`
- **MCP server** — 22 tools for Claude Desktop / Claude Code integration
- **Web UI** — multi-page studio at `localhost:3847`
- **CLI** — one-command processing: `podcli process episode.mp4`

---
Expand Down
43 changes: 43 additions & 0 deletions scripts/check-docs-drift.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { readFileSync } from "fs";
import { join, dirname } from "path";
import { fileURLToPath } from "url";

const root = join(dirname(fileURLToPath(import.meta.url)), "..");
const read = (p) => readFileSync(join(root, p), "utf8");

let manifest;
try {
manifest = JSON.parse(read("docs-manifest.json"));
} catch {
console.error("docs-manifest.json not found. Run: node scripts/gen-docs-manifest.mjs");
process.exit(1);
}

const readme = read("README.md");
const errors = [];
const warnings = [];

if (/single-page/i.test(readme) && manifest.studioRouteCount > 1) {
errors.push(`README calls the studio "single-page" but it has ${manifest.studioRouteCount} routes.`);
}

const toolClaims = [...new Set([...readme.matchAll(/(\d+)\s+(?:MCP\s+)?tools/gi)].map((m) => Number(m[1])))];
for (const claim of toolClaims) {
if (claim !== manifest.mcpToolCount) {
warnings.push(`README mentions "${claim} tools" but src registers ${manifest.mcpToolCount} (server.tool calls).`);
}
}

if (manifest.packageVersion && manifest.version && !manifest.version.endsWith(manifest.packageVersion)) {
warnings.push(`package.json version ${manifest.packageVersion} lags the latest tag ${manifest.version}.`);
}

for (const w of warnings) console.warn("warning:", w);

if (errors.length) {
console.error("Docs drift detected:");
for (const e of errors) console.error(" -", e);
process.exit(1);
}

console.log(`Docs in sync: ${manifest.mcpToolCount} tools, ${manifest.studioRouteCount} studio routes, ${manifest.version}.`);
40 changes: 40 additions & 0 deletions scripts/gen-docs-manifest.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { readFileSync, writeFileSync, readdirSync } from "fs";
import { execSync } from "child_process";
import { join, dirname } from "path";
import { fileURLToPath } from "url";

const root = join(dirname(fileURLToPath(import.meta.url)), "..");
const read = (p) => readFileSync(join(root, p), "utf8");

function countTools(dir) {
let n = 0;
for (const entry of readdirSync(join(root, dir), { withFileTypes: true })) {
const rel = join(dir, entry.name);
if (entry.isDirectory()) n += countTools(rel);
else if (entry.name.endsWith(".ts")) n += (read(rel).match(/server\.tool\(/g) || []).length;
}
return n;
}

const mainTsx = read("src/ui/client/main.tsx");
const studioRoutes = [...mainTsx.matchAll(/<Route\s+path="([^"]+)"\s+element=\{<(\w+)/g)]
.filter((m) => !/Navigate/.test(m[0]))
.map((m) => ({ path: m[1], component: m[2] }));

let version = "";
try {
version = execSync("git describe --tags --abbrev=0", { cwd: root }).toString().trim();
} catch {
version = JSON.parse(read("package.json")).version || "";
}

const manifest = {
version,
packageVersion: JSON.parse(read("package.json")).version || "",
mcpToolCount: countTools("src"),
studioRouteCount: studioRoutes.length,
studioRoutes,
};

writeFileSync(join(root, "docs-manifest.json"), JSON.stringify(manifest, null, 2) + "\n");
console.log("wrote docs-manifest.json:", JSON.stringify(manifest));
5 changes: 5 additions & 0 deletions src/services/clips-history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { basename, join } from "path";
import { v4 as uuidv4 } from "uuid";
import { paths } from "../config/paths.js";
import { sliceTranscript } from "../utils/transcript.js";
import { isDemoMode, demoClips } from "../ui/demo-fixtures.js";
import type { BatchClipsResult, ClipHistoryEntry, Format, WordTimestamp } from "../models/index.js";

type BatchResultRow = BatchClipsResult["results"][number];
Expand Down Expand Up @@ -31,6 +32,7 @@ export class ClipsHistory {
}

async load(): Promise<ClipHistoryEntry[]> {
if (isDemoMode()) return demoClips();
try {
if (!existsSync(this.historyPath)) return [];
const raw = await readFile(this.historyPath, "utf-8");
Expand All @@ -41,6 +43,7 @@ export class ClipsHistory {
}

private async save(entries: ClipHistoryEntry[]): Promise<void> {
if (isDemoMode()) return; // demo fixtures are read-only; never persist to clips.json
await this.ensureDir();
// Write to a temp file and atomically rename so a crash or a concurrent
// reader never sees a half-written clips.json.
Expand Down Expand Up @@ -181,6 +184,8 @@ export class ClipsHistory {
async remove(idOrPrefix: string): Promise<ClipHistoryEntry | null> {
const id = await this.resolveId(idOrPrefix);
if (!id) return null;
// Demo entries are read-only fixtures — never delete their (shipped) artifacts.
if (isDemoMode()) return (await this.findById(id)) ?? null;
const entry = await this.mutate((entries) => {
const idx = entries.findIndex((e) => e.id === id);
if (idx < 0) return null;
Expand Down
3 changes: 2 additions & 1 deletion src/ui/client/ClipDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ export default function ClipDetail() {
const tc = clip.thumbnail_config || {};
const dirty = title !== clip.title || captionStyle !== clip.caption_style;
const previewUrl = `/api/clips/${clip.id}/preview?t=${bust}`;
const posterUrl = tc.preview_path ? img(tc.preview_path, bust) : undefined;

const patch = (body: any) => api(`/clips/${clip.id}`, { method: "PATCH", body: JSON.stringify(body) });

Expand Down Expand Up @@ -231,7 +232,7 @@ export default function ClipDetail() {

<div className="clip-detail">
<div className="clip-detail-player">
{clip.output_path ? <ClipPlayer key={previewUrl} src={previewUrl} onTime={(t) => (playerTime.current = t)} /> : <div className="phone-empty">No rendered output</div>}
{clip.output_path ? <ClipPlayer key={previewUrl} src={previewUrl} poster={posterUrl} onTime={(t) => (playerTime.current = t)} /> : <div className="phone-empty">No rendered output</div>}
<button className="btn btn-ghost btn-sm" style={{ width: "100%", marginTop: 10 }} onClick={() => setReframing(true)}>Reframe (fix camera)</button>
<div className="clip-meta">
<span>{fmt(clip.start_second)}-{fmt(clip.end_second)} · {clip.duration}s</span>
Expand Down
3 changes: 2 additions & 1 deletion src/ui/client/ClipPlayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { PlayIcon, PauseIcon } from "./icons";

const fmt = (s: number) => (Number.isFinite(s) ? fmtTime(s) : "0:00");

export default function ClipPlayer({ src, onTime }: { src: string; onTime?: (t: number) => void }) {
export default function ClipPlayer({ src, poster, onTime }: { src: string; poster?: string; onTime?: (t: number) => void }) {
const ref = useRef<HTMLVideoElement>(null);
const [playing, setPlaying] = useState(false);
const [t, setT] = useState(0);
Expand Down Expand Up @@ -36,6 +36,7 @@ export default function ClipPlayer({ src, onTime }: { src: string; onTime?: (t:
<video
ref={ref}
src={src}
poster={poster}
playsInline
onClick={toggle}
onPlay={() => setPlaying(true)}
Expand Down
Binary file added src/ui/demo-assets/demo-deep-dive-1.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/ui/demo-assets/demo-deep-dive-2.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/ui/demo-assets/demo-long-story-1.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/ui/demo-assets/demo-long-story-2.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/ui/demo-assets/demo-tech-daily-1.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/ui/demo-assets/demo-tech-daily-2.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/ui/demo-assets/demo-the-founder-1.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/ui/demo-assets/demo-the-founder-2.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
51 changes: 51 additions & 0 deletions src/ui/demo-fixtures.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { describe, it, expect, afterEach } from "vitest";
import { existsSync } from "fs";
import { demoClips, DEMO_ASSETS_DIR } from "./demo-fixtures.js";

describe("demo fixtures", () => {
it("exposes clips that light up the library and analytics", () => {
const clips = demoClips();
expect(clips.length).toBeGreaterThanOrEqual(6);
for (const c of clips) {
expect(c.id).toBeTruthy();
expect(c.title).toBeTruthy();
expect(c.thumbnail_config?.preview_path).toContain("demo-assets");
// Analytics counts "published" only from clips with view/retention metrics.
expect(c.metrics?.views).toBeGreaterThan(0);
expect(c.metrics?.retention).toBeGreaterThan(0);
}
});

it("groups into more than one episode", () => {
const sources = new Set(demoClips().map((c) => c.source_video));
expect(sources.size).toBeGreaterThan(1);
});

it("ships a thumbnail png for every clip", () => {
for (const c of demoClips()) {
expect(existsSync(c.thumbnail_config!.preview_path!)).toBe(true);
}
expect(existsSync(DEMO_ASSETS_DIR)).toBe(true);
});
});

describe("ClipsHistory in demo mode", () => {
afterEach(() => { delete process.env.PODCLI_DEMO; });

it("serves fixtures and never persists writes", async () => {
const { ClipsHistory } = await import("../services/clips-history.js");
process.env.PODCLI_DEMO = "1";
const history = new ClipsHistory();

const listed = await history.list(500);
expect(listed.length).toBe(demoClips().length);

const first = listed[0];
// A remove must not throw or delete shipped artifacts; it just echoes the entry.
const removed = await history.remove(first.id);
expect(removed?.id).toBe(first.id);
expect(existsSync(first.thumbnail_config!.preview_path!)).toBe(true);
// Still all there — the delete was a no-op against read-only fixtures.
expect((await history.list(500)).length).toBe(demoClips().length);
});
});
Loading
Loading