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
10 changes: 10 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,16 @@ venv/
# Build output
dist/

# Tool caches
.cache/
**/.cache/
.bundle-cache/
**/.bundle-cache/
.vite/
.turbo/
.next/
*.tsbuildinfo

# Data directory
data/
.podcli/
Expand Down
61 changes: 61 additions & 0 deletions backend/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -2072,6 +2072,55 @@ def cmd_init_thumbnail(args):
)


def cmd_thumbnail_config(args):
"""Show, export, import, or reset the thumbnail template config.

Resolves the same config path the renderer uses, so the Web UI (which shells
out to this command) and generated thumbnails never disagree on it.
"""
from services.thumbnail_html import _load_config

action = getattr(args, "tc_action", None) or "show"
target = paths["thumbnailConfig"]

if action == "show":
print(json.dumps(_load_config(), indent=2, ensure_ascii=False))
return

if action == "export":
dest = os.path.abspath(os.path.expanduser(args.path))
os.makedirs(os.path.dirname(dest) or ".", exist_ok=True)
# Copy the override verbatim for a clean round-trip; seed from defaults
# when there is none yet.
if os.path.exists(target):
shutil.copyfile(target, dest)
else:
with open(dest, "w", encoding="utf-8") as f:
json.dump(_load_config(), f, indent=2, ensure_ascii=False)
print(f"Exported thumbnail config to {dest}")
return

if action == "import":
src = os.path.abspath(os.path.expanduser(args.path))
with open(src, encoding="utf-8") as f:
data = json.load(f)
if not isinstance(data, dict):
raise ValueError("thumbnail config must be a JSON object")
os.makedirs(os.path.dirname(target) or ".", exist_ok=True)
with open(target, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
print(f"Imported thumbnail config -> {target}")
return

if action == "reset":
if os.path.exists(target):
os.remove(target)
print("Reset thumbnail config — using the generic default template.")
return

raise ValueError(f"unknown thumbnail-config action: {action}")


def cmd_thumbnails(args):
"""Generate thumbnail variations for a title."""
from services.thumbnail_ai import generate_variations
Expand Down Expand Up @@ -3273,6 +3322,16 @@ def main():
thumb.add_argument("--line2", help="Explicit second thumbnail line")
thumb.add_argument("--json", action="store_true", help="Emit JSON {paths:[...]} to stdout")

# ── thumbnail-config ──
tcfg = sub.add_parser("thumbnail-config", help="Show, export, import, or reset the thumbnail template")
tcfg_sub = tcfg.add_subparsers(dest="tc_action")
tcfg_sub.add_parser("show", help="Print the effective thumbnail config (defaults + overrides) as JSON")
tcfg_exp = tcfg_sub.add_parser("export", help="Write the current thumbnail config to a file")
tcfg_exp.add_argument("path", help="Destination .json path")
tcfg_imp = tcfg_sub.add_parser("import", help="Replace the thumbnail config from a file")
tcfg_imp.add_argument("path", help="Source .json path")
tcfg_sub.add_parser("reset", help="Remove the override and revert to the generic default")

# ── swap-thumbnail ──
st = sub.add_parser("swap-thumbnail", help="Regenerate thumbnail on an existing clip")
st.add_argument("clip", help="Path to rendered clip (.mp4)")
Expand Down Expand Up @@ -3412,6 +3471,8 @@ def main():
cmd_studio(args)
elif args.command == "thumbnails":
cmd_thumbnails(args)
elif args.command == "thumbnail-config":
cmd_thumbnail_config(args)
elif args.command == "swap-thumbnail":
cmd_swap_thumbnail(args)
elif args.command == "bake-thumbnail":
Expand Down
3 changes: 2 additions & 1 deletion backend/services/clips_history.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
Entry shape (all fields beyond the core render record are optional):
id, source_video, start_second, end_second, caption_style, crop_strategy,
logo_path?, title, output_path, file_size_mb, duration, created_at,
content_type?, transcript_slice?, youtube_video_id?, metrics?
content_type?, transcript_slice?, youtube_video_id?, metrics?,
generated_titles?, description?, tags?, hashtags?

metrics? = {views?, retention?, ctr?, impressions?, fetched_at?} (Phase 2)

Expand Down
66 changes: 54 additions & 12 deletions cli/internal/update/update.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
// Package update checks GitHub Releases for a newer podcli and (once releases
// publish per-platform binaries) applies it. For now a manual update points the
// user at their package manager, matching the npm/bun reinstall fallback.
// Package update checks GitHub Releases for a newer podcli and applies the
// release binary for this platform.
package update

import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
Expand All @@ -22,15 +22,35 @@ import (

const repo = "nmbrthirteen/podcli"

type updatePhase string

const (
phaseDownload updatePhase = "download"
phaseVerify updatePhase = "verify"
phaseInstall updatePhase = "install"
)

type phaseError struct {
phase updatePhase
err error
}

func (e *phaseError) Error() string {
return e.err.Error()
}

func (e *phaseError) Unwrap() error {
return e.err
}

func exeExt() string {
if runtime.GOOS == "windows" {
return ".exe"
}
return ""
}

// managedBin is the binary the npm shim and direct installs both exec, so
// replacing it updates podcli regardless of how it was installed.
// managedBin is the binary direct installs exec, so replacing it updates podcli.
func managedBin() string {
return filepath.Join(paths.BinDir(), "podcli"+exeExt())
}
Expand Down Expand Up @@ -139,35 +159,57 @@ func Run(current string) int {
}
fmt.Printf("Updating podcli %s → %s ...\n", current, tag)
if err := apply(tag); err != nil {
fmt.Fprintf(os.Stderr, "podcli: self-update failed (%v).\n", err)
fmt.Fprintln(os.Stderr, "Reinstall via your package manager: npm i -g podcli (or: bun add -g podcli)")
printSelfUpdateFailure(os.Stderr, err)
return 1
}
fmt.Printf("Updated to podcli %s.\n", tag)
return 0
}

func printSelfUpdateFailure(w io.Writer, err error) {
fmt.Fprintf(w, "podcli: self-update failed (%v).\n", err)
fmt.Fprintln(w, "Your installed podcli was left unchanged.")

if phaseOf(err) == phaseDownload {
fmt.Fprintln(w, "Download failed. Check your network connection, then run `podcli update` again.")
return
}

fmt.Fprintln(w, "Run `podcli update` again. If it keeps failing, install the latest release binary manually.")
}

func phaseOf(err error) updatePhase {
var pe *phaseError
if errors.As(err, &pe) {
return pe.phase
}
return ""
}

// apply downloads the release binary for this platform and swaps the managed
// binary atomically.
func apply(tag string) error {
dest := managedBin()
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
return err
return &phaseError{phase: phaseInstall, err: err}
}
staged := dest + ".new"
if err := downloadFile(assetURL(tag), staged); err != nil {
return err
return &phaseError{phase: phaseDownload, err: err}
}
if err := verifyStaged(tag, staged); err != nil {
os.Remove(staged)
return err
return &phaseError{phase: phaseVerify, err: err}
}
if runtime.GOOS != "windows" {
if err := os.Chmod(staged, 0o755); err != nil {
return err
return &phaseError{phase: phaseInstall, err: err}
}
}
return swap(staged, dest)
if err := swap(staged, dest); err != nil {
return &phaseError{phase: phaseInstall, err: err}
}
return nil
}

// verifyStaged checks the downloaded binary against the release's checksums.txt.
Expand Down
31 changes: 31 additions & 0 deletions cli/internal/update/update_test.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
package update

import (
"bytes"
"errors"
"os"
"path/filepath"
"strings"
"testing"
)

Expand Down Expand Up @@ -43,3 +46,31 @@ func TestNewer(t *testing.T) {
}
}
}

func TestSelfUpdateFailureOmitsPackageManagerFallback(t *testing.T) {
var out bytes.Buffer
printSelfUpdateFailure(&out, errors.New("boom"))

got := out.String()
for _, unwanted := range []string{"npm", "bun", "package manager"} {
if strings.Contains(got, unwanted) {
t.Fatalf("failure output contains %q:\n%s", unwanted, got)
}
}
if !strings.Contains(got, "Your installed podcli was left unchanged.") {
t.Fatalf("failure output should say the install is unchanged:\n%s", got)
}
}

func TestDownloadFailureSuggestsRetry(t *testing.T) {
var out bytes.Buffer
printSelfUpdateFailure(&out, &phaseError{phase: phaseDownload, err: errors.New("network down")})

got := out.String()
if !strings.Contains(got, "Download failed.") {
t.Fatalf("download failure output should identify the download failure:\n%s", got)
}
if strings.Contains(got, "latest release binary manually") {
t.Fatalf("download failure output should not suggest manual install first:\n%s", got)
}
}
6 changes: 6 additions & 0 deletions src/models/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,12 @@ export interface ClipHistoryEntry {
thumbnail_config?: ClipThumbnailConfig;
youtube_video_id?: string;
metrics?: ClipPerformanceMetrics;
// AI-generated publishing metadata (titles/description/tags/hashtags), persisted
// so it survives a page reload instead of vanishing after generation.
generated_titles?: string[];
description?: string;
tags?: string;
hashtags?: string;
}

// === Knowledge Base Models ===
Expand Down
47 changes: 47 additions & 0 deletions src/server.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { readFileSync, writeFileSync } from "fs";

import {
transcribeToolDef,
Expand Down Expand Up @@ -1891,6 +1892,52 @@ export function createServer(): McpServer {
},
);

// =============================================
// Tool: manage_thumbnail_config
// =============================================
server.tool(
"manage_thumbnail_config",
"Show, export, import, or reset the thumbnail template (colors, fonts, frame, box, layout) podcli uses to generate thumbnails. 'show' returns the effective config; 'export' writes it to a file path; 'import' replaces it from a file path; 'reset' reverts to the generic default.",
{
action: z.enum(["show", "export", "import", "reset"]).describe("Config action"),
path: z.string().optional().describe("File path for export (destination) or import (source)"),
},
async ({ action, path: filePath }) => {
try {
if ((action === "export" || action === "import") && !filePath) {
return mcpError(`'path' is required for action '${action}'.`);
}
const base = "http://localhost:3847/api/thumbnail-config";
if (action === "show") {
const res = await fetch(base);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return { content: [{ type: "text" as const, text: JSON.stringify(await res.json(), null, 2) }] };
}
if (action === "export") {
const res = await fetch(base);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
writeFileSync(filePath!, JSON.stringify(await res.json(), null, 2), "utf-8");
return { content: [{ type: "text" as const, text: `Exported thumbnail config to ${filePath}` }] };
}
if (action === "import") {
const parsed = JSON.parse(readFileSync(filePath!, "utf-8"));
const res = await fetch(base, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(parsed) });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return { content: [{ type: "text" as const, text: `Imported thumbnail config from ${filePath}` }] };
}
const res = await fetch(`${base}/reset`, { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return { content: [{ type: "text" as const, text: "Reset thumbnail config to the generic default." }] };
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
if (msg.includes("ECONNREFUSED") || msg.includes("fetch failed")) {
return { content: [{ type: "text" as const, text: "Web UI is not running. Start with: npm run ui" }] };
}
return mcpError(msg);
}
},
);

// =============================================
// Tool: analyze_energy
// =============================================
Expand Down
Loading
Loading