diff --git a/.gitignore b/.gitignore index 7f3afdd..c87914f 100644 --- a/.gitignore +++ b/.gitignore @@ -47,6 +47,9 @@ tmp*.txt # Episodes (generated content packages) episodes/ +# Planning notes +plans/ + # Media & temp files *.mp4 *.mp3 diff --git a/backend/cli.py b/backend/cli.py index dc9f67d..27dd014 100644 --- a/backend/cli.py +++ b/backend/cli.py @@ -920,15 +920,15 @@ def _transcribe_progress(pct, msg): print(" ⚠ No highlights found, falling back to transcript selection") # Try an AI CLI first (uses PodStack knowledge base for intelligent selection) - from services.claude_suggest import ( - suggest_initial_with_claude, blend_signal_scores, _engine_label, _find_ai_cli, - ) + from services import ai_provider + from services.ai_cli import _engine_label + from services.claude_suggest import blend_signal_scores, suggest_initial_with_claude - ai_path, ai_engine = _find_ai_cli() + providers = ai_provider.status()["providers"] if clips: pass # already selected (resumed cache or saliency profile) - elif ai_path and config.get("ai_select", True): - ai_label = _engine_label(ai_engine) + elif providers and config.get("ai_select", True): + ai_label = providers[0]["label"] print(f" [3/4] Selecting moments with {ai_label} (PodStack)...") clips = suggest_initial_with_claude( segments=segments, @@ -938,8 +938,9 @@ def _transcribe_progress(pct, msg): ) if clips: blend_signal_scores(clips, energy_data=energy_data, events_data=events_data) - actual_engine = next((c.get("_ai_engine") for c in clips if c.get("_ai_engine")), ai_engine) - print(f" ✓ {_engine_label(actual_engine)} selected {len(clips)} clips") + engine_id = next((c.get("_ai_engine") for c in clips if c.get("_ai_engine")), "") + actual_engine = _engine_label(engine_id) if engine_id in ("claude", "codex") else ai_label + print(f" ✓ {actual_engine} selected {len(clips)} clips") _save_suggestions_session(cache_hash, top_n, actual_engine, clips, selection_sig) else: print(" ⚠ AI CLI unavailable, falling back to heuristics") @@ -1006,9 +1007,9 @@ def _transcribe_progress(pct, msg): pass _thumb_intro_duration = max(0.5, min(_thumb_intro_duration, 1.0)) - # Check if AI CLI is available for per-clip content generation - from services.claude_suggest import _find_ai_cli - _ai_cli_path, _ = _find_ai_cli() + # Per-clip content generation needs any provider, not specifically a binary. + from services import ai_provider + _ai_cli_path = "cloud" if ai_provider.available() else None # Pre-load thumbnail tools if enabled _thumb_gen = None @@ -3491,9 +3492,15 @@ def print_banner(): _diarization_ok = False speakers_ok = bool(hf_token) and _diarization_ok - # Check AI CLI (Claude Code or Codex) - from services.claude_suggest import _find_ai_cli - ai_path, ai_engine = _find_ai_cli() + # `info` should report what AI podcli will actually use, which for a + # signed-in user is the workspace rather than any local binary. + from services import ai_provider + # Every other lookup in this banner is guarded. This one reads and parses + # the local auth file, so a truncated one would take `podcli info` with it. + try: + _providers = ai_provider.status()["providers"] + except Exception: + _providers = [] print(f" {bold}podcli{reset} v{VERSION}") @@ -3504,8 +3511,8 @@ def print_banner(): cache_count = len([f for f in os.listdir(cache_dir) if f.endswith(".json")]) # Status — one line - ai_label = ("Claude" if ai_engine == "claude" else "Codex") if ai_path else "AI CLI" - ai_tag = f"{green}✓ {ai_label}{reset}" if ai_path else f"{yellow}✗{reset}" + ai_label = _providers[0]["label"] if _providers else "AI" + ai_tag = f"{green}✓ {ai_label}{reset}" if _providers else f"{yellow}✗{reset}" speaker_tag = f"{green}✓{reset}" if speakers_ok else f"{yellow}✗{reset}" cache_tag = f"{green}{cache_count}{reset}" if cache_count else f"{gray}0{reset}" kb_tag = f"{green}{kb_count}{reset}" if kb_count else f"{yellow}0{reset}" @@ -3633,6 +3640,132 @@ def print_help(): print() +def cmd_login(args): + import getpass + from services import podcli_cloud + + email = (args.email or input("Email: ")).strip() + # Prefer the prompt: a password in argv is visible in ps output and lands in + # the user's shell history. + password = args.password or getpass.getpass("Password: ") + if not email or not password: + print("Email and password are required.") + sys.exit(1) + + try: + podcli_cloud.login(email, password) + account = podcli_cloud.me() + podcli_cloud.remember_plan(account.get("plan", "")) + except podcli_cloud.CloudError as exc: + print(f"Sign-in failed: {exc}") + sys.exit(1) + + workspace = account.get("workspace") or {} + print(f"Signed in to {workspace.get('name', 'your workspace')} " + f"({account.get('plan', 'free')} plan, {account.get('role', 'member')}).") + if account.get("plan") == "free": + print("This workspace has no active subscription — podcli will keep using " + "your local AI CLI until one starts.") + + # Everything already rendered on this machine belongs in the workspace too, + # so the performance model starts with a back catalogue instead of nothing. + try: + synced, failed = podcli_cloud.backfill_clips() + except Exception: + synced, failed = 0, 0 + if synced: + print(f"Synced {synced} existing clip{'s' if synced != 1 else ''} to your workspace.") + if failed: + print(f"{failed} could not be synced — `podcli whoami` will retry later.") + + +def cmd_logout(args): + from services import podcli_cloud + + if not podcli_cloud.signed_in(): + print("Not signed in.") + return + podcli_cloud.clear_token() + print("Signed out. podcli will use your local AI CLI from now on.") + + +def cmd_whoami(args): + from services import ai_provider, podcli_cloud + + if not podcli_cloud.signed_in(): + print("Not signed in to podcli Pro. Run `podcli login`.") + else: + try: + account = podcli_cloud.me() + podcli_cloud.remember_plan(account.get("plan", "")) + workspace = account.get("workspace") or {} + print(f"Signed in to {workspace.get('name', '?')} " + f"({account.get('plan')} plan, {account.get('role')})") + used = workspace.get("episodes_used") + if used is not None: + print(f"Episodes used this month: {used}") + except podcli_cloud.CloudError as exc: + print(f"Signed in, but the account could not be checked: {exc}") + + providers = ai_provider.status()["providers"] + if providers: + print("AI will use: " + " → ".join(p["label"] for p in providers)) + else: + print("No AI available. Install Claude Code, set ANTHROPIC_API_KEY, or sign in.") + + +def cmd_workspace(args): + from services import podcli_cloud + + if not podcli_cloud.signed_in(): + print("Not signed in to podcli Pro. Run `podcli login`.") + sys.exit(1) + + action = getattr(args, "workspace_action", None) or "list" + try: + if action == "new": + created = podcli_cloud.create_workspace(args.name) + print(f"Created {created['name']} and switched to it (free plan).") + print("Each show carries its own subscription, so this one needs its own.") + _warn_local_data() + return + + workspaces = podcli_cloud.list_workspaces() + + if action == "use": + target = next( + (w for w in workspaces + if args.name.lower() in (w["name"].lower(), w["id"].lower())), + None, + ) + if not target: + print(f"No workspace matching {args.name!r}.") + sys.exit(1) + switched = podcli_cloud.switch_workspace(target["id"]) + print(f"Switched to {switched['name']} ({switched['plan']} plan).") + _warn_local_data() + return + + for w in workspaces: + marker = "*" if w.get("current") else " " + print(f" {marker} {w['name']} ({w['plan']}, {w['role']})") + except podcli_cloud.CloudError as exc: + print(f"Could not reach podcli Pro: {exc}") + sys.exit(1) + + +def _warn_local_data(): + """Switching workspace does not move the local knowledge base or assets. + + Those live in .podcli/ on this machine, and a second show's brand voice + overwriting the first is data loss rather than a sync. Keeping each show in + its own directory (or PODCLI_HOME) is the honest answer until profiles do it + automatically. + """ + print() + print(" Local .podcli/ data is per-directory, not per-workspace.") + print(" Work on each show from its own folder so their knowledge bases " + "and assets stay separate.") def _onboarding_marker() -> str: return os.path.join(paths["home"], ".onboarded") @@ -3824,6 +3957,20 @@ def main(): parser.add_argument("--no-banner", action="store_true", help=argparse.SUPPRESS) sub = parser.add_subparsers(dest="command") + # ── podcli Pro account ── + login_p = sub.add_parser("login", help="Sign in to podcli Pro") + login_p.add_argument("--email", help="Account email (prompted if omitted)") + login_p.add_argument("--password", help="Password (prompted if omitted; prefer the prompt)") + sub.add_parser("logout", help="Sign out of podcli Pro on this machine") + sub.add_parser("whoami", help="Show the signed-in podcli Pro account") + ws_p = sub.add_parser("workspace", help="Switch between shows in podcli Pro") + ws_sub = ws_p.add_subparsers(dest="workspace_action") + ws_sub.add_parser("list", help="List your workspaces") + ws_new = ws_sub.add_parser("new", help="Create a workspace for another show") + ws_new.add_argument("name", help="Workspace name") + ws_use = ws_sub.add_parser("use", help="Switch to a workspace") + ws_use.add_argument("name", help="Workspace name or id") + # ── process ── proc = sub.add_parser("process", help="Process a video into clips") proc.add_argument("video", nargs="?", default=None, help="Path to podcast video file (optional if preset has video_path)") @@ -4160,7 +4307,15 @@ def main(): print(" Setup cancelled. Your command did not run.", file=sys.stderr) sys.exit(130) - if args.command == "process": + if args.command == "login": + cmd_login(args) + elif args.command == "logout": + cmd_logout(args) + elif args.command == "whoami": + cmd_whoami(args) + elif args.command == "workspace": + cmd_workspace(args) + elif args.command == "process": if not getattr(args, "no_banner", False): print() cmd_process(args) diff --git a/backend/main.py b/backend/main.py index bb52b4e..6368b43 100644 --- a/backend/main.py +++ b/backend/main.py @@ -676,8 +676,8 @@ def _signal_profiles_for_suggest( def handle_suggest_clips(task_id: str, params: dict): """AI-powered clip suggestion using Claude/Codex and PodStack knowledge base.""" + from services import ai_provider from services.claude_suggest import ( - _find_ai_cli_candidates, select_clips_with_signal_scores, suggest_initial_with_claude, ) @@ -690,13 +690,15 @@ def handle_suggest_clips(task_id: str, params: dict): emit_result(task_id, "error", error="segments is required") return - if not _find_ai_cli_candidates(): + # Gate on the provider chain, not on a local binary: a signed-in Pro user + # has AI available without installing anything. + if not ai_provider.available(): emit_result( task_id, "error", error=( - "No AI CLI available (install Claude Code or Codex). " - "If already installed, set the path in Config → AI CLI or PODCLI_CLAUDE_PATH." + "No AI available. Sign in with `podcli login`, install Claude Code " + "or Codex, or set ANTHROPIC_API_KEY." ), ) return @@ -740,11 +742,19 @@ def handle_manage_env(task_id: str, params: dict): def handle_ai_cli_status(task_id: str, params: dict): - from services.claude_suggest import get_ai_cli_status + from services.ai_cli import get_ai_cli_status emit_result(task_id, "success", data=get_ai_cli_status()) +def handle_ai_provider_status(task_id: str, params: dict): + """Everything podcli can use for AI, not just local binaries — so the studio + can tell "nothing installed" apart from "signed in, nothing needed".""" + from services import ai_provider + + emit_result(task_id, "success", data=ai_provider.status()) + + def handle_find_moment(task_id: str, params: dict): """Locate user-pasted/described moments in the transcript via the AI CLI.""" from services.claude_suggest import find_moments_from_text @@ -774,7 +784,7 @@ def handle_find_moment(task_id: str, params: dict): def handle_generate_content(task_id: str, params: dict): """Generate titles, descriptions, tags for a clip using PodStack knowledge base.""" from services.content_generator import generate_clip_content - from services.claude_suggest import _find_ai_cli_candidates + from services import ai_provider clip = params.get("clip", {}) transcript_segments = params.get("transcript_segments", []) @@ -783,13 +793,15 @@ def handle_generate_content(task_id: str, params: dict): emit_result(task_id, "error", error="clip is required") return - if not _find_ai_cli_candidates(): + # Gate on the provider chain, not on a local binary: a signed-in Pro user + # has AI available without installing anything. + if not ai_provider.available(): emit_result( task_id, "error", error=( - "No AI CLI available (install Claude Code or Codex). " - "If already installed, set the path in Config → AI CLI or PODCLI_CLAUDE_PATH." + "No AI available. Sign in with `podcli login`, install Claude Code " + "or Codex, or set ANTHROPIC_API_KEY." ), ) return @@ -808,7 +820,7 @@ def handle_generate_content(task_id: str, params: dict): emit_result( task_id, "error", - error="AI CLI found but content generation failed — check claude/codex login and try again", + error="Content generation failed — check that your AI provider is reachable and try again", ) return @@ -832,7 +844,10 @@ def handle_generate_custom(task_id: str, params: dict): ) if result is None: - emit_result(task_id, "error", error="No AI CLI available (install Claude Code or Codex)") + # The gate above admits a workspace session or an API key as well as a + # local binary, so naming only the binary sends cloud users to install + # something they will never use. + emit_result(task_id, "error", error="No AI provider available — sign in to podcli Pro, install Claude Code or Codex, or set ANTHROPIC_API_KEY") return emit_result(task_id, "success", data=result) @@ -927,6 +942,7 @@ def handle_run_integration_tool(task_id: str, params: dict): "find_moment": handle_find_moment, "manage_env": handle_manage_env, "ai_cli_status": handle_ai_cli_status, + "ai_provider_status": handle_ai_provider_status, "generate_content": handle_generate_content, "generate_custom": handle_generate_custom, "manage_integrations": handle_manage_integrations, diff --git a/backend/services/ai_cli.py b/backend/services/ai_cli.py new file mode 100644 index 0000000..6d8daf0 --- /dev/null +++ b/backend/services/ai_cli.py @@ -0,0 +1,497 @@ +"""Discovery and invocation of the user's local AI CLI (Claude Code or Codex). + +Finding the binary is genuinely hard: npm prefixes, version managers, shell +aliases and platform extensions all move it. That search lives here so the +provider layer above can treat "run this prompt" as one call. +""" + +import os +import subprocess +import sys +from functools import lru_cache +from typing import Optional + +def _cli_name_exts() -> list[str]: + if sys.platform == "win32": + return ["", ".cmd", ".exe", ".bat"] + return [""] + + +def _resolve_cli_path(path: str) -> Optional[str]: + for ext in _cli_name_exts(): + candidate = path + ext + if os.path.isfile(candidate): + return candidate + return None + + +def _dedupe_dirs(dirs: list[str]) -> list[str]: + seen: set[str] = set() + ordered: list[str] = [] + for directory in dirs: + if not directory: + continue + directory = os.path.expanduser(directory) + if directory in seen: + continue + seen.add(directory) + if os.path.isdir(directory): + ordered.append(directory) + return ordered + + +def _npmrc_prefix_dirs() -> list[str]: + dirs: list[str] = [] + npmrc_paths = [os.path.join(os.path.expanduser("~"), ".npmrc")] + try: + from services.env_settings import _env_path + npmrc_paths.append(os.path.join(os.path.dirname(_env_path()), ".npmrc")) + except Exception: + pass + for npmrc in npmrc_paths: + if not os.path.isfile(npmrc): + continue + try: + with open(npmrc, encoding="utf-8") as f: + for line in f: + stripped = line.strip() + if not stripped or stripped.startswith("#") or stripped.startswith(";"): + continue + if stripped.startswith("prefix="): + prefix = stripped.split("=", 1)[1].strip() + if prefix: + dirs.append(prefix if sys.platform == "win32" else os.path.join(prefix, "bin")) + except Exception: + pass + return dirs + + +def _package_manager_bin_dirs() -> list[str]: + dirs: list[str] = [] + npm_cmds = [ + (["npm", "config", "get", "prefix"], "prefix"), + (["npm", "root", "-g"], "root"), + ] + for args, kind in npm_cmds: + try: + result = subprocess.run(args, capture_output=True, text=True, timeout=2) + except Exception: + continue + if result.returncode != 0: + continue + raw = result.stdout.strip().splitlines()[0].strip() if result.stdout.strip() else "" + if not raw: + continue + if kind == "prefix": + dirs.append(raw if sys.platform == "win32" else os.path.join(raw, "bin")) + elif kind == "root": + dirs.append(os.path.join(raw, ".bin")) + else: + dirs.append(raw) + + for args, kind in ( + (["pnpm", "config", "get", "global-bin-dir"], "bin"), + (["pnpm", "bin", "-g"], "bin"), + (["yarn", "global", "bin"], "bin"), + ): + try: + result = subprocess.run(args, capture_output=True, text=True, timeout=2) + except Exception: + continue + if result.returncode != 0: + continue + raw = result.stdout.strip().splitlines()[0].strip() if result.stdout.strip() else "" + if raw: + dirs.append(raw) + + return dirs + + +def _version_manager_bin_dirs() -> list[str]: + home = os.path.expanduser("~") + dirs = [ + os.path.join(home, "bin"), + os.path.join(home, ".asdf", "shims"), + os.path.join(home, ".local", "share", "mise", "shims"), + os.path.join(home, ".local", "share", "rtx", "shims"), + os.path.join(home, ".bun", "bin"), + os.path.join(home, ".cargo", "bin"), + os.path.join(home, "go", "bin"), + os.path.join(home, ".local", "share", "pnpm"), + os.path.join(home, ".claude", "bin"), + ] + + nvm_dir = os.environ.get("NVM_DIR") or os.path.join(home, ".nvm") + try: + import glob + dirs.extend(sorted(glob.glob(os.path.join(nvm_dir, "versions", "node", "*", "bin")), reverse=True)) + dirs.extend(glob.glob(os.path.join(home, ".fnm", "node-versions", "*", "installation", "bin"))) + dirs.extend(glob.glob(os.path.join(home, ".local", "share", "fnm", "node-versions", "*", "installation", "bin"))) + except Exception: + pass + + fnm_bin = os.path.join(home, ".local", "share", "fnm", "current", "bin") + dirs.append(fnm_bin) + dirs.append(os.path.join(home, ".volta", "bin")) + + if sys.platform == "win32": + for env_key in ("APPDATA", "LOCALAPPDATA", "ProgramFiles", "ProgramFiles(x86)", "ProgramW6432"): + base = os.environ.get(env_key) + if not base: + continue + dirs.extend([ + os.path.join(base, "npm"), + os.path.join(base, "Programs", "nodejs"), + os.path.join(base, "Microsoft", "WinGet", "Links"), + ]) + dirs.append(os.path.join(home, "scoop", "shims")) + dirs.append(os.path.join(os.environ.get("ProgramData", ""), "npm")) + else: + dirs.extend([ + "/usr/bin", + "/bin", + "/usr/local/bin", + "/opt/homebrew/bin", + "/opt/homebrew/sbin", + "/snap/bin", + "/var/lib/snapd/snap/bin", + ]) + + npm_prefix = ( + os.environ.get("NPM_CONFIG_PREFIX") + or os.environ.get("npm_config_prefix") + or "" + ).strip() + if npm_prefix: + dirs.append(os.path.join(os.path.expanduser(npm_prefix), "bin")) + + return dirs + + +def _static_lookup_dirs() -> list[str]: + home = os.path.expanduser("~") + dirs = [ + os.path.join(home, ".local", "bin"), + os.path.join(home, ".claude", "local", "bin"), + os.path.join(home, ".claude", "local", "node_modules", ".bin"), + os.path.join(home, ".npm-global", "bin"), + ] + if sys.platform == "win32": + appdata = os.environ.get("APPDATA") + if appdata: + dirs.append(os.path.join(appdata, "npm")) + dirs.append(os.path.join(home, ".local", "bin")) + return dirs + + +@lru_cache(maxsize=8) +def _lookup_dirs(_key: tuple) -> list[str]: + return _dedupe_dirs( + _static_lookup_dirs() + + _version_manager_bin_dirs() + + _npmrc_prefix_dirs() + + _package_manager_bin_dirs() + ) + + +def _all_lookup_dirs() -> list[str]: + return list(_lookup_dirs(_discovery_key())) + + +def _path_lookup_dirs() -> list[str]: + return _all_lookup_dirs() + + +def _npm_global_bin_dirs() -> list[str]: + return _package_manager_bin_dirs() + + +def _parse_shell_lookup_line(line: str) -> Optional[str]: + candidate = line.strip().strip('"') + if not candidate: + return None + if " is " in candidate: + candidate = candidate.split(" is ", 1)[1].strip() + if candidate.startswith("(") and candidate.endswith(")"): + candidate = candidate[1:-1].strip() + return _resolve_cli_path(candidate) or (candidate if os.path.isfile(candidate) else None) + + +def _shell_lookup(name: str) -> Optional[str]: + if sys.platform == "win32": + commands = [ + ["where", name], + [ + "powershell", + "-NoProfile", + "-Command", + f"(Get-Command {name} -All -ErrorAction SilentlyContinue | " + f"Select-Object -ExpandProperty Source)", + ], + ] + else: + commands = [ + ["sh", "-lc", f"command -v {name}"], + ["bash", "-lc", f"type -a {name} 2>/dev/null"], + ["zsh", "-lc", f"whence -p {name} 2>/dev/null; command -v {name} 2>/dev/null"], + ["fish", "-lc", f"type -a {name} 2>/dev/null"], + ] + + for cmd in commands: + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=3) + except Exception: + continue + if result.returncode != 0 or not result.stdout.strip(): + continue + for line in result.stdout.strip().splitlines(): + resolved = _parse_shell_lookup_line(line) + if resolved: + return resolved + return None + + +def _glob_cli_paths(name: str) -> list[str]: + import glob + home = os.path.expanduser("~") + patterns = [ + os.path.join(home, ".claude", "bin", name), + os.path.join(home, ".claude", "*", "bin", name), + os.path.join(home, ".local", "share", "claude", "bin", name), + os.path.join(home, ".local", "share", "npm", "*", "bin", name), + ] + if sys.platform == "win32": + patterns.extend([ + os.path.join(home, ".claude", "bin", f"{name}.exe"), + os.path.join(home, ".claude", "bin", f"{name}.cmd"), + ]) + found: list[str] = [] + for pattern in patterns: + try: + found.extend(glob.glob(pattern)) + except Exception: + pass + return found + + +def _configured_cli_path(engine: str) -> Optional[str]: + env_key = "PODCLI_CLAUDE_PATH" if engine == "claude" else "PODCLI_CODEX_PATH" + raw = (os.environ.get(env_key) or "").strip() + if not raw: + try: + from services.env_settings import _read_pairs + raw = (_read_pairs().get(env_key) or "").strip() + except Exception: + pass + if not raw: + return None + return _resolve_cli_path(raw) or (raw if os.path.isfile(raw) else None) + + +def _find_cli(name: str, extra_paths: list[str] = None) -> Optional[str]: + import shutil + + for path in (extra_paths or []) + _glob_cli_paths(name): + resolved = _resolve_cli_path(path) + if resolved: + return resolved + + lookup_dirs = _all_lookup_dirs() + lookup_path = os.pathsep.join(lookup_dirs + [os.environ.get("PATH", "")]) + found = shutil.which(name, path=lookup_path) + if found: + return found + + for directory in lookup_dirs: + resolved = _resolve_cli_path(os.path.join(directory, name)) + if resolved: + return resolved + + for directory in (os.environ.get("PATH", "") or "").split(os.pathsep): + if not directory: + continue + resolved = _resolve_cli_path(os.path.join(directory, name)) + if resolved: + return resolved + + return _shell_lookup(name) + + +def _ai_cli_search_paths(name: str) -> list[str]: + paths_out = [os.path.join(directory, name) for directory in _all_lookup_dirs()] + paths_out.extend(_glob_cli_paths(name)) + return paths_out + + +def _env_cli_path(engine: str) -> Optional[str]: + return _configured_cli_path(engine) + + +def get_ai_cli_status() -> dict: + configured = { + "claude": _configured_cli_path("claude"), + "codex": _configured_cli_path("codex"), + } + candidates = [ + {"engine": engine, "path": path} + for path, engine in _find_ai_cli_candidates() + ] + return { + "configured": configured, + "candidates": candidates, + "available": bool(candidates), + "searched_dirs": _all_lookup_dirs(), + } + + +def _env_file_stamp() -> tuple: + """ + Identity of the .env discovery also reads. + + A configured CLI path can come from the file as well as the environment, + and the backend task runner is long-lived: it serves the request that saves + the path and every request after it. Without the file in the key, saving a + path in the studio has no effect until the process restarts, which is a + regression against the old probe-every-time behaviour. + """ + try: + from services.env_settings import _env_path + path = _env_path() + stat = os.stat(path) + return (path, stat.st_mtime_ns, stat.st_size) + except Exception: + # No file, or no reading it: nothing to invalidate against. + return () + + +def _discovery_key() -> tuple: + """Everything discovery reads. Changing any of it must re-probe.""" + return tuple( + os.environ.get(name, "") + for name in ( + "PATH", "HOME", "NVM_DIR", "APPDATA", "ProgramData", + "NPM_CONFIG_PREFIX", "npm_config_prefix", + "PODCLI_CLAUDE_PATH", "PODCLI_CODEX_PATH", + ) + ) + _env_file_stamp() + + +@lru_cache(maxsize=8) +def _discover(_key: tuple) -> list[tuple[str, str]]: + candidates = [] + + claude = _env_cli_path("claude") or _find_cli("claude", _ai_cli_search_paths("claude")) + if claude: + candidates.append((claude, "claude")) + + codex = _env_cli_path("codex") or _find_cli("codex", _ai_cli_search_paths("codex")) + if codex: + candidates.append((codex, "codex")) + + return candidates + + +def _find_ai_cli_candidates() -> list[tuple[str, str]]: + # Each probe shells out to npm, pnpm and yarn, which costs ~3s. Callers ask + # several times per render and the filesystem does not move underneath them, + # so the result is cached against the environment it was derived from. + return list(_discover(_discovery_key())) + + +def _find_ai_cli() -> tuple[Optional[str], str]: + """ + Find the best available AI CLI. + + Returns (path, engine) where engine is "claude" or "codex". + Returns (None, "") if neither is available. + """ + candidates = _find_ai_cli_candidates() + return candidates[0] if candidates else (None, "") + + +def _engine_label(engine: str) -> str: + """Human-readable name for an AI engine id.""" + if engine == "claude": + return "Claude" + if engine == "codex": + return "Codex" + return "AI" + + +def _format_timeout_label(timeout: int) -> str: + """Render a human-readable timeout label for progress messages.""" + if timeout % 60 == 0 and timeout >= 60: + minutes = timeout // 60 + unit = "minute" if minutes == 1 else "minutes" + return f"{minutes} {unit}" + return f"{timeout}s" + + +def _run_ai_command( + cli_path: str, + engine: str, + prompt: str, + prompt_file: str, + project_dir: str, + timeout: int, +) -> subprocess.CompletedProcess: + """Execute one AI CLI prompt and return the completed process.""" + if engine == "codex": + output_file = prompt_file + ".out" + result = subprocess.run( + [ + cli_path, "exec", + "--full-auto", + "-o", output_file, + prompt, + ], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + cwd=project_dir, + timeout=timeout, + ) + if os.path.exists(output_file): + with open(output_file, encoding="utf-8") as f: + result = subprocess.CompletedProcess( + args=result.args, + returncode=result.returncode, + stdout=f.read(), + stderr=result.stderr, + ) + try: + os.unlink(output_file) + except Exception: + pass + return result + + shell = sys.platform == "win32" and cli_path.lower().endswith((".cmd", ".bat")) + cmd = f'"{cli_path}" --print -p -' if shell else [cli_path, "--print", "-p", "-"] + with open(prompt_file, encoding="utf-8") as prompt_fh: + return subprocess.run( + cmd, + stdin=prompt_fh, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + cwd=project_dir, + timeout=timeout, + shell=shell, + ) + + +def classify_cli_error(detail: str) -> str: + """Turn a raw AI CLI failure into an actionable hint. The generic + 'check login' message hides whether it's auth, a plan limit, or a crash.""" + low = (detail or "").lower() + if any(s in low for s in ("not logged in", "please run", "/login", "authenticate", "unauthorized", "invalid api key", "no credentials")): + return "not logged in. Run `claude` (or `codex`) once in a terminal to authenticate, then retry." + if any(s in low for s in ("usage limit", "rate limit", "quota", "too many requests", "429")): + return "usage or rate limit reached on your plan. Wait for the limit to reset, then retry." + if "timed out" in low or "timeout" in low: + return detail + if not detail: + return "the AI CLI returned no output. Run `claude` once in a terminal to confirm it responds." + return detail diff --git a/backend/services/ai_provider.py b/backend/services/ai_provider.py new file mode 100644 index 0000000..856f95b --- /dev/null +++ b/backend/services/ai_provider.py @@ -0,0 +1,355 @@ +"""Single entry point for every AI generation in podcli. + +Three backends, tried in order until one answers: + + cloud podcli Pro, if signed in (fastest, no install, prompt caching) + cli the user's local Claude Code / Codex binary (free, needs an install) + api ANTHROPIC_API_KEY, called directly over HTTPS (no install, per token) + +Callers pass a prompt and get text back. They do not care which backend ran, +which is the point: the local CLI is found, launched and parsed differently on +every platform, and that mess stops here. + +Selection is controlled by PODCLI_AI_PROVIDER (auto|cloud|cli|api). On `auto` +the order above applies: a Pro subscriber gets what they paid for first, and the +local CLI remains the fallback if the network or the subscription is unavailable +— podcli never stops working because a server did. +""" + +from __future__ import annotations + +import json +import os +import re +import sys +import urllib.error +import urllib.request +from dataclasses import dataclass, field +from typing import Any, Callable, Optional + +from services import ai_cli, podcli_cloud + +ANTHROPIC_URL = "https://api.anthropic.com/v1/messages" +ANTHROPIC_VERSION = "2023-06-01" +DEFAULT_API_MODEL = "claude-sonnet-5" +DEFAULT_MAX_TOKENS = 16000 + + +@dataclass +class AIResult: + ok: bool + text: str = "" + error: str = "" + provider: str = "" + label: str = "" + attempts: list[str] = field(default_factory=list) + # Extra independent answers to the same prompt, when the backend ran several. + # Callers that know how to merge them get a wider search for almost nothing; + # callers that ignore them behave exactly as before. + alternates: list[str] = field(default_factory=list) + + +def _mode() -> str: + mode = (os.environ.get("PODCLI_AI_PROVIDER") or "auto").strip().lower() + return mode if mode in ("auto", "cloud", "cli", "api") else "auto" + + +def _api_key() -> Optional[str]: + key = (os.environ.get("ANTHROPIC_API_KEY") or "").strip() + return key or None + + +def _api_model() -> str: + return (os.environ.get("PODCLI_AI_MODEL") or "").strip() or DEFAULT_API_MODEL + + +def _chain() -> list[tuple[str, str, str]]: + """Backends to try, in order: (kind, path_or_key, engine).""" + mode = _mode() + chain: list[tuple[str, str, str]] = [] + # A signed-in free workspace would otherwise upload the whole transcript on + # every pass only to be told 402. Forcing the mode still tries, so someone + # debugging entitlement can reach the server. + if mode in ("auto", "cloud") and podcli_cloud.signed_in(): + if mode == "cloud" or podcli_cloud.entitled(): + chain.append(("cloud", "", "cloud")) + if mode in ("auto", "cli"): + chain.extend(("cli", path, engine) for path, engine in ai_cli._find_ai_cli_candidates()) + if mode in ("auto", "api"): + key = _api_key() + if key: + chain.append(("api", key, "api")) + return chain + + +def label_for(kind: str, engine: str) -> str: + if kind == "cloud": + return "podcli Pro" + if kind == "api": + return "Claude API" + return ai_cli._engine_label(engine) + + +def available() -> bool: + return bool(_chain()) + + +def claude_cli_path() -> Optional[str]: + """The local Claude binary, for the one caller that streams its output.""" + for kind, path, engine in _chain(): + if kind == "cli" and engine == "claude": + return path + return None + + +def status() -> dict: + cli_status = ai_cli.get_ai_cli_status() + chain = _chain() + return { + **cli_status, + "mode": _mode(), + "api_key_set": bool(_api_key()), + "api_model": _api_model(), + "available": bool(chain), + "providers": [ + {"kind": kind, "engine": engine, "label": label_for(kind, engine)} + for kind, _, engine in chain + ], + } + + +def extract_json(text: str) -> Optional[Any]: + """Pull the first JSON value out of a model response. + + Models fence their JSON, prefix it with prose, or both, regardless of how + firmly the prompt asks them not to. + """ + if not text: + return None + body = text.strip() + if "```" in body: + fenced = re.search(r"```(?:json)?\s*\n?(.*?)\n?\s*```", body, re.DOTALL) + if fenced: + body = fenced.group(1).strip() + # By position, not by preference: a top-level array whose first element is + # an object would otherwise match "{" at index 1 and return one element of + # the list instead of the list. + openers = sorted( + (body.find(opener), opener) for opener in ("{", "[") if body.find(opener) >= 0 + ) + for start, _opener in openers: + try: + value, _ = json.JSONDecoder().raw_decode(body, start) + return value + except ValueError: + continue + return None + + +def _run_api(key: str, prompt: str, timeout: int) -> AIResult: + payload = json.dumps({ + "model": _api_model(), + "max_tokens": DEFAULT_MAX_TOKENS, + "messages": [{"role": "user", "content": prompt}], + }).encode("utf-8") + request = urllib.request.Request( + ANTHROPIC_URL, + data=payload, + headers={ + "content-type": "application/json", + "x-api-key": key, + "anthropic-version": ANTHROPIC_VERSION, + }, + ) + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + body = json.loads(response.read().decode("utf-8")) + except urllib.error.HTTPError as exc: + detail = "" + try: + detail = json.loads(exc.read().decode("utf-8")).get("error", {}).get("message", "") + except Exception: + pass + return AIResult(ok=False, provider="api", label="Claude API", + error=detail or f"Claude API returned HTTP {exc.code}") + except Exception as exc: + return AIResult(ok=False, provider="api", label="Claude API", error=str(exc)) + + if body.get("stop_reason") == "refusal": + return AIResult(ok=False, provider="api", label="Claude API", + error="Claude declined this request.") + + text = "".join( + block.get("text", "") + for block in body.get("content", []) + if block.get("type") == "text" + ).strip() + if not text: + return AIResult(ok=False, provider="api", label="Claude API", + error="Claude API returned no text.") + return AIResult(ok=True, text=text, provider="api", label="Claude API") + + +def _run_cloud(purpose: str, instruction: str, system: Optional[str], + cached_context: Optional[str], episode_source_hash: Optional[str], + timeout: int) -> AIResult: + try: + payload = podcli_cloud.generate( + purpose=purpose, + instruction=instruction, + system=system, + cached_context=cached_context, + episode_source_hash=episode_source_hash, + timeout=timeout, + ) + except podcli_cloud.CloudError as exc: + return AIResult(ok=False, provider="cloud", label="podcli Pro", error=str(exc)) + + text = (payload.get("text") or "").strip() + if not text: + return AIResult(ok=False, provider="cloud", label="podcli Pro", + error="podcli Pro returned no text.") + return AIResult( + ok=True, text=text, provider="cloud", label="podcli Pro", + alternates=[a for a in (payload.get("alternates") or []) if a], + ) + + +def _run_cli(cli_path: str, engine: str, prompt: str, prompt_file: str, + project_dir: str, timeout: int) -> AIResult: + label = ai_cli._engine_label(engine) + try: + completed = ai_cli._run_ai_command( + cli_path=cli_path, + engine=engine, + prompt=prompt, + prompt_file=prompt_file, + project_dir=project_dir, + timeout=timeout, + ) + except Exception as exc: + timed_out = "timed out" in str(exc).lower() or exc.__class__.__name__ == "TimeoutExpired" + detail = ( + f"{label} timed out ({ai_cli._format_timeout_label(timeout)} limit)" + if timed_out else f"{label} failed to start: {exc}" + ) + return AIResult(ok=False, provider=engine, label=label, error=detail) + + text = (completed.stdout or "").strip() + if completed.returncode != 0 or not text: + detail = (completed.stderr or "").strip() or text + return AIResult(ok=False, provider=engine, label=label, + error=ai_cli.classify_cli_error(detail)) + return AIResult(ok=True, text=text, provider=engine, label=label) + + +def generate( + prompt: str, + *, + timeout: int = 900, + project_dir: Optional[str] = None, + on_attempt: Optional[Callable[[str], None]] = None, + accept: Optional[Callable[[str], bool]] = None, + adapt: Optional[Callable[[str, str], str]] = None, + purpose: str = "generate", + stable_prefix: Optional[str] = None, + local_prompt: Optional[str] = None, + episode_source_hash: Optional[str] = None, +) -> AIResult: + """Run one prompt through the first backend that answers. + + on_attempt is called with a human label ("Claude", "podcli Pro") before each + attempt so callers can drive progress UI without knowing the chain. + + accept rejects a response the backend considers successful — an engine that + answers with prose where JSON was asked for has failed, and the next one + deserves a turn. Return True to accept, or False / a reason string to reject. + + adapt(engine, prompt) rewrites the prompt per backend, for engines that need + a shorter one than the others. + + stable_prefix is the large, unchanging half of the prompt — the transcript. + The cloud backend sends it as a separate cacheable block so it is read once + per episode rather than once per pass, which is the difference between ~$0.41 + and ~$0.90 per episode. + + Caching wants the stable text first and the varying ask last; several local + prompts are built the other way round, and reordering them would change what + the free path produces. local_prompt is the escape hatch: pass the exact + legacy string and local backends send it untouched while the cloud gets the + split form. Omit it and the prefix is simply prepended. + """ + chain = _chain() + if not chain: + return AIResult( + ok=False, + error="No AI available. Install Claude Code or set ANTHROPIC_API_KEY " + "(podcli config set ANTHROPIC_API_KEY ...).", + ) + + if project_dir is None: + project_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + + # The Claude CLI reads its prompt from a file, so an adapted prompt needs its + # own file rather than the shared one. + prompt_files: dict[str, str] = {} + + def prompt_file_for(text: str) -> str: + if text not in prompt_files: + from utils.prompt_files import write_prompt_file + prompt_files[text] = write_prompt_file(text) + return prompt_files[text] + + attempts: list[str] = [] + last = AIResult(ok=False, error="No AI backend produced a response.") + try: + for kind, target, engine in chain: + label = label_for(kind, engine) + if on_attempt: + on_attempt(label) + if kind == "cloud": + result = _run_cloud(purpose, prompt, None, stable_prefix, + episode_source_hash, timeout) + else: + whole = local_prompt or ( + f"{stable_prefix}\n\n{prompt}" if stable_prefix else prompt + ) + text = adapt(engine, whole) if adapt else whole + if kind == "api": + result = _run_api(target, text, timeout) + else: + result = _run_cli(target, engine, text, prompt_file_for(text), + project_dir, timeout) + if result.ok and accept: + verdict = accept(result.text) + if verdict is not True: + reason = verdict if isinstance(verdict, str) and verdict else \ + f"{label} returned an unusable response." + result = AIResult(ok=False, provider=result.provider, + label=label, error=reason) + attempts.append(f"{label}: {'ok' if result.ok else result.error}") + if result.ok: + result.attempts = attempts + return result + last = result + last.attempts = attempts + return last + finally: + for path in prompt_files.values(): + try: + os.unlink(path) + except OSError: + pass + + +def generate_json(prompt: str, **kwargs) -> tuple[Optional[Any], AIResult]: + """generate() plus the fence-stripping every caller was doing by hand. + + A backend whose answer will not parse is treated as failed, so the next one + in the chain gets a turn. + """ + kwargs.setdefault("accept", lambda text: extract_json(text) is not None) + result = generate(prompt, **kwargs) + if not result.ok: + return None, result + return extract_json(result.text), result diff --git a/backend/services/claude_suggest.py b/backend/services/claude_suggest.py index e12f8d2..57f4641 100644 --- a/backend/services/claude_suggest.py +++ b/backend/services/claude_suggest.py @@ -12,7 +12,6 @@ import os import subprocess import sys -import tempfile from typing import Optional, Callable from config.paths import paths @@ -22,432 +21,16 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from presets import MIN_CLIP_DURATION, MAX_CLIP_DURATION, TARGET_CLIP_DURATION_MIN, TARGET_CLIP_DURATION_MAX from utils.text import clean_title - - -def _cli_name_exts() -> list[str]: - if sys.platform == "win32": - return ["", ".cmd", ".exe", ".bat"] - return [""] - - -def _resolve_cli_path(path: str) -> Optional[str]: - for ext in _cli_name_exts(): - candidate = path + ext - if os.path.isfile(candidate): - return candidate - return None - - -def _dedupe_dirs(dirs: list[str]) -> list[str]: - seen: set[str] = set() - ordered: list[str] = [] - for directory in dirs: - if not directory: - continue - directory = os.path.expanduser(directory) - if directory in seen: - continue - seen.add(directory) - if os.path.isdir(directory): - ordered.append(directory) - return ordered - - -def _npmrc_prefix_dirs() -> list[str]: - dirs: list[str] = [] - npmrc_paths = [os.path.join(os.path.expanduser("~"), ".npmrc")] - try: - from services.env_settings import _env_path - npmrc_paths.append(os.path.join(os.path.dirname(_env_path()), ".npmrc")) - except Exception: - pass - for npmrc in npmrc_paths: - if not os.path.isfile(npmrc): - continue - try: - with open(npmrc, encoding="utf-8") as f: - for line in f: - stripped = line.strip() - if not stripped or stripped.startswith("#") or stripped.startswith(";"): - continue - if stripped.startswith("prefix="): - prefix = stripped.split("=", 1)[1].strip() - if prefix: - dirs.append(prefix if sys.platform == "win32" else os.path.join(prefix, "bin")) - except Exception: - pass - return dirs - - -def _package_manager_bin_dirs() -> list[str]: - dirs: list[str] = [] - npm_cmds = [ - (["npm", "config", "get", "prefix"], "prefix"), - (["npm", "root", "-g"], "root"), - ] - for args, kind in npm_cmds: - try: - result = subprocess.run(args, capture_output=True, text=True, timeout=2) - except Exception: - continue - if result.returncode != 0: - continue - raw = result.stdout.strip().splitlines()[0].strip() if result.stdout.strip() else "" - if not raw: - continue - if kind == "prefix": - dirs.append(raw if sys.platform == "win32" else os.path.join(raw, "bin")) - elif kind == "root": - dirs.append(os.path.join(raw, ".bin")) - else: - dirs.append(raw) - - for args, kind in ( - (["pnpm", "config", "get", "global-bin-dir"], "bin"), - (["pnpm", "bin", "-g"], "bin"), - (["yarn", "global", "bin"], "bin"), - ): - try: - result = subprocess.run(args, capture_output=True, text=True, timeout=2) - except Exception: - continue - if result.returncode != 0: - continue - raw = result.stdout.strip().splitlines()[0].strip() if result.stdout.strip() else "" - if raw: - dirs.append(raw) - - return dirs - - -def _version_manager_bin_dirs() -> list[str]: - home = os.path.expanduser("~") - dirs = [ - os.path.join(home, "bin"), - os.path.join(home, ".asdf", "shims"), - os.path.join(home, ".local", "share", "mise", "shims"), - os.path.join(home, ".local", "share", "rtx", "shims"), - os.path.join(home, ".bun", "bin"), - os.path.join(home, ".cargo", "bin"), - os.path.join(home, "go", "bin"), - os.path.join(home, ".local", "share", "pnpm"), - os.path.join(home, ".claude", "bin"), - ] - - nvm_dir = os.environ.get("NVM_DIR") or os.path.join(home, ".nvm") - try: - import glob - dirs.extend(sorted(glob.glob(os.path.join(nvm_dir, "versions", "node", "*", "bin")), reverse=True)) - dirs.extend(glob.glob(os.path.join(home, ".fnm", "node-versions", "*", "installation", "bin"))) - dirs.extend(glob.glob(os.path.join(home, ".local", "share", "fnm", "node-versions", "*", "installation", "bin"))) - except Exception: - pass - - fnm_bin = os.path.join(home, ".local", "share", "fnm", "current", "bin") - dirs.append(fnm_bin) - dirs.append(os.path.join(home, ".volta", "bin")) - - if sys.platform == "win32": - for env_key in ("APPDATA", "LOCALAPPDATA", "ProgramFiles", "ProgramFiles(x86)", "ProgramW6432"): - base = os.environ.get(env_key) - if not base: - continue - dirs.extend([ - os.path.join(base, "npm"), - os.path.join(base, "Programs", "nodejs"), - os.path.join(base, "Microsoft", "WinGet", "Links"), - ]) - dirs.append(os.path.join(home, "scoop", "shims")) - dirs.append(os.path.join(os.environ.get("ProgramData", ""), "npm")) - else: - dirs.extend([ - "/usr/bin", - "/bin", - "/usr/local/bin", - "/opt/homebrew/bin", - "/opt/homebrew/sbin", - "/snap/bin", - "/var/lib/snapd/snap/bin", - ]) - - npm_prefix = ( - os.environ.get("NPM_CONFIG_PREFIX") - or os.environ.get("npm_config_prefix") - or "" - ).strip() - if npm_prefix: - dirs.append(os.path.join(os.path.expanduser(npm_prefix), "bin")) - - return dirs - - -def _static_lookup_dirs() -> list[str]: - home = os.path.expanduser("~") - dirs = [ - os.path.join(home, ".local", "bin"), - os.path.join(home, ".claude", "local", "bin"), - os.path.join(home, ".claude", "local", "node_modules", ".bin"), - os.path.join(home, ".npm-global", "bin"), - ] - if sys.platform == "win32": - appdata = os.environ.get("APPDATA") - if appdata: - dirs.append(os.path.join(appdata, "npm")) - dirs.append(os.path.join(home, ".local", "bin")) - return dirs - - -def _all_lookup_dirs() -> list[str]: - return _dedupe_dirs( - _static_lookup_dirs() - + _version_manager_bin_dirs() - + _npmrc_prefix_dirs() - + _package_manager_bin_dirs() - ) - - -def _path_lookup_dirs() -> list[str]: - return _all_lookup_dirs() - - -def _npm_global_bin_dirs() -> list[str]: - return _package_manager_bin_dirs() - - -def _parse_shell_lookup_line(line: str) -> Optional[str]: - candidate = line.strip().strip('"') - if not candidate: - return None - if " is " in candidate: - candidate = candidate.split(" is ", 1)[1].strip() - if candidate.startswith("(") and candidate.endswith(")"): - candidate = candidate[1:-1].strip() - return _resolve_cli_path(candidate) or (candidate if os.path.isfile(candidate) else None) - - -def _shell_lookup(name: str) -> Optional[str]: - if sys.platform == "win32": - commands = [ - ["where", name], - [ - "powershell", - "-NoProfile", - "-Command", - f"(Get-Command {name} -All -ErrorAction SilentlyContinue | " - f"Select-Object -ExpandProperty Source)", - ], - ] - else: - commands = [ - ["sh", "-lc", f"command -v {name}"], - ["bash", "-lc", f"type -a {name} 2>/dev/null"], - ["zsh", "-lc", f"whence -p {name} 2>/dev/null; command -v {name} 2>/dev/null"], - ["fish", "-lc", f"type -a {name} 2>/dev/null"], - ] - - for cmd in commands: - try: - result = subprocess.run(cmd, capture_output=True, text=True, timeout=3) - except Exception: - continue - if result.returncode != 0 or not result.stdout.strip(): - continue - for line in result.stdout.strip().splitlines(): - resolved = _parse_shell_lookup_line(line) - if resolved: - return resolved - return None - - -def _glob_cli_paths(name: str) -> list[str]: - import glob - home = os.path.expanduser("~") - patterns = [ - os.path.join(home, ".claude", "bin", name), - os.path.join(home, ".claude", "*", "bin", name), - os.path.join(home, ".local", "share", "claude", "bin", name), - os.path.join(home, ".local", "share", "npm", "*", "bin", name), - ] - if sys.platform == "win32": - patterns.extend([ - os.path.join(home, ".claude", "bin", f"{name}.exe"), - os.path.join(home, ".claude", "bin", f"{name}.cmd"), - ]) - found: list[str] = [] - for pattern in patterns: - try: - found.extend(glob.glob(pattern)) - except Exception: - pass - return found - - -def _configured_cli_path(engine: str) -> Optional[str]: - env_key = "PODCLI_CLAUDE_PATH" if engine == "claude" else "PODCLI_CODEX_PATH" - raw = (os.environ.get(env_key) or "").strip() - if not raw: - try: - from services.env_settings import _read_pairs - raw = (_read_pairs().get(env_key) or "").strip() - except Exception: - pass - if not raw: - return None - return _resolve_cli_path(raw) or (raw if os.path.isfile(raw) else None) - - -def _find_cli(name: str, extra_paths: list[str] = None) -> Optional[str]: - import shutil - - for path in (extra_paths or []) + _glob_cli_paths(name): - resolved = _resolve_cli_path(path) - if resolved: - return resolved - - lookup_dirs = _all_lookup_dirs() - lookup_path = os.pathsep.join(lookup_dirs + [os.environ.get("PATH", "")]) - found = shutil.which(name, path=lookup_path) - if found: - return found - - for directory in lookup_dirs: - resolved = _resolve_cli_path(os.path.join(directory, name)) - if resolved: - return resolved - - for directory in (os.environ.get("PATH", "") or "").split(os.pathsep): - if not directory: - continue - resolved = _resolve_cli_path(os.path.join(directory, name)) - if resolved: - return resolved - - return _shell_lookup(name) - - -def _ai_cli_search_paths(name: str) -> list[str]: - paths_out = [os.path.join(directory, name) for directory in _all_lookup_dirs()] - paths_out.extend(_glob_cli_paths(name)) - return paths_out - - -def _env_cli_path(engine: str) -> Optional[str]: - return _configured_cli_path(engine) - - -def get_ai_cli_status() -> dict: - configured = { - "claude": _configured_cli_path("claude"), - "codex": _configured_cli_path("codex"), - } - candidates = [ - {"engine": engine, "path": path} - for path, engine in _find_ai_cli_candidates() - ] - return { - "configured": configured, - "candidates": candidates, - "available": bool(candidates), - "searched_dirs": _all_lookup_dirs(), - } - - -def _find_ai_cli_candidates() -> list[tuple[str, str]]: - candidates = [] - - claude = _env_cli_path("claude") or _find_cli("claude", _ai_cli_search_paths("claude")) - if claude: - candidates.append((claude, "claude")) - - codex = _env_cli_path("codex") or _find_cli("codex", _ai_cli_search_paths("codex")) - if codex: - candidates.append((codex, "codex")) - - return candidates - - -def _find_ai_cli() -> tuple[Optional[str], str]: - """ - Find the best available AI CLI. - - Returns (path, engine) where engine is "claude" or "codex". - Returns (None, "") if neither is available. - """ - candidates = _find_ai_cli_candidates() - return candidates[0] if candidates else (None, "") - - -def _engine_label(engine: str) -> str: - """Human-readable name for an AI engine id.""" - if engine == "claude": - return "Claude" - if engine == "codex": - return "Codex" - return "AI" - - -def _format_timeout_label(timeout: int) -> str: - """Render a human-readable timeout label for progress messages.""" - if timeout % 60 == 0 and timeout >= 60: - minutes = timeout // 60 - unit = "minute" if minutes == 1 else "minutes" - return f"{minutes} {unit}" - return f"{timeout}s" - - -def _run_ai_command( - cli_path: str, - engine: str, - prompt: str, - prompt_file: str, - project_dir: str, - timeout: int, -) -> subprocess.CompletedProcess: - """Execute one AI CLI prompt and return the completed process.""" - if engine == "codex": - output_file = prompt_file + ".out" - result = subprocess.run( - [ - cli_path, "exec", - "--full-auto", - "-o", output_file, - prompt, - ], - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - cwd=project_dir, - timeout=timeout, - ) - if os.path.exists(output_file): - with open(output_file, encoding="utf-8") as f: - result = subprocess.CompletedProcess( - args=result.args, - returncode=result.returncode, - stdout=f.read(), - stderr=result.stderr, - ) - try: - os.unlink(output_file) - except Exception: - pass - return result - - shell = sys.platform == "win32" and cli_path.lower().endswith((".cmd", ".bat")) - cmd = f'"{cli_path}" --print -p -' if shell else [cli_path, "--print", "-p", "-"] - with open(prompt_file, encoding="utf-8") as prompt_fh: - return subprocess.run( - cmd, - stdin=prompt_fh, - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - cwd=project_dir, - timeout=timeout, - shell=shell, - ) +from services import ai_provider, podcli_cloud +from services.ai_cli import ( + _engine_label, + _find_ai_cli, + _find_ai_cli_candidates, + _format_timeout_label, + _run_ai_command, + classify_cli_error, + get_ai_cli_status, +) def _load_existing_shorts(episodes_path: str) -> list[str]: @@ -634,6 +217,19 @@ def _build_prompt( {transcript_text}""" +def _split_prompt_for_cache(prompt: str, transcript_text: str) -> tuple[str, str]: + """Separate the transcript from the ask, for backends that cache prefixes. + + Returns (stable_prefix, instruction). The transcript is the only large + thing here and it is identical across every pass on an episode, so caching + it turns four full reads into one read and three cache hits. + """ + marker = f"\n\n{transcript_text}" + if not transcript_text or not prompt.endswith(marker): + return "", prompt + return transcript_text, prompt[: -len(marker)] + + def _build_transcript_text(segments: list[dict]) -> str: """Serialize transcript segments into the prompt-friendly text format.""" lines = [] @@ -739,14 +335,13 @@ def find_moments_from_text( progress_callback: Optional[Callable[[int, str], None]] = None, max_results: int = 3, ) -> list[dict]: - """Locate the moment(s) the user described/pasted in the transcript via an AI - CLI. Returns clip dicts (same shape as suggest_with_claude). Status goes to + """Locate the moment(s) the user described/pasted in the transcript. + Returns clip dicts (same shape as suggest_with_claude). Status goes to progress_callback; warnings to stderr — never stdout, which is the task runner's JSON-RPC channel.""" existing_clips = existing_clips or [] - candidates = _find_ai_cli_candidates() - if not candidates: - print("No AI CLI available for moment search", file=sys.stderr, flush=True) + if not ai_provider.available(): + print("No AI available for moment search", file=sys.stderr, flush=True) return [] if progress_callback: @@ -794,49 +389,17 @@ def find_moments_from_text( Transcript: {transcript_text}""" - # Prompt goes to .podcli/tmp/ (gitignored), not the repo root, so a crash - # mid-run never litters the working tree with transcript dumps. project_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..") - from utils.prompt_files import write_prompt_file - prompt_file = write_prompt_file(prompt) - - try: - for idx, (cli_path, engine) in enumerate(candidates): - if progress_callback: - label = "Claude" if engine == "claude" else "Codex" - progress_callback(40, f"Searching transcript with {label}...") - try: - result = _run_ai_command( - cli_path=cli_path, - engine=engine, - prompt=prompt, - prompt_file=prompt_file, - project_dir=project_dir, - timeout=900, - ) - except Exception: - continue - - if result.returncode != 0 or not result.stdout.strip(): - continue - response = result.stdout.strip() - if "```" in response: - import re - - fence_match = re.search(r"```(?:json)?\s*\n?(.*?)\n?\s*```", response, re.DOTALL) - if fence_match: - response = fence_match.group(1).strip() - - try: - json_start = response.find("{") - if json_start >= 0: - data, _ = json.JSONDecoder().raw_decode(response, json_start) - else: - data = json.loads(response) - except Exception: - continue + def announce(label: str) -> None: + if progress_callback: + progress_callback(40, f"Searching transcript with {label}...") + try: + data, _result = ai_provider.generate_json( + prompt, timeout=900, project_dir=project_dir, on_attempt=announce, + ) + if data: found = [] for c in data.get("clips", []): scores = c.get("scores", {}) @@ -882,26 +445,6 @@ def find_moments_from_text( except Exception as e: print(f"Moment search error: {e}", file=sys.stderr, flush=True) return [] - finally: - try: - os.unlink(prompt_file) - except Exception: - pass - - -def classify_cli_error(detail: str) -> str: - """Turn a raw AI CLI failure into an actionable hint. The generic - 'check login' message hides whether it's auth, a plan limit, or a crash.""" - low = (detail or "").lower() - if any(s in low for s in ("not logged in", "please run", "/login", "authenticate", "unauthorized", "invalid api key", "no credentials")): - return "not logged in. Run `claude` (or `codex`) once in a terminal to authenticate, then retry." - if any(s in low for s in ("usage limit", "rate limit", "quota", "too many requests", "429")): - return "usage or rate limit reached on your plan. Wait for the limit to reset, then retry." - if "timed out" in low or "timeout" in low: - return detail - if not detail: - return "the AI CLI returned no output. Run `claude` once in a terminal to confirm it responds." - return detail def suggest_with_claude( @@ -919,13 +462,12 @@ def suggest_with_claude( Tries available AI CLIs in preference order and retries on runtime failure. Returns None if neither succeeds. """ - candidates = _find_ai_cli_candidates() - if not candidates: + providers = ai_provider.status()["providers"] + if not providers: return None if progress_callback: - label = _engine_label(candidates[0][1]) - progress_callback(0, f"Preparing transcript for {label}...") + progress_callback(0, f"Preparing transcript for {providers[0]['label']}...") transcript_text = _build_transcript_text(segments) @@ -943,161 +485,177 @@ def suggest_with_claude( reaction_times=reaction_times, ) - # Write prompt to temp file to avoid shell escaping issues. - # Goes to .podcli/tmp/ (gitignored) so crashes don't litter the repo root. project_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..") - from utils.prompt_files import write_prompt_file - prompt_file = write_prompt_file(prompt) - - if progress_callback: - first_label = _engine_label(candidates[0][1]) - progress_callback(20, f"Asking {first_label} to analyze transcript...") - - try: - def _parse_seconds(val) -> float: - """Parse a timestamp value — handles both 123.4 and '2:03' formats.""" - if isinstance(val, (int, float)): - return float(val) - s = str(val).strip() - if ":" in s: - parts = s.split(":") - try: - return float(parts[0]) * 60 + float(parts[1]) - except (ValueError, IndexError): - return 0.0 + def _parse_seconds(val) -> float: + """Parse a timestamp value — handles both 123.4 and '2:03' formats.""" + if isinstance(val, (int, float)): + return float(val) + s = str(val).strip() + if ":" in s: + parts = s.split(":") try: - return float(s) - except ValueError: + return float(parts[0]) * 60 + float(parts[1]) + except (ValueError, IndexError): return 0.0 + try: + return float(s) + except ValueError: + return 0.0 - last_detail: Optional[str] = None - for idx, (cli_path, engine) in enumerate(candidates): - label = _engine_label(engine) - if idx > 0 and progress_callback: - progress_callback(0, f"Retrying with {label}...") - progress_callback(20, f"Asking {label} to analyze transcript...") + attempted: list[str] = [] + current = {"label": ""} - try: - result = _run_ai_command( - cli_path=cli_path, - engine=engine, - prompt=prompt, - prompt_file=prompt_file, - project_dir=project_dir, - timeout=timeout, - ) - except subprocess.TimeoutExpired: - last_detail = f"{label} timed out ({_format_timeout_label(timeout)} limit)" - if progress_callback: - progress_callback(0, last_detail) - continue - except Exception as e: - last_detail = f"{label} error: {e}" - if progress_callback: - progress_callback(0, last_detail) - continue + def announce(label: str) -> None: + current["label"] = label + if attempted and progress_callback: + progress_callback(0, f"Retrying with {label}...") + if progress_callback: + progress_callback(20, f"Asking {label} to analyze transcript...") + attempted.append(label) - if result.returncode != 0 or not result.stdout.strip(): - detail = (result.stderr or "no response").strip()[:200] - last_detail = f"{label}: {detail}" - if progress_callback: - progress_callback(0, f"{label} returned error: {detail}") - continue + def usable(text: str): + """Reject a response that parses but has nothing in it, so the next + engine gets a turn rather than the user getting an empty result.""" + label = current["label"] + if progress_callback: + progress_callback(80, f"Parsing {label}'s suggestions...") + parsed = ai_provider.extract_json(text) + if not isinstance(parsed, dict): + return f"{label} returned output that wasn't valid JSON" + if not isinstance(parsed.get("clips"), list) or not parsed["clips"]: + return f"{label} ran but found no clips in the transcript" + return True + + cached_prefix, instruction = _split_prompt_for_cache(prompt, transcript_text) + + # What this channel's own published clips say about what works, plus the + # house style learned from edits the team made to earlier output. Empty for + # everyone else, so the free path is unchanged. + learned = podcli_cloud.prompt_block() + if learned: + instruction = f"{learned}\n\n{instruction}" + + attempt = ai_provider.generate( + instruction, + timeout=timeout, + project_dir=project_dir, + on_attempt=announce, + accept=usable, + purpose="select_moments", + stable_prefix=cached_prefix or None, + # Local backends keep the prompt exactly as it has always been built; + # only the cloud sees the split form. + local_prompt=prompt, + ) - if progress_callback: - progress_callback(80, f"Parsing {label}'s suggestions...") + if not attempt.ok: + if progress_callback: + progress_callback(0, attempt.error) + if error_sink is not None: + # Classified only for a CLI failure. The advice it adds is "run + # `claude` once in a terminal", which is right for a local CLI and + # wrong for a workspace session or an API key, and `classify_cli_error` + # matches on "unauthorized" so it rewrote those too. + # The CLI path tags the attempt with its engine name, not "cli", + # so this asks the question the other way round. + error_sink.append( + attempt.error + if attempt.provider in ("cloud", "api") + else classify_cli_error(attempt.error) + ) + return None - try: - response = result.stdout.strip() - if "```" in response: - import re - fence_match = re.search(r"```(?:json)?\s*\n?(.*?)\n?\s*```", response, re.DOTALL) - if fence_match: - response = fence_match.group(1).strip() - - json_start = response.find("{") - if json_start >= 0: - decoder = json.JSONDecoder() - data, _ = decoder.raw_decode(response, json_start) - else: - data = json.loads(response) - except json.JSONDecodeError as e: - last_detail = f"{label} returned output that wasn't valid JSON ({e})" - if progress_callback: - progress_callback(0, f"Could not parse {label}'s response as JSON: {e}") + label = attempt.label + + # Several independent searches over the same transcript find overlapping but + # not identical moments. Keeping the union and re-ranking beats picking one + # set, and the dedupe/scoring below already exists for exactly this shape. + def records(payload: object) -> list[dict]: + """ + The clip objects in a response, and only those. + + `usable` checked the primary response is a non-empty list, which still + admits a null or a string inside it, and the alternates are not checked + at all: `.get` on any of those is an AttributeError out of a code path + with nothing above it to catch. + """ + if not isinstance(payload, dict): + return [] + found = payload.get("clips") + if not isinstance(found, list): + return [] + return [c for c in found if isinstance(c, dict)] + + clips = records(ai_provider.extract_json(attempt.text)) + for alternate in attempt.alternates: + clips.extend(records(ai_provider.extract_json(alternate))) + + normalized = [] + for c in clips: + scores = c.get("scores") + scores = scores if isinstance(scores, dict) else {} + total = sum(scores.values()) if scores else c.get("total_score", 0) + + raw_segments = c.get("segments") + raw_segments = raw_segments if isinstance(raw_segments, list) else [] + keep_segments = [] + for seg in raw_segments: + if not isinstance(seg, dict): continue + s = round(_parse_seconds(seg.get("start", 0)), 1) + e = round(_parse_seconds(seg.get("end", 0)), 1) + if e > s: + keep_segments.append({"start": s, "end": e}) - clips = data.get("clips", []) - if not clips: - last_detail = f"{label} ran but found no clips in the transcript" - if progress_callback: - progress_callback(0, f"{label} returned no clips") - continue + start_sec = round(_parse_seconds(c.get("start_second", 0)), 1) + end_sec = round(_parse_seconds(c.get("end_second", 0)), 1) - normalized = [] - for c in clips: - scores = c.get("scores", {}) - total = sum(scores.values()) if scores else c.get("total_score", 0) + if not keep_segments and end_sec > start_sec: + keep_segments = [{"start": start_sec, "end": end_sec}] - raw_segments = c.get("segments", []) - keep_segments = [] - for seg in raw_segments: - s = round(_parse_seconds(seg.get("start", 0)), 1) - e = round(_parse_seconds(seg.get("end", 0)), 1) - if e > s: - keep_segments.append({"start": s, "end": e}) - - start_sec = round(_parse_seconds(c.get("start_second", 0)), 1) - end_sec = round(_parse_seconds(c.get("end_second", 0)), 1) - - if not keep_segments and end_sec > start_sec: - keep_segments = [{"start": start_sec, "end": end_sec}] - - kept_duration = sum(seg["end"] - seg["start"] for seg in keep_segments) - if kept_duration < MIN_CLIP_DURATION or kept_duration > MAX_CLIP_DURATION: - continue - - normalized.append({ - "title": clean_title(c.get("title", "Untitled")), - "start_second": keep_segments[0]["start"] if keep_segments else start_sec, - "end_second": keep_segments[-1]["end"] if keep_segments else end_sec, - "segments": keep_segments, - "duration": round(kept_duration), - "score": total, - "content_type": c.get("content_type", "unknown"), - "reasoning": c.get("why", ""), - "preview_text": c.get("quote", "")[:120], - "suggested_caption_style": "hormozi", - "quote": c.get("quote", ""), - "why": c.get("why", ""), - "reasons": [c.get("content_type", "")], - "preview": c.get("quote", "")[:120], - "_ai_engine": engine, - }) + kept_duration = sum(seg["end"] - seg["start"] for seg in keep_segments) + if kept_duration < MIN_CLIP_DURATION or kept_duration > MAX_CLIP_DURATION: + continue - selected = _select_top_by_score( - _drop_clips_overlapping(normalized, exclude_clips or []), top_n - ) + normalized.append({ + "title": clean_title(c.get("title", "Untitled")), + "start_second": keep_segments[0]["start"] if keep_segments else start_sec, + "end_second": keep_segments[-1]["end"] if keep_segments else end_sec, + "segments": keep_segments, + "duration": round(kept_duration), + "score": total, + "content_type": c.get("content_type", "unknown"), + "reasoning": c.get("why", ""), + "preview_text": c.get("quote", "")[:120], + "suggested_caption_style": "hormozi", + "quote": c.get("quote", ""), + "why": c.get("why", ""), + "reasons": [c.get("content_type", "")], + "preview": c.get("quote", "")[:120], + "_ai_engine": attempt.provider, + }) - if selected: - if progress_callback: - progress_callback(100, f"{label} suggested {len(selected)} clips") - return selected + # Dedupe within the pool as well as against already-selected clips: several + # attempts routinely surface the same strong moment, and without this the + # top N would be the same clip repeated. + selected = _select_top_by_score( + _drop_clips_overlapping(_dedupe_clips_by_range(normalized), exclude_clips or []), + top_n, + ) - last_detail = f"{label} returned clips but none were usable (wrong length or format)" - if progress_callback: - progress_callback(0, f"{label} returned no usable clips") + if selected: + if progress_callback: + progress_callback(100, f"{label} suggested {len(selected)} clips") + return selected - if error_sink is not None: - error_sink.append(classify_cli_error(last_detail or "")) - return None - finally: - # Clean up temp file - try: - os.unlink(prompt_file) - except Exception: - pass + if progress_callback: + progress_callback(0, f"{label} returned no usable clips") + if error_sink is not None: + error_sink.append( + f"{label} returned clips but none were usable (wrong length or format)" + ) + return None def suggest_initial_with_claude( diff --git a/backend/services/content_generator.py b/backend/services/content_generator.py index 99b20c1..4abe9ed 100644 --- a/backend/services/content_generator.py +++ b/backend/services/content_generator.py @@ -1,5 +1,5 @@ """ -Per-clip content generation (titles, descriptions, tags) via AI CLI. +Per-clip content generation (titles, descriptions, tags). Single source of truth used by CLI, Web UI, and MCP. """ @@ -12,9 +12,18 @@ import threading from typing import Optional, Callable -from services.claude_suggest import _engine_label, _find_ai_cli_candidates, _run_ai_command +from config.paths import paths +from services import ai_provider from services.knowledge_base import load_kb_context as kb_load_context, warn_missing_context +# Codex silently truncates long prompts, so it gets a shortened one. The prompts +# here lead with the request precisely so this cut only costs transcript tail. +CODEX_PROMPT_LIMIT = 4000 + + +def _shorten_for_codex(engine: str, prompt: str) -> str: + return prompt[:CODEX_PROMPT_LIMIT] if engine == "codex" else prompt + CONTENT_KB_FILES = [ ("05-title-formulas.md", 3000), @@ -186,8 +195,7 @@ def generate_custom_content( Returns {"text", "engine"} with the raw model output, or None if no AI CLI. """ - candidates = _find_ai_cli_candidates() - if not candidates: + if not ai_provider.available(): return None kb_context = load_kb_context() @@ -210,37 +218,25 @@ def generate_custom_content( TRANSCRIPT EXCERPT: {excerpt}""" - project_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..") - from utils.prompt_files import write_prompt_file - prompt_file = write_prompt_file(prompt) - try: - for idx, (cli_path, engine) in enumerate(candidates): - label = _engine_label(engine) - if progress_callback: - progress_callback(30, f"Asking {label}..." if idx == 0 else f"Retrying with {label}...") - try: - cr = _run_ai_command( - cli_path=cli_path, - engine=engine, - prompt=prompt[:4000] if engine == "codex" else prompt, - prompt_file=prompt_file, - project_dir=project_dir, - timeout=120, - ) - except Exception as exc: - print(f"Warning: {label} content generation failed: {exc}", file=sys.stderr) - continue - if cr.returncode != 0 or not cr.stdout.strip(): - continue - if progress_callback: - progress_callback(100, "Done") - return {"text": cr.stdout.strip(), "engine": engine} + attempted: list[str] = [] + + def announce(label: str) -> None: + if progress_callback: + progress_callback(30, f"Asking {label}..." if not attempted else f"Retrying with {label}...") + attempted.append(label) + + result = ai_provider.generate( + prompt, + timeout=120, + on_attempt=announce, + adapt=_shorten_for_codex, + ) + if not result.ok: + print(f"Warning: content generation failed: {result.error}", file=sys.stderr) return None - finally: - try: - os.unlink(prompt_file) - except Exception as exc: - print(f"Warning: could not remove prompt file {prompt_file}: {exc}", file=sys.stderr) + if progress_callback: + progress_callback(100, "Done") + return {"text": result.text, "engine": result.provider} def generate_clip_content( @@ -262,13 +258,12 @@ def generate_clip_content( Returns: dict with raw_text, titles, description, tags, hashtags, or None if AI unavailable """ - candidates = _find_ai_cli_candidates() - if not candidates: + providers = ai_provider.status()["providers"] + if not providers: return None - label = _engine_label(candidates[0][1]) if progress_callback: - progress_callback(0, f"Generating content via {label}...") + progress_callback(0, f"Generating content via {providers[0]['label']}...") kb_context = load_kb_context(task="title and description generation") @@ -355,19 +350,23 @@ def generate_clip_content( project_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..") - from utils.prompt_files import write_prompt_file - prompt_file = write_prompt_file(prompt) + def usable(text: str) -> bool: + parsed = _parse_content(text) + return bool(parsed["titles"] or parsed["description"]) - try: - for idx, (cli_path, engine) in enumerate(candidates): - label = _engine_label(engine) - if progress_callback: - if idx > 0: - progress_callback(0, f"Retrying content generation with {label}...") - progress_callback(30, f"Asking {label} for titles & descriptions...") + raw_text = None + engine_used = "" - raw_text = None - if engine == "claude" and partial_callback is not None: + # The Studio renders titles as they arrive. Only the Claude CLI can stream, + # so it gets first refusal; everything else falls through to the chain. + if partial_callback is not None: + cli_path = ai_provider.claude_cli_path() + if cli_path: + if progress_callback: + progress_callback(30, "Asking Claude for titles & descriptions...") + from utils.prompt_files import write_prompt_file + prompt_file = write_prompt_file(prompt) + try: raw_text = _stream_claude_content( cli_path=cli_path, prompt_file=prompt_file, @@ -375,42 +374,42 @@ def generate_clip_content( timeout=120, on_partial=partial_callback, ) - - if raw_text is None: + engine_used = "claude" + finally: try: - cr = _run_ai_command( - cli_path=cli_path, - engine=engine, - prompt=prompt[:4000] if engine == "codex" else prompt, - prompt_file=prompt_file, - project_dir=project_dir, - timeout=120, - ) - except subprocess.TimeoutExpired: - continue - except Exception: - continue - - if cr.returncode != 0 or not cr.stdout.strip(): - continue - raw_text = cr.stdout.strip() + os.unlink(prompt_file) + except OSError: + pass + if raw_text is None or not usable(raw_text): + attempted: list[str] = [] + + def announce(label: str) -> None: if progress_callback: - progress_callback(90, "Parsing content...") + if attempted: + progress_callback(0, f"Retrying content generation with {label}...") + progress_callback(30, f"Asking {label} for titles & descriptions...") + attempted.append(label) + + attempt = ai_provider.generate( + prompt, + timeout=120, + project_dir=project_dir, + on_attempt=announce, + adapt=_shorten_for_codex, + accept=usable, + ) + if not attempt.ok: + return None + raw_text, engine_used = attempt.text, attempt.provider - result = _parse_content(raw_text) - result["engine"] = engine - if not result["titles"] and not result["description"]: - continue + if progress_callback: + progress_callback(90, "Parsing content...") - if progress_callback: - progress_callback(100, f"Content ready ({len(result['titles'])} titles)") + result = _parse_content(raw_text) + result["engine"] = engine_used - return result + if progress_callback: + progress_callback(100, f"Content ready ({len(result['titles'])} titles)") - return None - finally: - try: - os.unlink(prompt_file) - except Exception: - pass + return result diff --git a/backend/services/env_settings.py b/backend/services/env_settings.py index fe4d6ca..cc3550b 100644 --- a/backend/services/env_settings.py +++ b/backend/services/env_settings.py @@ -143,7 +143,7 @@ def set_setting(key: str, value: str) -> None: if not value: raise ValueError("value is empty") if key in ("PODCLI_CLAUDE_PATH", "PODCLI_CODEX_PATH"): - from services.claude_suggest import _resolve_cli_path + from services.ai_cli import _resolve_cli_path resolved = _resolve_cli_path(value) or (value if os.path.isfile(value) else None) if not resolved: raise ValueError(f"path does not exist: {value}") @@ -160,7 +160,7 @@ def unset_setting(key: str) -> None: def run_env_action(action: str, key: Optional[str] = None, value: Optional[str] = None) -> dict[str, Any]: act = (action or "list").strip().lower() if act == "list": - from services.claude_suggest import get_ai_cli_status + from services.ai_cli import get_ai_cli_status return { "settings": list_settings(), "path": os.path.abspath(_env_path()), diff --git a/backend/services/integrations/youtube/learnings.py b/backend/services/integrations/youtube/learnings.py index 829c603..049746e 100644 --- a/backend/services/integrations/youtube/learnings.py +++ b/backend/services/integrations/youtube/learnings.py @@ -129,13 +129,11 @@ def write_semantic_learnings(top_n: int = 4, min_total: int = 6) -> Optional[str top_performers, underperformers = ranked[:top_n], ranked[-top_n:] try: - from services.claude_suggest import _find_ai_cli_candidates, _run_ai_command + from services import ai_provider except Exception: return None - candidates = _find_ai_cli_candidates() - if not candidates: + if not ai_provider.available(): return None - cli_path, engine = candidates[0] prompt = ( "You analyze short-form video performance to guide future clip selection.\n" @@ -146,22 +144,10 @@ def write_semantic_learnings(top_n: int = 4, min_total: int = 6) -> Optional[str "the top performers from the underperformers (hooks, topic, emotional beat, structure) and give " "actionable guidance for picking future shorts. No preamble, just the bullets." ) - os.makedirs(paths["working"], exist_ok=True) - prompt_file = os.path.join(paths["working"], "_perf_analysis_prompt.txt") - with open(prompt_file, "w", encoding="utf-8") as f: - f.write(prompt) - try: - res = _run_ai_command(cli_path, engine, prompt, prompt_file, paths["project_root"], timeout=180) - except Exception: - return None - finally: - try: - os.unlink(prompt_file) - except Exception: - pass - text = (res.stdout or "").strip() - if not text: + result = ai_provider.generate(prompt, timeout=180, project_dir=paths["project_root"]) + if not result.ok: return None + text = result.text now = datetime.now(timezone.utc).strftime("%Y-%m-%d") block = f"{AI_START}\n## What separates top performers (AI analysis · {now})\n\n{text}\n{AI_END}" return write_learnings(ai_block=block) diff --git a/backend/services/podcli_cloud.py b/backend/services/podcli_cloud.py new file mode 100644 index 0000000..2e33d5a --- /dev/null +++ b/backend/services/podcli_cloud.py @@ -0,0 +1,356 @@ +"""Client for podcli Pro's hosted API. + +This module is the whole of Pro that lives in the open source app: where the +token is kept, how it is sent, and what shape the request takes. There is no +secret here and nothing to crack — the server decides entitlement, so a patched +client gets a UI that says Pro and an HTTP 401. +""" + +from __future__ import annotations + +import json +import os +import time +import urllib.error +import urllib.parse +import urllib.request +from typing import Any, Optional + +from config.paths import paths + +DEFAULT_API_URL = "https://api.podcli.com" +AUTH_FILENAME = "auth.json" + + +def api_url() -> str: + """ + The API base, restricted to http and https. + + urlopen honours whatever scheme it is given, so an unchecked value here + lets `file:` turn a local path into what the code treats as an API + response. + """ + raw = (os.environ.get("PODCLI_API_URL") or DEFAULT_API_URL).rstrip("/") + if urllib.parse.urlparse(raw).scheme not in ("http", "https"): + return DEFAULT_API_URL + return raw + + +def _auth_path() -> str: + return os.path.join(paths["home"], AUTH_FILENAME) + + +def read_token() -> Optional[str]: + """The session token, from the environment or the file `podcli login` wrote.""" + env = (os.environ.get("PODCLI_TOKEN") or "").strip() + if env: + return env + try: + with open(_auth_path(), encoding="utf-8") as fh: + token = (json.load(fh).get("token") or "").strip() + return token or None + except (OSError, ValueError): + return None + + +def _auth_data() -> dict: + try: + with open(_auth_path(), encoding="utf-8") as fh: + return json.load(fh) or {} + except (OSError, ValueError): + return {} + + +def _write_auth(data: dict) -> None: + os.makedirs(paths["home"], exist_ok=True) + path = _auth_path() + # Opened with the mode already set rather than chmod'd afterwards: the + # session token would otherwise be world-readable for the width of the + # write, and a file that already existed would keep its old mode until the + # chmod landed. + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "w", encoding="utf-8") as fh: + json.dump(data, fh) + try: + os.chmod(path, 0o600) + except OSError: + pass + + +def write_token(token: str, workspace_id: str = "") -> None: + _write_auth({"token": token, "workspace_id": workspace_id}) + + +PAID_PLANS = ("pro", "team", "studio", "agency") +PLAN_TTL_SECONDS = 6 * 3600 + + +def remember_plan(plan: str) -> None: + """Cache what the server last said this workspace is entitled to.""" + data = _auth_data() + if not data.get("token"): + return + data["plan"] = (plan or "").strip().lower() + data["plan_checked_at"] = time.time() + _write_auth(data) + + +def entitled() -> bool: + """ + False only when the workspace is known to have no subscription. + + Unknown and stale both mean "try": a subscription bought a minute ago has to + work without signing out first, and the server is the only real authority. + """ + data = _auth_data() + plan = (data.get("plan") or "").strip().lower() + if not plan: + return True + if time.time() - float(data.get("plan_checked_at") or 0) > PLAN_TTL_SECONDS: + return True + return plan in PAID_PLANS + + +def clear_token() -> None: + try: + os.unlink(_auth_path()) + except OSError: + pass + + +def signed_in() -> bool: + return read_token() is not None + + +class CloudError(Exception): + def __init__(self, message: str, status: int = 0, retryable: bool = False): + super().__init__(message) + self.status = status + self.retryable = retryable + + +def request(method: str, path: str, body: Optional[dict] = None, + timeout: int = 300) -> Any: + token = read_token() + if not token: + raise CloudError("not signed in — run `podcli login`", status=401) + + data = json.dumps(body).encode("utf-8") if body is not None else None + req = urllib.request.Request( + f"{api_url()}{path}", + data=data, + method=method, + headers={ + "authorization": f"Bearer {token}", + **({"content-type": "application/json"} if data else {}), + }, + ) + + try: + with urllib.request.urlopen(req, timeout=timeout) as response: + raw = response.read().decode("utf-8") + return json.loads(raw) if raw else None + except urllib.error.HTTPError as exc: + detail, retryable = _describe(exc) + raise CloudError(detail, status=exc.code, retryable=retryable) from None + except urllib.error.URLError as exc: + raise CloudError(f"could not reach {api_url()}: {exc.reason}", + retryable=True) from None + + +def _describe(exc: urllib.error.HTTPError) -> tuple[str, bool]: + """Turn an HTTP failure into something a user can act on.""" + payload: dict = {} + try: + parsed = json.loads(exc.read().decode("utf-8")) + # A server can answer with a list or a bare string. Assuming an object + # turns the error path itself into an AttributeError. + if isinstance(parsed, dict): + payload = parsed + except Exception: + pass + detail = payload.get("error") + if isinstance(detail, list): + detail = "; ".join(str(item.get("message", item)) for item in detail) + + if exc.code == 401: + return ("podcli Pro session expired — run `podcli login` again", False) + if exc.code == 402: + return ("this workspace has no active podcli Pro subscription", False) + if exc.code == 403: + return (detail or "your role does not allow this", False) + if exc.code == 429: + used, cap = payload.get("used"), payload.get("cap") + if used is not None and cap is not None: + return (f"monthly limit reached ({used}/{cap} episodes)", False) + return (detail or "rate limited, try again shortly", True) + if exc.code >= 500 or exc.code == 503: + return (detail or "podcli Pro is temporarily unavailable", True) + return (detail or f"podcli Pro returned HTTP {exc.code}", False) + + +def generate(purpose: str, instruction: str, *, system: Optional[str] = None, + cached_context: Optional[str] = None, + episode_source_hash: Optional[str] = None, + max_tokens: int = 16000, timeout: int = 300) -> dict: + body: dict[str, Any] = { + "purpose": purpose, + "instruction": instruction, + "maxTokens": max_tokens, + } + if system: + body["system"] = system + if cached_context: + body["cachedContext"] = cached_context + if episode_source_hash: + body["episodeSourceHash"] = episode_source_hash + return request("POST", "/v1/ai/generate", body, timeout=timeout) + + +def source_hash(video_path: str) -> Optional[str]: + """Identify an episode across machines. + + Must stay byte-identical to the TypeScript implementation in + src/services/podcli-cloud.ts — the two clients hash the same file and the + server dedupes episodes on the result, so any divergence silently splits one + episode into two. First 8 MB only: distinctive enough, and digesting a 2 GB + master on every render is not. + """ + import hashlib + + digest = hashlib.sha256() + remaining = 8 * 1024 * 1024 + try: + with open(video_path, "rb") as fh: + while remaining > 0: + chunk = fh.read(min(1024 * 1024, remaining)) + if not chunk: + break + digest.update(chunk) + remaining -= len(chunk) + except OSError: + return None + return digest.hexdigest()[:32] + + +def register_clip(clip: dict) -> Optional[dict]: + return request("POST", "/v1/clips", clip, timeout=60) + + +def backfill_clips(limit: int = 200) -> tuple[int, int]: + """Push locally-recorded clips that never reached the workspace. + + Runs at sign-in so a new subscriber's back catalogue is behind the + performance model from their first session, rather than the model starting + empty and staying useless for months. + """ + from services.clips_history import load_clips_history, update_clip + + synced = failed = 0 + for entry in load_clips_history(): + if synced + failed >= limit: + break + if entry.get("cloud_id"): + continue + source = entry.get("source_video") + if not source or not os.path.exists(source): + continue + + digest = source_hash(source) + if not digest: + failed += 1 + continue + + try: + result = register_clip({ + "sourceHash": digest, + "episodeTitle": os.path.basename(source), + "title": entry.get("title"), + "startSecond": entry.get("start_second"), + "endSecond": entry.get("end_second"), + "durationSec": entry.get("duration"), + "contentType": entry.get("content_type"), + "captionStyle": entry.get("caption_style"), + "aspectRatio": entry.get("format"), + "transcriptSlice": entry.get("transcript_slice"), + }) + except CloudError as exc: + failed += 1 + # An expired session or a workspace with no subscription answers the + # same way for every remaining clip. Continuing would hash and + # upload another few hundred megabytes to be refused each time. + if exc.status in (401, 402, 403): + break + continue + + if result and result.get("id"): + update_clip(entry["id"], cloud_id=result["id"], cloud_synced=True) + synced += 1 + else: + failed += 1 + + return synced, failed + + +def prompt_block() -> str: + """What this workspace has learned, phrased for the selection prompt. + + Rendered server-side rather than assembled here, so improving how a + workspace's history is presented to the model is a deploy rather than + something that waits for every user to upgrade their CLI. + + Short timeout and silent on failure: better clips are the point, but not at + the cost of blocking a suggestion run behind a slow network. + """ + if not signed_in(): + return "" + try: + payload = request("GET", "/v1/insights/prompt-block", timeout=10) + except CloudError: + return "" + return (payload or {}).get("block") or "" + + +def list_workspaces() -> list[dict]: + return (request("GET", "/v1/workspaces", timeout=30) or {}).get("workspaces", []) + + +def create_workspace(name: str) -> dict: + payload = request("POST", "/v1/workspaces", {"name": name}, timeout=30) + write_token(payload["token"], payload["id"]) + return payload + + +def switch_workspace(workspace_id: str) -> dict: + """Switching means a new session, not a mutable field on the old one. + + Tenancy is decided once, at authentication, from the session's workspace — + so a token can never be pointed at a workspace it was not issued for. + """ + payload = request("POST", f"/v1/workspaces/{workspace_id}/session", {}, timeout=30) + write_token(payload["token"], payload["workspaceId"]) + return payload + + +def me() -> dict: + return request("GET", "/v1/auth/me", timeout=30) + + +def login(email: str, password: str) -> dict: + """Exchange credentials for a session token. Does not require an existing one.""" + data = json.dumps({"email": email, "password": password}).encode("utf-8") + req = urllib.request.Request( + f"{api_url()}/v1/auth/login", data=data, method="POST", + headers={"content-type": "application/json"}, + ) + try: + with urllib.request.urlopen(req, timeout=30) as response: + payload = json.loads(response.read().decode("utf-8")) + except urllib.error.HTTPError as exc: + detail, _ = _describe(exc) + raise CloudError(detail, status=exc.code) from None + except urllib.error.URLError as exc: + raise CloudError(f"could not reach {api_url()}: {exc.reason}") from None + + write_token(payload["token"], payload.get("workspaceId", "")) + return payload diff --git a/backend/services/thumbnail_ai.py b/backend/services/thumbnail_ai.py index d9fb2fd..3bf2114 100644 --- a/backend/services/thumbnail_ai.py +++ b/backend/services/thumbnail_ai.py @@ -480,40 +480,15 @@ def _extract_json(text: str): def _ask_ai_for_json(prompt: str, timeout: int = 30): - """Run the first available AI CLI on `prompt`, returning the first JSON value - it emits, or None if no CLI is available or none returns parseable JSON.""" - from services.claude_suggest import _find_ai_cli_candidates, _run_ai_command - - candidates = _find_ai_cli_candidates() - if not candidates: - return None - - prompt_file = None - try: - from utils.prompt_files import write_prompt_file - prompt_file = write_prompt_file(prompt) - project_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..") - for cli_path, engine in candidates: - try: - result = _run_ai_command( - cli_path=cli_path, engine=engine, prompt=prompt, - prompt_file=prompt_file, project_dir=project_dir, timeout=timeout, - ) - except Exception as e: - log_event("thumbnail-ai", "ai cli failed", level="warn", engine=engine, err=e) - continue - if result.returncode != 0 or not result.stdout.strip(): - continue - parsed = _extract_json(result.stdout) - if parsed is not None: - return parsed - finally: - if prompt_file: - try: - os.unlink(prompt_file) - except Exception: - pass - return None + """Run `prompt` through the AI provider chain, returning the first JSON value + it emits, or None if nothing is available or nothing returns parseable JSON.""" + from services import ai_provider + + parsed, result = ai_provider.generate_json(prompt, timeout=timeout) + if parsed is None: + log_event("thumbnail-ai", "ai request failed", level="warn", + err=result.error, tried=", ".join(result.attempts)) + return parsed def _thumbnail_kb_context() -> str: diff --git a/backend/services/transcription_whispercpp.py b/backend/services/transcription_whispercpp.py index de32502..6d3548c 100644 --- a/backend/services/transcription_whispercpp.py +++ b/backend/services/transcription_whispercpp.py @@ -16,6 +16,29 @@ _SPECIAL = re.compile(r"^\[.*\]$") # [_BEG_], [_TT_...], etc. +# whisper.cpp's WHISPER_AHEADS_* presets. The alignment heads are per-architecture: +# passing a preset whose layer/head indices exceed the loaded model's dimensions +# aborts whisper-cli with exit 3, so the preset must track the model, not a default. +_DTW_PRESETS = { + "tiny", "tiny.en", "base", "base.en", "small", "small.en", + "medium", "medium.en", "large.v1", "large.v2", "large.v3", "large.v3-turbo", +} +_QUANT_SUFFIX = re.compile(r"-(?:q\d+(?:_\d+)?(?:_?[a-z]+)*|f16|f32)$", re.IGNORECASE) + + +def _dtw_preset_for_model(model_path: str) -> Optional[str]: + name = os.path.basename(model_path) + for ext in (".bin", ".gguf"): + if name.lower().endswith(ext): + name = name[: -len(ext)] + break + if name.lower().startswith("ggml-"): + name = name[5:] + name = _QUANT_SUFFIX.sub("", name).lower() + if name.startswith("large-v"): + name = "large." + name[len("large-"):] + return name if name in _DTW_PRESETS else None + def _extract_wav(media_path: str, wav_path: str, ffmpeg: str = "ffmpeg") -> None: subprocess.run( @@ -142,7 +165,7 @@ def transcribe_file( whisper_cli: str = "whisper-cli", ffmpeg: str = "ffmpeg", language: Optional[str] = "en", - dtw_model: str = "base", + dtw_model: Optional[str] = None, threads: int = 4, vad: bool = False, vad_model: Optional[str] = None, @@ -165,8 +188,9 @@ def transcribe_file( cmd = [whisper_cli, "-m", model_path, "-f", wav, "-ojf", "-of", out_base, "-t", str(threads)] - if dtw_model: - cmd += ["-dtw", dtw_model] + dtw = dtw_model if dtw_model is not None else _dtw_preset_for_model(model_path) + if dtw: + cmd += ["-dtw", dtw] if vad and vad_model and os.path.exists(vad_model): # VAD removes the trailing-words-into-silence failure mode but adds a # systematic early bias (silence-removal remapping). Off by default; diff --git a/cli/internal/engine/engine.go b/cli/internal/engine/engine.go index 722be67..d6200a3 100644 --- a/cli/internal/engine/engine.go +++ b/cli/internal/engine/engine.go @@ -151,6 +151,34 @@ func MCPServer() string { return "" } +func SyncScript() string { + p := filepath.Join(paths.RuntimeDir(), "studio", "sync.mjs") + if exists(p) { + return p + } + return "" +} + +// RunSync reconciles this machine with the podcli Pro workspace. Ships with the +// studio bundle because the sync logic lives on the TypeScript side, alongside +// the clip history and asset registry it reconciles. +func RunSync() (int, error) { + node, script := Node(), SyncScript() + if node == "" || script == "" { + return 1, fmt.Errorf("sync not provisioned — run `podcli setup`") + } + cmd := exec.Command(node, script) + cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr + cmd.Env = nodeEnv() + if err := cmd.Run(); err != nil { + if ee, ok := err.(*exec.ExitError); ok { + return ee.ExitCode(), nil + } + return 1, err + } + return 0, nil +} + // nodeEnv builds the env a bundled Node server (studio/MCP) needs: the TS // paths.ts reads these names (note PYTHON_PATH/FFMPEG_PATH differ from the // PODCLI_* names the Python side uses). Project data stays cwd-local. diff --git a/cli/main.go b/cli/main.go index a6ba4ef..304311e 100644 --- a/cli/main.go +++ b/cli/main.go @@ -54,6 +54,12 @@ func main() { fmt.Fprintln(os.Stderr, "podcli:", err) } os.Exit(code) + case "sync": + code, err := engine.RunSync() + if err != nil { + fmt.Fprintln(os.Stderr, "podcli:", err) + } + os.Exit(code) case "config": if len(args) >= 2 && (args[1] == "get" || args[1] == "set") { os.Exit(configCmd(args[1:])) @@ -772,6 +778,9 @@ PodStack commands (run inside Claude Code / Codex): retro-episode Add --codex / --claude to pick the agent Launcher commands: + login | logout | whoami + podcli Pro account on this machine + sync Reconcile clips, assets, and knowledge with your workspace doctor Show resolved paths, interpreter, backend, ffmpeg, models version Print version update Check for and apply a newer release diff --git a/scripts/build-studio.sh b/scripts/build-studio.sh index 19f4866..1f6507a 100644 --- a/scripts/build-studio.sh +++ b/scripts/build-studio.sh @@ -22,4 +22,6 @@ node -e "require('esbuild').buildSync({entryPoints:['dist/ui/web-server.js'],bun cp -r dist/ui/public "$out/public" # MCP stdio server (the mcp__podcli__* tools Claude/Codex drive). node -e "require('esbuild').buildSync({entryPoints:['dist/index.js'],bundle:true,platform:'node',format:'esm',outfile:'$out/mcp-server.mjs',banner:{js:\"$banner\"},logLevel:'error'})" -echo "studio + mcp bundle -> $out" +# `podcli sync` — reconciles clips, assets, and knowledge with a Pro workspace. +node -e "require('esbuild').buildSync({entryPoints:['dist/sync.js'],bundle:true,platform:'node',format:'esm',outfile:'$out/sync.mjs',banner:{js:\"$banner\"},logLevel:'error'})" +echo "studio + mcp + sync bundle -> $out" diff --git a/src/models/index.ts b/src/models/index.ts index 64054e3..908dfaf 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -2,7 +2,7 @@ export interface TaskRequest { task_id: string; - task_type: "transcribe" | "parse_transcript" | "create_clip" | "batch_clips" | "analyze_energy" | "detect_highlights" | "manage_reel" | "pack_transcript" | "detect_encoder" | "presets" | "ping" | "suggest_clips" | "find_moment" | "generate_content" | "generate_custom" | "corrections" | "manage_integrations" | "run_integration_tool" | "manage_config" | "manage_env" | "ai_cli_status"; + task_type: "transcribe" | "parse_transcript" | "create_clip" | "batch_clips" | "analyze_energy" | "detect_highlights" | "manage_reel" | "pack_transcript" | "detect_encoder" | "presets" | "ping" | "suggest_clips" | "find_moment" | "generate_content" | "generate_custom" | "corrections" | "manage_integrations" | "run_integration_tool" | "manage_config" | "manage_env" | "ai_cli_status" | "ai_provider_status"; params: Record; } @@ -277,6 +277,12 @@ export interface ClipHistoryEntry { description?: string; tags?: string; hashtags?: string; + // Set for signed-in users once the clip is mirrored to the workspace. A false + // cloud_synced marks a clip a later sweep should backfill; the local file + // stays the source of truth either way. + cloud_id?: string; + cloud_synced?: boolean; + cloud_video_uploaded?: boolean; } // === Knowledge Base Models === diff --git a/src/services/asset-sync.test.ts b/src/services/asset-sync.test.ts new file mode 100644 index 0000000..3332256 --- /dev/null +++ b/src/services/asset-sync.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { createHash } from "crypto"; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; + +const tmp = mkdtempSync(join(tmpdir(), "podcli-assetsync-test-")); +process.env.PODCLI_HOME = tmp; +process.env.PODCLI_DATA = tmp; + +const digest = (body: string) => + createHash("sha256").update(Buffer.from(body)).digest("hex").slice(0, 32); + +vi.mock("./podcli-cloud.js", () => ({ + signedIn: vi.fn(async () => true), + listAssets: vi.fn(async () => []), + uploadAsset: vi.fn(async () => ({ unchanged: false })), + checksum: (body: Buffer) => + createHash("sha256").update(body).digest("hex").slice(0, 32), +})); + +const cloud = await import("./podcli-cloud.js"); +const { push } = await import("./asset-sync.js"); +const { AssetManager } = await import("./asset-manager.js"); + +const intro = join(tmp, "intro.mp4"); +let assetName = ""; + +describe("asset push", () => { + beforeEach(async () => { + rmSync(join(tmp, "assets"), { recursive: true, force: true }); + mkdirSync(join(tmp, "assets"), { recursive: true }); + writeFileSync(intro, "intro bytes"); + vi.clearAllMocks(); + vi.mocked(cloud.signedIn).mockResolvedValue(true); + assetName = (await new AssetManager().register("Show intro", intro, "intro")).name; + }); + + it("does not re-upload an asset the workspace already holds", async () => { + vi.mocked(cloud.listAssets).mockResolvedValue([ + { id: "1", name: assetName, kind: "intro", is_default: false, + size_bytes: "11", checksum: digest("intro bytes") }, + ]); + + const report = await push(); + + expect(cloud.uploadAsset).not.toHaveBeenCalled(); + expect(report.skipped).toContain(assetName); + expect(report.uploaded).toEqual([]); + }); + + it("uploads when the local file has changed", async () => { + vi.mocked(cloud.listAssets).mockResolvedValue([ + { id: "1", name: assetName, kind: "intro", is_default: false, + size_bytes: "11", checksum: digest("something else") }, + ]); + + const report = await push(); + + expect(cloud.uploadAsset).toHaveBeenCalledTimes(1); + expect(report.uploaded).toContain(assetName); + }); + + it("still uploads when the listing cannot be read", async () => { + vi.mocked(cloud.listAssets).mockRejectedValue(new Error("offline")); + + const report = await push(); + + expect(cloud.uploadAsset).toHaveBeenCalledTimes(1); + expect(report.failed).toEqual([]); + }); +}); diff --git a/src/services/asset-sync.ts b/src/services/asset-sync.ts new file mode 100644 index 0000000..6265da1 --- /dev/null +++ b/src/services/asset-sync.ts @@ -0,0 +1,173 @@ +import { existsSync } from "fs"; +import { mkdir, readFile, writeFile } from "fs/promises"; +import { join } from "path"; +import { paths } from "../config/paths.js"; +import { AssetManager, inferType } from "./asset-manager.js"; +import * as cloud from "./podcli-cloud.js"; +import type { Asset, AssetType } from "../models/index.js"; + +/** + * Two-way sync between .podcli/assets/ and the workspace asset library. + * + * Local assets keep working untouched for everyone; this only runs for + * signed-in users. The point is that a second machine, or a teammate, gets the + * show's logo and outro without anyone emailing files around. + */ + +const SYNCABLE_KINDS: Record = { + logo: "logo", + intro: "intro", + outro: "outro", + music: "music", +} as Record; + +function cloudKind(type: AssetType): string { + return SYNCABLE_KINDS[type] ?? "other"; +} + +export type SyncReport = { + uploaded: string[]; + downloaded: string[]; + skipped: string[]; + failed: Array<{ name: string; reason: string }>; +}; + +const empty = (): SyncReport => ({ uploaded: [], downloaded: [], skipped: [], failed: [] }); + +/** + * Push local assets the workspace doesn't have. + * + * The server discards an upload whose checksum it already holds, but only after + * receiving it. Comparing first keeps a 200 MB intro off the wire on every + * sync; if the listing cannot be fetched, everything is uploaded as before. + */ +export async function push(): Promise { + const report = empty(); + if (!(await cloud.signedIn())) return report; + + const manager = new AssetManager(); + const registry = await manager.load(); + + let held = new Map(); + try { + held = new Map((await cloud.listAssets()).map((a) => [a.name, a.checksum])); + } catch { + // Fall through: an unreadable listing must not stop the push. + } + + for (const asset of registry.assets) { + if (!existsSync(asset.path)) { + report.skipped.push(asset.name); + continue; + } + try { + const body = await readFile(asset.path); + if (held.get(asset.name) === cloud.checksum(body)) { + report.skipped.push(asset.name); + continue; + } + const result = await cloud.uploadAsset( + asset.name, + cloudKind(asset.type), + body, + Boolean(asset.default), + ); + if (result?.unchanged) report.skipped.push(asset.name); + else report.uploaded.push(asset.name); + } catch (err) { + report.failed.push({ + name: asset.name, + reason: err instanceof Error ? err.message : String(err), + }); + } + } + return report; +} + +/** + * Pull workspace assets this machine is missing. + * + * Files land in .podcli/assets/ and are registered locally, so every existing + * code path — rendering, presets, the studio — finds them exactly where it + * already looks. Nothing downstream needs to know they came from a server. + */ +export async function pull(): Promise { + const report = empty(); + if (!(await cloud.signedIn())) return report; + + const manager = new AssetManager(); + const registry = await manager.load(); + const known = new Map(registry.assets.map((a) => [a.name, a])); + + let remote: Awaited>; + try { + remote = await cloud.listAssets(); + } catch (err) { + report.failed.push({ + name: "(list)", + reason: err instanceof Error ? err.message : String(err), + }); + return report; + } + + const dir = join(paths.assets, "shared"); + for (const entry of remote) { + const local = known.get(entry.name); + // A local file that already exists wins: the user's own copy is never + // silently overwritten by the workspace version. + if (local && existsSync(local.path)) { + report.skipped.push(entry.name); + continue; + } + try { + const body = await cloud.downloadAsset(entry.id); + await mkdir(dir, { recursive: true }); + // The whole name, flattened: two workspace assets called `intro/logo.png` + // and `outro/logo.png` both end in `logo.png`, and the second download + // would land on the first and leave two registry entries pointing at one + // file. + const target = join(dir, entry.name.replace(/[\\/]+/g, "-")); + await writeFile(target, body); + // The workspace already knows what this is. Re-deriving the type from the + // extension turns a `music` asset stored as .mp4 into a video. + await manager.register(entry.name, target, assetType(entry.kind) ?? inferType(target)); + report.downloaded.push(entry.name); + } catch (err) { + report.failed.push({ + name: entry.name, + reason: err instanceof Error ? err.message : String(err), + }); + } + } + return report; +} + +/** + * The kinds worth taking back from the workspace. + * + * "other" is deliberately absent even though it is a valid type both ends: it + * is what `cloudKind` uploads anything unrecognised as, so honouring it on the + * way back would turn a local video into "other" on the round trip. Guessing + * from the file is the better answer for exactly that case. + */ +const ASSET_TYPES: readonly AssetType[] = [ + "logo", "outro", "intro", "music", "video", "image", "audio", +]; + +/** The workspace's own kind, when it is one this app models. */ +function assetType(kind: string | undefined): AssetType | null { + return kind && (ASSET_TYPES as readonly string[]).includes(kind) + ? (kind as AssetType) + : null; +} + +export async function sync(): Promise { + const up = await push(); + const down = await pull(); + return { + uploaded: up.uploaded, + downloaded: down.downloaded, + skipped: [...up.skipped, ...down.skipped], + failed: [...up.failed, ...down.failed], + }; +} diff --git a/src/services/clips-history-cloud.test.ts b/src/services/clips-history-cloud.test.ts new file mode 100644 index 0000000..8bd84b0 --- /dev/null +++ b/src/services/clips-history-cloud.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; + +const tmp = mkdtempSync(join(tmpdir(), "podcli-clipcloud-test-")); +process.env.PODCLI_HOME = tmp; +process.env.PODCLI_DATA = tmp; + +vi.mock("./podcli-cloud.js", () => ({ + signedIn: vi.fn(async () => false), + sourceHash: vi.fn(async () => "abc123"), + registerClip: vi.fn(async () => ({ id: "cloud-clip-1" })), + uploadClipVideo: vi.fn(async () => true), + logClipEvent: vi.fn(async () => undefined), +})); + +const cloud = await import("./podcli-cloud.js"); +const { ClipsHistory } = await import("./clips-history.js"); + +const source = join(tmp, "episode.mp4"); +const output = join(tmp, "clip.mp4"); + +/** record() fires its cloud sync in the background; let it settle before asserting. */ +const settle = () => new Promise((resolve) => setTimeout(resolve, 10)); + +async function seed(history: InstanceType) { + return history.record({ + title: "A clip", + source_video: source, + output_path: output, + duration: 42, + start_second: 10, + end_second: 52, + format: "9:16", + } as never); +} + +describe("clip cloud sync", () => { + let history: InstanceType; + + beforeEach(() => { + rmSync(join(tmp, "history"), { recursive: true, force: true }); + mkdirSync(join(tmp, "history"), { recursive: true }); + writeFileSync(source, "source bytes"); + writeFileSync(output, "rendered bytes"); + vi.clearAllMocks(); + vi.mocked(cloud.signedIn).mockResolvedValue(false); + history = new ClipsHistory(); + }); + + it("makes no network call when signed out", async () => { + await seed(history); + const result = await history.backfillCloud(); + + expect(result).toEqual({ synced: 0, failed: 0 }); + expect(cloud.registerClip).not.toHaveBeenCalled(); + expect(cloud.uploadClipVideo).not.toHaveBeenCalled(); + }); + + it("uploads the rendered clip after registering it", async () => { + const entry = await seed(history); + await settle(); + vi.mocked(cloud.signedIn).mockResolvedValue(true); + + await history.backfillCloud(); + + expect(cloud.registerClip).toHaveBeenCalledTimes(1); + expect(cloud.uploadClipVideo).toHaveBeenCalledWith("cloud-clip-1", output); + const after = await history.findById(entry.id); + expect(after?.cloud_id).toBe("cloud-clip-1"); + expect(after?.cloud_video_uploaded).toBe(true); + }); + + it("registers a clip once when two syncs overlap", async () => { + await seed(history); + await settle(); + vi.mocked(cloud.signedIn).mockResolvedValue(true); + + await Promise.all([history.backfillCloud(), history.backfillCloud()]); + + expect(cloud.registerClip).toHaveBeenCalledTimes(1); + expect(cloud.uploadClipVideo).toHaveBeenCalledTimes(1); + }); + + it("does not re-upload a clip whose video the workspace already has", async () => { + const entry = await seed(history); + await settle(); + vi.mocked(cloud.signedIn).mockResolvedValue(true); + await history.backfillCloud(); + vi.clearAllMocks(); + vi.mocked(cloud.signedIn).mockResolvedValue(true); + + await history.backfillCloud(); + + expect(cloud.uploadClipVideo).not.toHaveBeenCalled(); + expect(cloud.registerClip).not.toHaveBeenCalled(); + expect((await history.findById(entry.id))?.cloud_video_uploaded).toBe(true); + }); +}); diff --git a/src/services/clips-history.ts b/src/services/clips-history.ts index 316a43f..4ba255a 100644 --- a/src/services/clips-history.ts +++ b/src/services/clips-history.ts @@ -40,6 +40,7 @@ export class ClipsHistory { // requests can't lose each other's edits. Cross-process safety (vs the Python // CLI) rests on the atomic temp-file rename in save(). private writeChain: Promise = Promise.resolve(); + private syncing = new Set(); private async ensureDir() { if (!existsSync(paths.history)) { @@ -86,9 +87,76 @@ export class ClipsHistory { await this.mutate((entries) => { entries.push(full); }); + void this.syncToCloud(full); return full; } + /** + * Mirror a rendered clip to the workspace, for signed-in users. + * + * Deliberately not awaited and unable to throw: a clip that rendered + * successfully must be recorded locally whether or not a server was reachable. + * The local history file remains the source of truth; this is a copy. + * + * Clips that fail to sync are left marked so a later sweep can backfill them — + * the performance model wants the whole history, not the part that happened to + * have a working network. + */ + private async syncToCloud(entry: ClipHistoryEntry): Promise { + // record() starts this in the background, so `podcli sync` can reach the + // same entry while it is still in flight and register the clip twice. + if (this.syncing.has(entry.id)) return; + this.syncing.add(entry.id); + try { + const cloud = await import("./podcli-cloud.js"); + if (!(await cloud.signedIn())) return; + + const source = entry.source_video; + if (!source) return; + + const clipId = entry.cloud_id ?? (await cloud.registerClip({ + sourceHash: await cloud.sourceHash(source), + episodeTitle: basename(source), + title: entry.title, + startSecond: entry.start_second, + endSecond: entry.end_second, + durationSec: entry.duration, + contentType: entry.content_type, + captionStyle: entry.caption_style, + aspectRatio: entry.format, + transcriptSlice: entry.transcript_slice, + }))?.id; + if (!clipId) return; + + await this.update(entry.id, { cloud_id: clipId }); + + // Metadata alone leaves a share link with nothing to play, so the + // rendered file follows it. Uploaded once: the server keeps the first + // copy and answers `unchanged` after that. + let hasVideo = entry.cloud_video_uploaded === true; + // A rendered file that no longer exists locally can never be uploaded. + // There is nothing left to do for it, and reporting it as failed on every + // run is the unfixable number this file already refuses to print. + const uploadable = !hasVideo && existsSync(entry.output_path); + + if (uploadable) { + hasVideo = await cloud.uploadClipVideo(clipId, entry.output_path); + if (hasVideo) await this.update(entry.id, { cloud_video_uploaded: true }); + } + + // Synchronised means the clip is watchable, not merely described: an + // upload that failed while still reporting success is how `podcli sync` + // exits happy with every share link playing nothing. + await this.update(entry.id, { + cloud_synced: hasVideo || !existsSync(entry.output_path), + }); + } catch { + await this.update(entry.id, { cloud_synced: false }).catch(() => {}); + } finally { + this.syncing.delete(entry.id); + } + } + // Persist every successful row of a batch render. Single source of truth for // turning backend batch results into history entries — callers used to inline // this loop, drifting on defaults and on which fields got recorded. @@ -230,12 +298,76 @@ export class ClipsHistory { async update(id: string, patch: Partial): Promise { if (!id) return null; - return this.mutate((entries) => { + const changed = await this.mutate((entries) => { const e = entries.find((x) => x.id === id); if (!e) return null; + const before = e.title; Object.assign(e, patch); - return e; + return { entry: e, previousTitle: before }; }); + if (!changed) return null; + + // A human rewriting a generated title is the clearest taste signal podcli + // gets — it says what the model produced and what a person preferred + // instead. Reported only when the title actually changed, so the sync + // bookkeeping in syncToCloud can't trigger it. + if (patch.title !== undefined && patch.title !== changed.previousTitle) { + void this.reportEvent(changed.entry, "title_edited", changed.previousTitle, patch.title); + } + return changed.entry; + } + + /** Best-effort; never blocks or fails the edit that produced it. */ + private async reportEvent( + entry: ClipHistoryEntry, + kind: "title_edited" | "discarded" | "thumbnail_regenerated", + before?: string, + after?: string, + ): Promise { + if (!entry.cloud_id) return; + try { + const cloud = await import("./podcli-cloud.js"); + if (!(await cloud.signedIn())) return; + await cloud.logClipEvent(entry.cloud_id, kind, before, after); + } catch { + // The signal is nice to have, not worth surfacing an error over. + } + } + + /** + * Push clips that never reached the workspace. + * + * Covers two cases that both matter: a render that happened while the network + * was down, and — more importantly — everything rendered *before* the user + * subscribed. A new Pro user should start with their back catalogue behind the + * performance model, not an empty history. + */ + async backfillCloud(limit = 200): Promise<{ synced: number; failed: number }> { + const cloud = await import("./podcli-cloud.js"); + if (!(await cloud.signedIn())) return { synced: 0, failed: 0 }; + + // A clip whose source video has been moved or deleted can never be hashed, + // so it can never sync. Skipping it keeps `podcli sync` quiet; counting it + // as a failure would report the same unfixable number on every run until + // people stopped reading the output. + const pending = (await this.load()) + .filter((e) => e.source_video && existsSync(e.source_video)) + .filter((e) => !e.cloud_id || !e.cloud_video_uploaded) + .slice(0, limit); + + let synced = 0; + let failed = 0; + for (const entry of pending) { + try { + await this.syncToCloud(entry); + const after = await this.findById(entry.id); + if (after?.cloud_synced) synced++; + else failed++; + } catch { + failed++; + } + } + return { synced, failed }; } // Remove a clip and the artifacts podcli rendered for it (output video, diff --git a/src/services/knowledge-sync.test.ts b/src/services/knowledge-sync.test.ts new file mode 100644 index 0000000..4a6ccee --- /dev/null +++ b/src/services/knowledge-sync.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { mkdtempSync, rmSync, mkdirSync, existsSync, readdirSync, writeFileSync, readFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; + +const tmp = mkdtempSync(join(tmpdir(), "podcli-ksync-test-")); +process.env.PODCLI_HOME = tmp; +process.env.PODCLI_DATA = tmp; + +vi.mock("./podcli-cloud.js", () => ({ + signedIn: vi.fn(async () => true), + listKnowledge: vi.fn(async () => []), + getKnowledge: vi.fn(async () => ({ content: "owned", version: 1 })), + putKnowledge: vi.fn(async () => ({ conflict: false, version: 1, unchanged: true })), +})); + +const cloud = await import("./podcli-cloud.js"); +const { sync } = await import("./knowledge-sync.js"); + +describe("knowledge sync", () => { + beforeEach(() => { + rmSync(join(tmp, "knowledge"), { recursive: true, force: true }); + // The sync state lives beside the folder, not in it. Leaving it behind made + // these tests order-dependent: the pull phase skips any path already in the + // map, so a later test passed only because an earlier one had not recorded + // a version for the same filename. + rmSync(join(tmp, "knowledge-sync.json"), { force: true }); + mkdirSync(join(tmp, "knowledge"), { recursive: true }); + vi.clearAllMocks(); + }); + + it("refuses a workspace path that escapes the knowledge folder", async () => { + vi.mocked(cloud.listKnowledge).mockResolvedValue([ + { path: "../../pwned.md", version: 1, updated_at: "" }, + ]); + + const report = await sync(); + + expect(existsSync(join(tmp, "..", "pwned.md"))).toBe(false); + expect(report.pulled).toEqual([]); + expect(report.failed[0]?.path).toBe("../../pwned.md"); + // Rejected before the content is ever requested. + expect(cloud.getKnowledge).not.toHaveBeenCalled(); + }); + + it("never pushes shipped defaults over the workspace copy on a first sync", async () => { + writeFileSync(join(tmp, "knowledge", "02-voice-and-tone.md"), "# shipped default"); + vi.mocked(cloud.listKnowledge).mockResolvedValue([ + { path: "02-voice-and-tone.md", version: 7, updated_at: "" }, + ]); + vi.mocked(cloud.getKnowledge).mockResolvedValue({ + content: "# the team's real voice guide", version: 7, + }); + + const report = await sync(); + + expect(cloud.putKnowledge).not.toHaveBeenCalled(); + expect(report.conflicts).toEqual([{ path: "02-voice-and-tone.md", theirVersion: 7 }]); + // Neither copy is lost. + expect(readFileSync(join(tmp, "knowledge", "02-voice-and-tone.md"), "utf-8")) + .toBe("# shipped default"); + expect(readFileSync(join(tmp, "knowledge", "02-voice-and-tone.md.workspace-7"), "utf-8")) + .toBe("# the team's real voice guide"); + }); + + it("adopts the workspace version when both copies already match", async () => { + writeFileSync(join(tmp, "knowledge", "05-title-formulas.md"), "# same bytes"); + vi.mocked(cloud.listKnowledge).mockResolvedValue([ + { path: "05-title-formulas.md", version: 4, updated_at: "" }, + ]); + vi.mocked(cloud.getKnowledge).mockResolvedValue({ content: "# same bytes", version: 4 }); + + const report = await sync(); + + expect(cloud.putKnowledge).not.toHaveBeenCalled(); + expect(report.unchanged).toEqual(["05-title-formulas.md"]); + expect(report.conflicts).toEqual([]); + expect(existsSync(join(tmp, "knowledge", "05-title-formulas.md.workspace-4"))).toBe(false); + }); + + it("pushes a local file the workspace does not have", async () => { + writeFileSync(join(tmp, "knowledge", "99-mine.md"), "# only here"); + vi.mocked(cloud.listKnowledge).mockResolvedValue([]); + vi.mocked(cloud.putKnowledge).mockResolvedValue({ + conflict: false, version: 1, unchanged: false, + }); + + const report = await sync(); + + expect(cloud.putKnowledge).toHaveBeenCalledWith("99-mine.md", "# only here", undefined); + expect(report.pushed).toEqual(["99-mine.md"]); + }); + + it("pulls a file the workspace has and this machine does not", async () => { + vi.mocked(cloud.listKnowledge).mockResolvedValue([ + { path: "02-voice-and-tone.md", version: 3, updated_at: "" }, + ]); + + const report = await sync(); + + expect(report.pulled).toEqual(["02-voice-and-tone.md"]); + expect(readdirSync(join(tmp, "knowledge"))).toContain("02-voice-and-tone.md"); + }); +}); diff --git a/src/services/knowledge-sync.ts b/src/services/knowledge-sync.ts new file mode 100644 index 0000000..cbd529c --- /dev/null +++ b/src/services/knowledge-sync.ts @@ -0,0 +1,172 @@ +import { existsSync } from "fs"; +import { mkdir, readFile, readdir, writeFile } from "fs/promises"; +import { dirname, join, relative, resolve, sep } from "path"; +import { paths } from "../config/paths.js"; +import * as cloud from "./podcli-cloud.js"; + +/** + * Sync .podcli/knowledge/ with the workspace. + * + * This is the shared brand brain: voice, banned words, title formulas, + * thumbnail rules. A new editor joining a team should inherit all of it by + * signing in, rather than being sent a folder over Slack. + * + * Free podcli keeps these files local and fully effective, as it always will. + */ + +const STATE_FILE = "knowledge-sync.json"; + +/** + * Version of each file as of the last successful sync. + * + * Without this there is no way to tell "I edited this" from "they edited this" + * — both just look like a difference — and every sync would either clobber + * someone or refuse to do anything. + */ +type SyncState = Record; + +async function loadState(): Promise { + try { + return JSON.parse(await readFile(join(paths.home, STATE_FILE), "utf-8")); + } catch { + return {}; + } +} + +async function saveState(state: SyncState): Promise { + await mkdir(paths.home, { recursive: true }); + await writeFile(join(paths.home, STATE_FILE), JSON.stringify(state, null, 2), "utf-8"); +} + +/** + * The workspace decides these filenames, so a server that returned + * `../../.zshrc` would otherwise have this write anywhere the user can. + */ +function insideKnowledge(path: string): string | null { + const root = resolve(paths.knowledge); + const target = resolve(root, path); + return target.startsWith(root + sep) ? target : null; +} + +/** + * Every .md under the knowledge folder, as workspace-style relative paths. + * + * Walked by hand rather than with `readdir({ recursive })`: that option needs + * Node 20.1 and `dirent.parentPath` needs 20.12, while podcli supports 18. CI + * runs 20, so the crash would have reached Node 18 users rather than the build. + * + * Recursive at all because the workspace accepts nested paths: a flat listing + * pulled `brand/voice.md` once and then never pushed a local edit to it again. + */ +async function localFiles(): Promise { + if (!existsSync(paths.knowledge)) return []; + + const found: string[] = []; + const walk = async (dir: string): Promise => { + const entries = await readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + const full = join(dir, entry.name); + if (entry.isDirectory()) await walk(full); + else if (entry.isFile() && entry.name.endsWith(".md")) { + found.push(relative(paths.knowledge, full).split(sep).join("/")); + } + } + }; + + await walk(paths.knowledge); + return found.sort(); +} + +export type KnowledgeSyncReport = { + pushed: string[]; + pulled: string[]; + unchanged: string[]; + conflicts: Array<{ path: string; theirVersion: number }>; + failed: Array<{ path: string; reason: string }>; +}; + +export async function sync(): Promise { + const report: KnowledgeSyncReport = { + pushed: [], pulled: [], unchanged: [], conflicts: [], failed: [], + }; + if (!(await cloud.signedIn())) return report; + + const state = await loadState(); + const remote = new Map((await cloud.listKnowledge()).map((f) => [f.path, f])); + const local = await localFiles(); + const localSet = new Set(local); + + // Reconciled before anything is pushed. podcli ships default knowledge files, + // so a machine that has never synced has a full set of boilerplate that would + // otherwise be pushed straight over the workspace's real one — the server + // skips its conflict check when no expectedVersion is sent. + const unresolved = new Set(); + for (const [path, meta] of remote) { + const target = insideKnowledge(path); + if (!target) { + report.failed.push({ path, reason: "path escapes the knowledge folder" }); + unresolved.add(path); + continue; + } + if (state[path] !== undefined) continue; + + try { + const file = await cloud.getKnowledge(path); + const version = file.version ?? meta.version; + + if (!localSet.has(path)) { + await mkdir(dirname(target), { recursive: true }); + await writeFile(target, file.content, "utf-8"); + state[path] = version; + report.pulled.push(path); + continue; + } + + // Both sides have this file and nothing records which came first. Equal + // content is simply adopted; otherwise the workspace copy lands beside + // the local one and a human decides. + const mine = await readFile(target, "utf-8"); + if (mine === file.content) { + state[path] = version; + report.unchanged.push(path); + } else { + await writeFile(join(paths.knowledge, `${path}.workspace-${version}`), + file.content, "utf-8"); + report.conflicts.push({ path, theirVersion: version }); + } + unresolved.add(path); + } catch (err) { + report.failed.push({ path, reason: err instanceof Error ? err.message : String(err) }); + unresolved.add(path); + } + } + + for (const path of local) { + if (unresolved.has(path)) continue; + const content = await readFile(join(paths.knowledge, path), "utf-8"); + const known = state[path]; + try { + const result = await cloud.putKnowledge(path, content, known); + if (result.conflict) { + // Neither copy is discarded. The workspace version is written beside + // the local one so a human can compare and merge; nobody's work is + // thrown away by a sync running in the background. + const version = Number(result.version); + const suffix = Number.isFinite(version) ? version : "remote"; + const theirs = join(paths.knowledge, `${path}.workspace-${suffix}`); + await writeFile(theirs, result.content, "utf-8"); + report.conflicts.push({ path, theirVersion: version }); + continue; + } + state[path] = result.version; + if (result.unchanged) report.unchanged.push(path); + else report.pushed.push(path); + } catch (err) { + report.failed.push({ path, reason: err instanceof Error ? err.message : String(err) }); + } + } + + + await saveState(state); + return report; +} diff --git a/src/services/podcli-cloud.ts b/src/services/podcli-cloud.ts new file mode 100644 index 0000000..c669777 --- /dev/null +++ b/src/services/podcli-cloud.ts @@ -0,0 +1,292 @@ +import { createHash } from "node:crypto"; +import { createReadStream } from "node:fs"; +import { readFile, stat } from "node:fs/promises"; +import { join } from "node:path"; +import { paths } from "../config/paths.js"; + +/** + * Client for podcli Pro's hosted API. + * + * The Python backend has its own copy of this because the two runtimes cannot + * share one — deliberate duplication of about eighty lines, not an accident. + * + * Nothing here is secret. The server decides entitlement, so a patched client + * gets an HTTP 401 rather than free Pro. + */ + +const DEFAULT_API_URL = "https://api.podcli.com"; + +export function apiUrl(): string { + return (process.env.PODCLI_API_URL || DEFAULT_API_URL).replace(/\/+$/, ""); +} + +/** + * Read on every call, deliberately not cached. + * + * The studio server is long-running, so a cached token survives `podcli logout` + * in another terminal and the UI keeps claiming the user is signed in. Reading a + * small file costs microseconds against the HTTP request that follows it, so + * caching bought nothing and cost correctness. + */ +export async function readToken(): Promise { + const fromEnv = (process.env.PODCLI_TOKEN || "").trim(); + if (fromEnv) return fromEnv; + try { + const raw = await readFile(join(paths.home, "auth.json"), "utf-8"); + return ((JSON.parse(raw).token as string | undefined) || "").trim() || null; + } catch { + return null; + } +} + +export async function signedIn(): Promise { + return (await readToken()) !== null; +} + +async function request(method: string, path: string, body?: unknown, timeoutMs = 30_000) { + const token = await readToken(); + if (!token) throw new Error("not signed in"); + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetch(`${apiUrl()}${path}`, { + method, + headers: { + authorization: `Bearer ${token}`, + ...(body === undefined ? {} : { "content-type": "application/json" }), + }, + body: body === undefined ? undefined : JSON.stringify(body), + signal: controller.signal, + }); + if (!response.ok) { + const detail = await response.text().catch(() => ""); + throw new Error(`HTTP ${response.status}${detail ? `: ${detail.slice(0, 200)}` : ""}`); + } + const text = await response.text(); + return text ? JSON.parse(text) : null; + } finally { + clearTimeout(timer); + } +} + +/** + * Identifies an episode across machines. + * + * Hashing the first 8 MB rather than the whole file: a 2 GB master would take + * seconds to digest and the head of a video is more than distinctive enough to + * key on. Two editors working from the same file land on the same episode. + */ +export async function sourceHash(videoPath: string): Promise { + const hash = createHash("sha256"); + const stream = createReadStream(videoPath, { start: 0, end: 8 * 1024 * 1024 - 1 }); + for await (const chunk of stream) hash.update(chunk as Buffer); + return hash.digest("hex").slice(0, 32); +} + +export type ClipRegistration = { + sourceHash: string; + episodeTitle?: string; + episodeDuration?: number; + title?: string; + startSecond?: number; + endSecond?: number; + durationSec?: number; + contentType?: string; + captionStyle?: string; + aspectRatio?: string; + aiEngine?: string; + score?: number; + quote?: string; + reasoning?: string; + transcriptSlice?: string; + extra?: Record; +}; + +export async function registerClip(clip: ClipRegistration): Promise<{ id: string } | null> { + return request("POST", "/v1/clips", clip); +} + +/** Matches the server's body cap; a larger file is refused before the upload. */ +const MAX_CLIP_BYTES = 200 * 1024 * 1024; + +/** + * Send the rendered clip itself, so share links have something to play. + * + * Only the rendered clip travels — never the source video. It is the whole + * reason a share link can exist without the storage cost of the master. + */ +export async function uploadClipVideo(clipId: string, filePath: string): Promise { + const token = await readToken(); + if (!token) return false; + + const { size } = await stat(filePath); + if (size === 0 || size > MAX_CLIP_BYTES) return false; + + const response = await fetch(`${apiUrl()}/v1/clips/${clipId}/video`, { + method: "PUT", + headers: { authorization: `Bearer ${token}`, "content-type": "video/mp4" }, + body: await readFile(filePath), + signal: AbortSignal.timeout(300_000), + }); + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${(await response.text()).slice(0, 200)}`); + } + return true; +} + +export type Breakdown = { + key: string; + clips: number; + retention: number | null; + ctr: number | null; + views: number | null; +}; + +export type Insights = { + sampleSize: number; + byContentType: Breakdown[]; + byCaptionStyle: Breakdown[]; + byLength: Breakdown[]; + topClips: Array<{ title: string; retention: number; views: number; content_type: string }>; + guidance: string[]; +}; + +export type Preferences = { + titleEdits: Array<{ before: string; after: string }>; + discardRate: number | null; + observations: string[]; +}; + +// Nullable because `request` returns null for an empty body, and a caller that +// trusts the declared shape would dereference it. +export async function getInsights(): Promise { + return request("GET", "/v1/insights"); +} + +export async function getPreferences(): Promise { + return request("GET", "/v1/insights/preferences"); +} + +export async function whoami(): Promise<{ + workspaceId: string; + role: string; + plan: string; + workspace: { name: string; episodes_used: number }; +}> { + return request("GET", "/v1/auth/me", undefined, 10_000); +} + +export type RemoteKnowledgeFile = { path: string; version: number; updated_at: string }; + +export async function listKnowledge(): Promise { + const payload = await request("GET", "/v1/knowledge"); + return payload?.files ?? []; +} + +export async function getKnowledge(path: string): Promise<{ content: string; version: number }> { + return request("GET", `/v1/knowledge/file?path=${encodeURIComponent(path)}`); +} + +export type PutKnowledgeResult = + | { conflict: false; version: number; unchanged: boolean } + | { conflict: true; version: number; content: string }; + +export async function putKnowledge( + path: string, + content: string, + expectedVersion?: number, +): Promise { + const token = await readToken(); + if (!token) throw new Error("not signed in"); + + const response = await fetch(`${apiUrl()}/v1/knowledge/file`, { + method: "PUT", + headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, + body: JSON.stringify({ path, content, expectedVersion }), + signal: AbortSignal.timeout(30_000), + }); + + // A 409 is an expected outcome here, not an error: someone else edited the + // file. The body carries their version so the caller can show both. + if (response.status === 409) { + const body = await response.json(); + return { conflict: true, version: body.version, content: body.content }; + } + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${(await response.text()).slice(0, 200)}`); + } + const body = await response.json(); + return { conflict: false, version: body.version, unchanged: Boolean(body.unchanged) }; +} + +export type RemoteAsset = { + id: string; + name: string; + kind: string; + is_default: boolean; + size_bytes: string; + checksum: string; +}; + +/** Mirrors how the workspace digests an asset, so an upload can be skipped. */ +export function checksum(body: Buffer): string { + return createHash("sha256").update(body).digest("hex").slice(0, 32); +} + +export async function listAssets(): Promise { + const payload = await request("GET", "/v1/assets"); + return payload?.assets ?? []; +} + +export async function uploadAsset( + name: string, + kind: string, + body: Buffer, + isDefault = false, +): Promise<{ id: string; unchanged?: boolean }> { + const token = await readToken(); + if (!token) throw new Error("not signed in"); + + const params = new URLSearchParams({ name, kind, isDefault: String(isDefault) }); + const response = await fetch(`${apiUrl()}/v1/assets?${params}`, { + method: "PUT", + headers: { + authorization: `Bearer ${token}`, + "content-type": "application/octet-stream", + }, + // Node's fetch wants a view, not the Buffer's whole underlying pool. + body: new Uint8Array(body), + signal: AbortSignal.timeout(300_000), + }); + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${(await response.text()).slice(0, 200)}`); + } + return response.json(); +} + +export async function downloadAsset(id: string): Promise { + const token = await readToken(); + if (!token) throw new Error("not signed in"); + + const response = await fetch(`${apiUrl()}/v1/assets/${id}/download`, { + headers: { authorization: `Bearer ${token}` }, + signal: AbortSignal.timeout(300_000), + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return Buffer.from(await response.arrayBuffer()); +} + +export type ClipEventKind = + | "suggested" | "rendered" | "discarded" + | "title_edited" | "thumbnail_regenerated" + | "approved" | "changes_requested" | "published"; + +export async function logClipEvent( + cloudClipId: string, + kind: ClipEventKind, + before?: string, + after?: string, +): Promise { + await request("POST", `/v1/clips/${cloudClipId}/events`, { kind, before, after }); +} diff --git a/src/sync.ts b/src/sync.ts new file mode 100644 index 0000000..059afb9 --- /dev/null +++ b/src/sync.ts @@ -0,0 +1,86 @@ +import { ClipsHistory } from "./services/clips-history.js"; +import * as assetSync from "./services/asset-sync.js"; +import * as knowledgeSync from "./services/knowledge-sync.js"; +import * as cloud from "./services/podcli-cloud.js"; + +/** + * `podcli sync` — reconcile this machine with the workspace. + * + * Clips, assets, and knowledge each sync automatically at the moments that + * matter (render, login), so this is the manual catch-up: after working + * offline, after a teammate changes the brand guide, or on a new machine. + * + * Every step is independent and none can fail another — a knowledge conflict + * must not stop assets from arriving. + */ +async function main(): Promise { + if (!(await cloud.signedIn())) { + console.log("Not signed in to podcli Pro. Run `podcli login` first."); + return 1; + } + + let problems = 0; + + console.log("Syncing clips..."); + try { + const { synced, failed } = await new ClipsHistory().backfillCloud(); + console.log( + synced || failed + ? ` ${synced} synced${failed ? `, ${failed} failed` : ""}` + : " already up to date", + ); + problems += failed; + } catch (err) { + console.log(` failed: ${err instanceof Error ? err.message : String(err)}`); + problems++; + } + + console.log("Syncing assets..."); + try { + const report = await assetSync.sync(); + const parts = [ + report.uploaded.length && `${report.uploaded.length} uploaded`, + report.downloaded.length && `${report.downloaded.length} downloaded`, + ].filter(Boolean); + console.log(parts.length ? ` ${parts.join(", ")}` : " already up to date"); + for (const f of report.failed) console.log(` ${f.name}: ${f.reason}`); + problems += report.failed.length; + } catch (err) { + console.log(` failed: ${err instanceof Error ? err.message : String(err)}`); + problems++; + } + + console.log("Syncing knowledge base..."); + try { + const report = await knowledgeSync.sync(); + const parts = [ + report.pushed.length && `${report.pushed.length} pushed`, + report.pulled.length && `${report.pulled.length} pulled`, + ].filter(Boolean); + console.log(parts.length ? ` ${parts.join(", ")}` : " already up to date"); + + for (const conflict of report.conflicts) { + console.log( + ` conflict: ${conflict.path} — the workspace copy was saved as ` + + `${conflict.path}.workspace-${conflict.theirVersion}. Merge it, then sync again.`, + ); + } + for (const f of report.failed) console.log(` ${f.path}: ${f.reason}`); + problems += report.failed.length; + } catch (err) { + console.log(` failed: ${err instanceof Error ? err.message : String(err)}`); + problems++; + } + + // Conflicts are not counted as problems: they are a normal outcome that + // needs a human, not a failure that needs a retry. + return problems > 0 ? 1 : 0; +} + +main().then( + (code) => process.exit(code), + (err) => { + console.error("sync failed:", err instanceof Error ? err.message : String(err)); + process.exit(1); + }, +); diff --git a/src/ui/client/AccountChip.tsx b/src/ui/client/AccountChip.tsx new file mode 100644 index 0000000..d281d8d --- /dev/null +++ b/src/ui/client/AccountChip.tsx @@ -0,0 +1,45 @@ +import React, { useEffect, useState } from "react"; + +type Account = { + signedIn: boolean; + workspace?: string; + plan?: string; + episodesUsed?: number; + cap?: number; +}; + +/** + * Signed-in state at the bottom of the sidebar. + * + * Shows nothing when signed out. Sync that runs invisibly feels like sync that + * isn't running, so a subscriber should be able to see their workspace without + * going looking for it. + */ +export default function AccountChip() { + const [account, setAccount] = useState(null); + + useEffect(() => { + fetch("/api/pro/account") + .then((r) => r.json()) + .then(setAccount) + .catch(() => setAccount({ signedIn: false })); + }, []); + + if (!account?.signedIn) return null; + + const used = account.episodesUsed ?? 0; + const cap = account.cap ?? 0; + // Only surface the quota once it's close enough to matter. A counter at 2/10 + // is noise; at 8/10 it's the difference between planning and being surprised. + const showQuota = cap > 0 && used / cap >= 0.7; + + return ( +
+
{account.workspace}
+
+ {account.plan === "team" ? "Team" : "Pro"} + {showQuota && ` · ${used}/${cap} episodes`} +
+
+ ); +} diff --git a/src/ui/client/AiSetup.tsx b/src/ui/client/AiSetup.tsx new file mode 100644 index 0000000..ba00706 --- /dev/null +++ b/src/ui/client/AiSetup.tsx @@ -0,0 +1,142 @@ +import React, { useEffect, useState } from "react"; +import { Cloud, Terminal, Key } from "lucide-react"; +import { labelStyle } from "./lib"; + +/** + * What podcli will use for AI, and what to do when the answer is "nothing". + * + * Two real options are offered side by side and neither is dressed up as the + * only one: install a CLI you already pay for, or let us run it. A user who + * picks the free path has solved their problem, which is the point. + */ + +type Provider = { kind: string; engine: string; label: string }; + +type Status = { + available: boolean; + providers: Provider[]; + mode: string; + api_key_set: boolean; + candidates: Array<{ engine: string; path: string }>; +}; + +const INSTALL_COMMAND = "npm install -g @anthropic-ai/claude-code"; + +function Option({ + icon, title, body, action, +}: { + icon: React.ReactNode; title: string; body: string; action: React.ReactNode; +}) { + return ( +
+
+ {icon} + {title} +
+
{body}
+ {action} +
+ ); +} + +export default function AiSetup() { + const [status, setStatus] = useState(null); + const [copied, setCopied] = useState(false); + + useEffect(() => { + fetch("/api/ai-provider-status") + .then((r) => (r.ok ? r.json() : Promise.reject(new Error(String(r.status))))) + // The types say these are always present; the server can answer with an + // error body or a partial payload, and rendering a missing array throws + // out of this component and takes the settings page with it. + .then((payload) => setStatus({ + ...payload, + providers: Array.isArray(payload?.providers) ? payload.providers : [], + candidates: Array.isArray(payload?.candidates) ? payload.candidates : [], + })) + .catch(() => setStatus(null)); + }, []); + + if (!status) return null; + + if (status.available) { + return ( +
+
AI
+
+ Using + + {status.providers.map((p) => p.label).join(" → ")} + +
+ {status.providers.length > 1 && ( +
+ podcli tries these in order, so a failure falls through to the next one + rather than stopping. +
+ )} +
+ ); + } + + return ( +
+
AI is not set up
+
+ podcli transcribes, cuts, and renders without any of this. Picking moments, + titles, and descriptions needs a model. Two ways to get one: +
+ +
+
+ + {status.candidates.length > 0 && ( + // Found but unusable is a different problem from missing, and saying + // "not detected" here would send someone to reinstall what they have. +
+ A CLI was found at {status.candidates[0].path} but did not respond. + Run {status.candidates[0].engine} once in a terminal to sign in, then reload. +
+ )} +
+ ); +} diff --git a/src/ui/client/AnalyticsPage.tsx b/src/ui/client/AnalyticsPage.tsx index dbf4fd3..0c6cd67 100644 --- a/src/ui/client/AnalyticsPage.tsx +++ b/src/ui/client/AnalyticsPage.tsx @@ -3,6 +3,7 @@ import { PageHeader } from "./Page"; import { Link } from "react-router-dom"; import { TrendingUp, Eye, Percent, MousePointerClick } from "lucide-react"; import { api, upload, fmt } from "./lib"; +import WorkspaceInsights from "./WorkspaceInsights"; interface Row { key: string; count: number; avgViews: number; avgRetention: number; avgCtr: number } interface Data { @@ -164,6 +165,8 @@ export default function AnalyticsPage() { {msg &&
{msg}
} + + {showConnect && (
Connect YouTube (read-only)
diff --git a/src/ui/client/ConfigPage.tsx b/src/ui/client/ConfigPage.tsx index 58c3e6d..aa2b4e5 100644 --- a/src/ui/client/ConfigPage.tsx +++ b/src/ui/client/ConfigPage.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useRef, useState } from "react"; import { PageHeader } from "./Page"; import { api, upload } from "./lib"; +import AiSetup from "./AiSetup"; type SettingRow = { key: string; @@ -54,7 +55,10 @@ export default function ConfigPage() { } catch { /* settings are optional */ } } + const [aiRefresh, setAiRefresh] = useState(0); + async function refreshAiCli() { + setAiRefresh((n) => n + 1); try { setAiCli(await api("/ai-cli-status")); } catch { @@ -169,6 +173,8 @@ export default function ConfigPage() { )}
+ +
AI CLI
{aiCli ? ( diff --git a/src/ui/client/Layout.tsx b/src/ui/client/Layout.tsx index 0a635ea..7043788 100644 --- a/src/ui/client/Layout.tsx +++ b/src/ui/client/Layout.tsx @@ -15,6 +15,7 @@ import { Search, } from "lucide-react"; import CommandPalette from "./CommandPalette"; +import AccountChip from "./AccountChip"; const icons: Record = { library: LayoutGrid, @@ -73,6 +74,8 @@ export default function Layout() {
Insights
Analytics + +
diff --git a/src/ui/client/WorkspaceInsights.tsx b/src/ui/client/WorkspaceInsights.tsx new file mode 100644 index 0000000..b21583e --- /dev/null +++ b/src/ui/client/WorkspaceInsights.tsx @@ -0,0 +1,118 @@ +import React, { useEffect, useState } from "react"; +import { PenLine, TrendingUp, Users } from "lucide-react"; +import { labelStyle } from "./lib"; + +/** + * Workspace-wide performance and learned house style. + * + * Renders nothing at all when signed out. A free user sees the Analytics page + * they have always seen — no locked panel, no upsell banner, no greyed-out + * button. This section exists because the workspace data exists. + */ + +type Breakdown = { key: string; clips: number; retention: number | null }; + +type Payload = { + signedIn: boolean; + insights?: { + sampleSize: number; + byContentType: Breakdown[]; + byLength: Breakdown[]; + guidance: string[]; + topClips: Array<{ title: string; retention: number; content_type: string }>; + }; + preferences?: { + observations: string[]; + discardRate: number | null; + titleEdits: Array<{ before: string; after: string }>; + }; +}; + +export default function WorkspaceInsights() { + const [data, setData] = useState(null); + + useEffect(() => { + fetch("/api/pro/insights") + .then((r) => (r.ok ? r.json() : Promise.reject(new Error(String(r.status))))) + .then(setData) + .catch(() => setData({ signedIn: false })); + }, []); + + if (!data?.signedIn || !data.insights) return null; + + const { insights, preferences } = data; + // The types say these arrays are always there. The server can answer with a + // partial payload, and reading a missing one throws out of this panel and + // takes the page it sits on with it. + const hasModel = (insights.guidance?.length ?? 0) > 0; + const hasStyle = (preferences?.observations?.length ?? 0) > 0; + + // Signed in but nothing learned yet. Say why, and say what changes it — + // an empty panel with no explanation reads as broken. + if (!hasModel && !hasStyle) { + return ( +
+
Workspace
+
+ {insights.sampleSize === 0 + ? "Connect YouTube and publish a few clips. Once performance data arrives, podcli starts picking moments based on what works for this channel." + : `Tracking ${insights.sampleSize} published clip${insights.sampleSize === 1 ? "" : "s"}. A few more and patterns become reliable enough to act on.`} +
+
+ ); + } + + return ( + <> + {hasModel && ( +
+
+ What works on this channel +
+
+ From {insights.sampleSize} published clips across your workspace. podcli uses this + when picking moments. +
+ {insights.guidance.map((line) => ( +
+ + {line} +
+ ))} +
+ )} + + {hasStyle && ( +
+
+ House style +
+
+ Learned from edits your team made to generated output. Nobody configured these. +
+ {preferences!.observations.map((line) => ( +
{line}
+ ))} + + {preferences!.titleEdits.length > 0 && ( +
+ + Recent title rewrites ({preferences!.titleEdits.length}) + +
+ {preferences!.titleEdits.slice(0, 6).map((edit, i) => ( +
+
+ {edit.before} +
+
{edit.after}
+
+ ))} +
+
+ )} +
+ )} + + ); +} diff --git a/src/ui/public/css/styles.css b/src/ui/public/css/styles.css index 75ee434..8514d95 100644 --- a/src/ui/public/css/styles.css +++ b/src/ui/public/css/styles.css @@ -1203,6 +1203,15 @@ input[type="range"]::-webkit-slider-thumb { } .sidebar-link:hover { background: var(--surface2); color: var(--text); } .sidebar-link.active { background: var(--accent-subtle); color: var(--accent); } + +/* Signed-in workspace, pinned to the foot of the sidebar so it reads as status + rather than as another navigation item. */ +.sidebar-account { margin-top: auto; padding: 12px; border-top: 1px solid var(--border); } +.sidebar-account-name { + font-size: 12px; font-weight: 700; color: var(--text); + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.sidebar-account-sub { font-size: 11px; color: var(--text3); margin-top: 2px; } .sidebar-link .ico { width: 16px; height: 16px; flex-shrink: 0; opacity: 0.85; } .sidebar-link.disabled { opacity: 0.4; pointer-events: none; } .sidebar-link .soon { margin-left: auto; font-size: 9px; font-weight: 700; letter-spacing: 0.5px; color: var(--text3); border: 1px solid var(--border); border-radius: 4px; padding: 1px 5px; } diff --git a/src/ui/web-server.ts b/src/ui/web-server.ts index ece7d3c..899a3ab 100644 --- a/src/ui/web-server.ts +++ b/src/ui/web-server.ts @@ -1425,6 +1425,47 @@ app.get("/api/job/:id/stream", (req, res) => { /** * GET /api/outputs — List finished clips */ +/** + * Workspace-wide performance, for signed-in users. + * + * Returns `{ signedIn: false }` rather than an error when there's no account: + * the studio renders the same Analytics page either way, just without the + * workspace section. Nothing is greyed out and nothing says "upgrade". + */ +app.get("/api/pro/insights", async (_req, res) => { + try { + const cloud = await import("../services/podcli-cloud.js"); + if (!(await cloud.signedIn())) return res.json({ signedIn: false }); + + const [insights, preferences] = await Promise.all([ + cloud.getInsights(), + cloud.getPreferences(), + ]); + res.json({ signedIn: true, insights, preferences }); + } catch (err) { + // A workspace that can't be reached must not break the local analytics the + // page is primarily there to show. + res.json({ signedIn: false, error: err instanceof Error ? err.message : String(err) }); + } +}); + +app.get("/api/pro/account", async (_req, res) => { + try { + const cloud = await import("../services/podcli-cloud.js"); + if (!(await cloud.signedIn())) return res.json({ signedIn: false }); + const me = await cloud.whoami(); + res.json({ + signedIn: true, + workspace: me.workspace?.name, + plan: me.plan, + episodesUsed: me.workspace?.episodes_used, + cap: me.plan === "team" ? 40 : 10, + }); + } catch { + res.json({ signedIn: false }); + } +}); + app.get("/api/outputs", async (_req, res) => { try { await mkdir(paths.output, { recursive: true }); @@ -2335,6 +2376,15 @@ app.get("/api/ai-cli-status", async (_req, res) => { } }); +app.get("/api/ai-provider-status", async (_req, res) => { + try { + const result = await executor.execute>("ai_provider_status", {}); + res.json(result.data ?? { available: false, providers: [], candidates: [] }); + } catch (err: unknown) { + res.status(500).json({ error: errMsg(err) }); + } +}); + app.get("/api/youtube/config", (_req, res) => { try { const all = JSON.parse(readFileSync(paths.integrations, "utf-8")); diff --git a/tests/test_ai_fallback.py b/tests/test_ai_fallback.py index f54a70f..23fe0a4 100644 --- a/tests/test_ai_fallback.py +++ b/tests/test_ai_fallback.py @@ -12,6 +12,8 @@ if BACKEND_ROOT not in sys.path: sys.path.insert(0, BACKEND_ROOT) +from services import ai_cli as ai +from services import ai_provider as ap from services import claude_suggest as cs from services import content_generator as cg from services import thumbnail_ai as tai @@ -58,11 +60,11 @@ def test_suggest_with_claude_retries_with_codex_after_runtime_failure(self): }) with mock.patch.object( - cs, - "_find_ai_cli_candidates", - return_value=[("/tmp/claude", "claude"), ("/tmp/codex", "codex")], + ap, + "_chain", + return_value=[("cli", "/tmp/claude", "claude"), ("cli", "/tmp/codex", "codex")], ), mock.patch.object( - cs, + ai, "_run_ai_command", side_effect=[ subprocess.CompletedProcess(args=["claude"], returncode=1, stdout="", stderr="claude down"), @@ -218,11 +220,11 @@ def test_suggest_with_claude_reports_actual_timeout_limit(self): progress = [] with mock.patch.object( - cs, - "_find_ai_cli_candidates", - return_value=[("/tmp/claude", "claude")], + ap, + "_chain", + return_value=[("cli", "/tmp/claude", "claude")], ), mock.patch.object( - cs, + ai, "_run_ai_command", side_effect=subprocess.TimeoutExpired(cmd=["claude"], timeout=90), ): @@ -264,11 +266,11 @@ def test_generate_clip_content_retries_with_codex(self): #power #energy #datacenters #ai #infrastructure""" with mock.patch.object( - cg, - "_find_ai_cli_candidates", - return_value=[("/tmp/claude", "claude"), ("/tmp/codex", "codex")], + ap, + "_chain", + return_value=[("cli", "/tmp/claude", "claude"), ("cli", "/tmp/codex", "codex")], ), mock.patch.object( - cg, + ai, "_run_ai_command", side_effect=[ subprocess.CompletedProcess(args=["claude"], returncode=1, stdout="", stderr="claude down"), @@ -300,11 +302,11 @@ def test_thumbnail_layout_retries_with_codex(self): """ with mock.patch.object( - cs, - "_find_ai_cli_candidates", - return_value=[("/tmp/claude", "claude"), ("/tmp/codex", "codex")], + ap, + "_chain", + return_value=[("cli", "/tmp/claude", "claude"), ("cli", "/tmp/codex", "codex")], ), mock.patch.object( - cs, + ai, "_run_ai_command", side_effect=[ subprocess.CompletedProcess(args=["claude"], returncode=1, stdout="", stderr="claude down"), @@ -329,8 +331,8 @@ def test_find_cli_resolves_windows_cmd_shim(self): shim = os.path.join(tmp, "claude.cmd") with open(shim, "w", encoding="utf-8") as fh: fh.write("@echo off\n") - with mock.patch.object(cs.sys, "platform", "win32"): - found = cs._find_cli("claude", [os.path.join(tmp, "claude")]) + with mock.patch.object(ai.sys, "platform", "win32"): + found = ai._find_cli("claude", [os.path.join(tmp, "claude")]) self.assertEqual(found, shim) @unittest.skipIf(os.name == "nt", "POSIX executable discovery; Windows uses .cmd/.exe shims") @@ -343,7 +345,7 @@ def test_find_cli_uses_home_bin(self): fh.write("#!/bin/sh\n") with mock.patch.dict(os.environ, {"HOME": home, "PATH": ""}, clear=False): with mock.patch("os.path.expanduser", side_effect=lambda p: p.replace("~", home)): - found = cs._find_cli("claude", []) + found = ai._find_cli("claude", []) self.assertEqual(found, cli) @unittest.skipIf(os.name == "nt", "POSIX executable discovery; Windows uses .cmd/.exe shims") @@ -359,16 +361,16 @@ def test_npmrc_prefix_is_searched(self): fh.write(f"prefix={prefix}\n") with mock.patch.dict(os.environ, {"HOME": home, "PATH": ""}, clear=False): with mock.patch("os.path.expanduser", side_effect=lambda p: p.replace("~", home)): - with mock.patch.object(cs, "_package_manager_bin_dirs", return_value=[]): - with mock.patch.object(cs, "_shell_lookup", return_value=None): - found = cs._find_cli("claude", []) + with mock.patch.object(ai, "_package_manager_bin_dirs", return_value=[]): + with mock.patch.object(ai, "_shell_lookup", return_value=None): + found = ai._find_cli("claude", []) self.assertEqual(found, cli) def test_parse_shell_lookup_line_handles_type_a(self): with tempfile.NamedTemporaryFile(delete=False) as tmp: path = tmp.name try: - self.assertEqual(cs._parse_shell_lookup_line(f"claude is {path}"), path) + self.assertEqual(ai._parse_shell_lookup_line(f"claude is {path}"), path) finally: os.remove(path) @@ -381,7 +383,7 @@ def test_find_cli_uses_legacy_claude_local_path(self): fh.write("#!/bin/sh\n") with mock.patch.dict(os.environ, {"HOME": home, "PATH": ""}, clear=False): with mock.patch("os.path.expanduser", side_effect=lambda p: p.replace("~", home)): - found = cs._find_cli("claude", cs._ai_cli_search_paths("claude")) + found = ai._find_cli("claude", ai._ai_cli_search_paths("claude")) self.assertEqual(found, cli) def test_env_override_prefers_podcli_claude_path(self): @@ -390,8 +392,8 @@ def test_env_override_prefers_podcli_claude_path(self): with open(cli, "w", encoding="utf-8") as fh: fh.write("#!/bin/sh\n") with mock.patch.dict(os.environ, {"PODCLI_CLAUDE_PATH": cli, "PATH": ""}, clear=False): - with mock.patch.object(cs, "_find_cli", return_value=None) as find_mock: - candidates = cs._find_ai_cli_candidates() + with mock.patch.object(ai, "_find_cli", return_value=None) as find_mock: + candidates = ai._find_ai_cli_candidates() find_mock.assert_called_once() self.assertEqual(find_mock.call_args.args[0], "codex") self.assertEqual(candidates[0], (cli, "claude")) @@ -406,7 +408,7 @@ def test_configured_path_reads_from_env_file(self): fh.write(f"PODCLI_CLAUDE_PATH={cli}\n") with mock.patch.dict(os.environ, {"PODCLI_ENV_FILE": env_file, "PATH": ""}, clear=False): os.environ.pop("PODCLI_CLAUDE_PATH", None) - found = cs._configured_cli_path("claude") + found = ai._configured_cli_path("claude") self.assertEqual(found, cli) def test_find_cli_falls_back_to_shell_lookup(self): @@ -414,18 +416,18 @@ def test_find_cli_falls_back_to_shell_lookup(self): cli = os.path.join(tmp, "claude") with open(cli, "w", encoding="utf-8") as fh: fh.write("#!/bin/sh\n") - with mock.patch.object(cs, "_shell_lookup", return_value=cli): + with mock.patch.object(ai, "_shell_lookup", return_value=cli): with mock.patch("shutil.which", return_value=None): - found = cs._find_cli("claude", []) + found = ai._find_cli("claude", []) self.assertEqual(found, cli) def test_get_ai_cli_status_reports_candidates(self): with mock.patch.object( - cs, + ai, "_find_ai_cli_candidates", return_value=[("/tmp/claude", "claude")], - ), mock.patch.object(cs, "_configured_cli_path", return_value=None): - status = cs.get_ai_cli_status() + ), mock.patch.object(ai, "_configured_cli_path", return_value=None): + status = ai.get_ai_cli_status() self.assertTrue(status["available"]) self.assertEqual(status["candidates"][0]["engine"], "claude") with tempfile.TemporaryDirectory() as tmp: @@ -436,14 +438,14 @@ def test_get_ai_cli_status_reports_candidates(self): with open(cli, "w", encoding="utf-8") as fh: fh.write("#!/bin/sh\n") - with mock.patch("services.claude_suggest.subprocess.run") as run_mock: + with mock.patch("services.ai_cli.subprocess.run") as run_mock: run_mock.return_value = subprocess.CompletedProcess( args=[cli, "--print", "-p", "-"], returncode=0, stdout="{}", stderr="", ) - cs._run_ai_command( + ai._run_ai_command( cli_path=cli, engine="claude", prompt="find clips", diff --git a/tests/test_entitlement_chain.py b/tests/test_entitlement_chain.py new file mode 100644 index 0000000..de76acb --- /dev/null +++ b/tests/test_entitlement_chain.py @@ -0,0 +1,80 @@ +import json +import os +import sys +import tempfile +import time +import unittest +from unittest import mock + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "backend")) + +from services import ai_provider, podcli_cloud # noqa: E402 + + +class EntitlementTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.mkdtemp() + patcher = mock.patch.dict(podcli_cloud.paths, {"home": self.tmp}) + patcher.start() + self.addCleanup(patcher.stop) + # PODCLI_TOKEN would shadow the file these tests are about. + env = mock.patch.dict(os.environ, {"PODCLI_TOKEN": "", "PODCLI_AI_PROVIDER": ""}) + env.start() + self.addCleanup(env.stop) + + def write_auth(self, **fields): + with open(os.path.join(self.tmp, "auth.json"), "w", encoding="utf-8") as fh: + json.dump({"token": "t", "workspace_id": "w", **fields}, fh) + + def test_unknown_plan_still_tries_the_cloud(self): + self.write_auth() + self.assertTrue(podcli_cloud.entitled()) + + def test_free_plan_is_not_entitled(self): + self.write_auth(plan="free", plan_checked_at=time.time()) + self.assertFalse(podcli_cloud.entitled()) + + def test_paid_plan_is_entitled(self): + for plan in ("pro", "team", "studio"): + with self.subTest(plan=plan): + self.write_auth(plan=plan, plan_checked_at=time.time()) + self.assertTrue(podcli_cloud.entitled()) + + def test_a_stale_free_verdict_is_retried(self): + # A subscription bought after the last check must work without re-login. + self.write_auth(plan="free", + plan_checked_at=time.time() - podcli_cloud.PLAN_TTL_SECONDS - 1) + self.assertTrue(podcli_cloud.entitled()) + + def test_remember_plan_keeps_the_token(self): + self.write_auth() + podcli_cloud.remember_plan("pro") + with open(os.path.join(self.tmp, "auth.json"), encoding="utf-8") as fh: + data = json.load(fh) + self.assertEqual(data["token"], "t") + self.assertEqual(data["workspace_id"], "w") + self.assertEqual(data["plan"], "pro") + + def test_free_workspace_skips_the_cloud_leg(self): + self.write_auth(plan="free", plan_checked_at=time.time()) + with mock.patch.object(ai_provider.ai_cli, "_find_ai_cli_candidates", + return_value=[("/bin/claude", "claude")]): + chain = ai_provider._chain() + self.assertEqual([kind for kind, _, _ in chain], ["cli"]) + + def test_paid_workspace_puts_the_cloud_first(self): + self.write_auth(plan="pro", plan_checked_at=time.time()) + with mock.patch.object(ai_provider.ai_cli, "_find_ai_cli_candidates", + return_value=[("/bin/claude", "claude")]): + chain = ai_provider._chain() + self.assertEqual([kind for kind, _, _ in chain], ["cloud", "cli"]) + + def test_forced_cloud_mode_ignores_a_free_verdict(self): + self.write_auth(plan="free", plan_checked_at=time.time()) + with mock.patch.dict(os.environ, {"PODCLI_AI_PROVIDER": "cloud"}): + chain = ai_provider._chain() + self.assertEqual([kind for kind, _, _ in chain], ["cloud"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_find_moments.py b/tests/test_find_moments.py index b1d1fb2..a4f0ef3 100644 --- a/tests/test_find_moments.py +++ b/tests/test_find_moments.py @@ -11,6 +11,8 @@ if BACKEND_ROOT not in sys.path: sys.path.insert(0, BACKEND_ROOT) +from services import ai_cli +from services import ai_provider from services import claude_suggest as cs SEGMENTS = [ @@ -39,14 +41,14 @@ def _fake_run(**kwargs): class FindMomentsTests(unittest.TestCase): def setUp(self): - self._orig_candidates = cs._find_ai_cli_candidates - self._orig_run = cs._run_ai_command - cs._find_ai_cli_candidates = lambda: [("/usr/bin/claude", "claude")] - cs._run_ai_command = lambda **kw: _fake_run(**kw) + self._orig_chain = ai_provider._chain + self._orig_run = ai_cli._run_ai_command + ai_provider._chain = lambda: [("cli", "/usr/bin/claude", "claude")] + ai_cli._run_ai_command = lambda **kw: _fake_run(**kw) def tearDown(self): - cs._find_ai_cli_candidates = self._orig_candidates - cs._run_ai_command = self._orig_run + ai_provider._chain = self._orig_chain + ai_cli._run_ai_command = self._orig_run def test_finds_and_shapes_moment(self): clips = cs.find_moments_from_text("the turning point", SEGMENTS, []) @@ -60,7 +62,7 @@ def test_finds_and_shapes_moment(self): self.assertGreater(c["score"], 0) def test_no_ai_cli_returns_empty(self): - cs._find_ai_cli_candidates = lambda: [] + ai_provider._chain = lambda: [] self.assertEqual(cs.find_moments_from_text("x", SEGMENTS, []), []) def test_progress_callback_invoked(self): diff --git a/tests/test_suggest_handler.py b/tests/test_suggest_handler.py index 4c577d2..a28d21c 100644 --- a/tests/test_suggest_handler.py +++ b/tests/test_suggest_handler.py @@ -11,7 +11,7 @@ sys.path.insert(0, BACKEND_ROOT) import main as backend_main -from services import claude_suggest +from services import ai_provider, claude_suggest SEGMENTS = [{"start": 0.0, "end": 10.0, "text": "hello"}] ENERGY_DATA = [{"time": float(t), "rms_db": -30.0} for t in range(31)] + [ @@ -44,7 +44,7 @@ def fake_emit_result(task_id, status, data=None, error=None): emitted.update({"task_id": task_id, "status": status, "data": data, "error": error}) with mock.patch.object(claude_suggest, "suggest_initial_with_claude", fake_suggest), \ - mock.patch.object(claude_suggest, "_find_ai_cli_candidates", return_value=["claude"]), \ + mock.patch.object(ai_provider, "available", return_value=True), \ mock.patch.object(backend_main, "emit_result", fake_emit_result), \ mock.patch.object(backend_main, "emit_progress"): backend_main.handle_suggest_clips("task-1", params) diff --git a/tests/test_whispercpp_adapter.py b/tests/test_whispercpp_adapter.py index c1bb9ac..78dea57 100644 --- a/tests/test_whispercpp_adapter.py +++ b/tests/test_whispercpp_adapter.py @@ -7,7 +7,7 @@ if BACKEND_ROOT not in sys.path: sys.path.insert(0, BACKEND_ROOT) -from services.transcription_whispercpp import _tokens_to_words +from services.transcription_whispercpp import _dtw_preset_for_model, _tokens_to_words class WhisperCppAdapterTests(unittest.TestCase): @@ -19,5 +19,26 @@ def test_sentencepiece_marker_is_removed(self): self.assertEqual([w["word"] for w in words], ["hello", "world"]) +class DtwPresetTests(unittest.TestCase): + def test_preset_tracks_the_model_file(self): + cases = { + "ggml-tiny.en.bin": "tiny.en", + "ggml-base.bin": "base", + "ggml-small.bin": "small", + "ggml-large-v3-turbo.bin": "large.v3-turbo", + "ggml-base.en-q5_1.bin": "base.en", + # K-quantisation names carry underscores between the qualifiers, and + # failing to strip them dropped -dtw for a model that supports it. + "ggml-large-v3-q4_k_m.gguf": "large.v3", + "ggml-small.en-q8_0.bin": "small.en", + } + for name, preset in cases.items(): + with self.subTest(name=name): + self.assertEqual(_dtw_preset_for_model(os.path.join("/models", name)), preset) + + def test_unknown_model_gets_no_preset(self): + self.assertIsNone(_dtw_preset_for_model("/models/ggml-distil-large-v2.bin")) + + if __name__ == "__main__": unittest.main()