"""Push project metadata to gnommoweb server. Usage: gnommo push -p video1 # push parent video project gnommo push -p short_pixelated_universe # push a short project gnommo push -p myproject --force # force push, overwrite server Reads project.json and POSTs to POST /api/projects/push. If project.json contains a "parent_project" field, the project is pushed as a short and registered under that parent. Otherwise it is pushed as a parent video project. Parent project.json "shorts" field is a list of slugs (just an index): "shorts": ["short_pixelated_universe", "short_planck_length"] Short project.json has its own full config plus a parent_project field: { "id": "short_pixelated_universe", "parent_project": "Video1", "resolution": [1080, 1920], "fps": 30, "duration_seconds": 60, ... } Conflict detection: - If server.updated_at > our recorded server_updated_at → server has newer changes → warn and abort unless --force Configuration (from .env or environment): GNOMMOWEB_URL Base URL (e.g. http://localhost:3001) GNOMMOWEB_API_KEY Bearer token (CONTENT_API_KEY from gnommoweb) """ import json import os import sys from datetime import datetime, timezone from pathlib import Path try: import requests except ImportError: print( "Error: 'requests' package is required. Run: pip install requests", file=sys.stderr, ) sys.exit(1) SYNC_FILE_LOCAL = ".gnommo_sync.json" SYNC_FILE_PROD = ".gnommo_sync.prod.json" def _sync_file(prod: bool) -> str: return SYNC_FILE_PROD if prod else SYNC_FILE_LOCAL def _load_env_file(): env_path = Path(__file__).parent.parent / ".env" if not env_path.exists(): return with open(env_path) as f: for line in f: line = line.strip() if not line or line.startswith("#") or "=" not in line: continue key, _, value = line.partition("=") key = key.strip() value = value.strip().strip('"').strip("'") if key not in os.environ: os.environ[key] = value def _read_sync(project_path: Path, prod: bool = False) -> dict: sync_file = project_path / _sync_file(prod) if sync_file.exists(): with open(sync_file) as f: return json.load(f) return {} def _write_sync(project_path: Path, data: dict, prod: bool = False): with open(project_path / _sync_file(prod), "w") as f: json.dump(data, f, indent=2) def _parse_ts(ts_str) -> datetime | None: if not ts_str: return None try: return datetime.fromisoformat(ts_str.replace("Z", "+00:00")) except ValueError: return None def cmd_push( project_path: Path, verbose: bool = False, force: bool = False, prod: bool = False ) -> int: _load_env_file() if prod: api_url = os.environ.get("GNOMMOWEB_PROD_URL", "").rstrip("/") api_key = os.environ.get("GNOMMOWEB_PROD_API_KEY", "") if not api_url: print("Error: GNOMMOWEB_PROD_URL is not set.", file=sys.stderr) return 1 if not api_key: print("Error: GNOMMOWEB_PROD_API_KEY is not set.", file=sys.stderr) return 1 else: api_url = os.environ.get("GNOMMOWEB_URL", "").rstrip("/") api_key = os.environ.get("GNOMMOWEB_API_KEY", "") if not api_url: print("Error: GNOMMOWEB_URL is not set.", file=sys.stderr) return 1 if not api_key: print("Error: GNOMMOWEB_API_KEY is not set.", file=sys.stderr) return 1 if verbose: target = "production" if prod else "local" print(f" → {target}: {api_url}") project_file = project_path / "project.json" if not project_file.exists(): print(f"Error: {project_file} not found", file=sys.stderr) return 1 with open(project_file) as f: project = json.load(f) project_id = project.get("id") name = project.get("name") if not project_id or not name: print("Error: project.json must have 'id' and 'name' fields.", file=sys.stderr) return 1 parent_project = project.get("parent_project") # ── Build payload ───────────────────────────────────────────────────────── if parent_project: payload = _build_short_payload(project, project_path, verbose) else: payload = _build_parent_payload(project, project_path, verbose) if verbose: kind = "short" if parent_project else "parent video" print(f"Pushing {project_id} ({kind}) to {api_url}") # ── POST ────────────────────────────────────────────────────────────────── try: r = requests.post( f"{api_url}/api/projects/push", json=payload, headers={"Authorization": f"Bearer {api_key}"}, timeout=30, ) except requests.exceptions.ConnectionError: print(f"✗ Could not connect to {api_url}") return 1 if not r.ok: try: body = r.json() except Exception: body = r.text[:500] print(f"✗ Server returned {r.status_code}: {body}") return 1 result = r.json() server_updated_at = result.get("server_updated_at") # ── Write sync state ────────────────────────────────────────────────────── now_iso = datetime.now(tz=timezone.utc).isoformat(timespec="seconds") existing_sync = _read_sync(project_path, prod) _write_sync( project_path, { **existing_sync, "last_pushed_at": now_iso, "server_updated_at": server_updated_at, }, prod, ) # ── Print summary ───────────────────────────────────────────────────────── asset = result.get("asset", {}) if result.get("type") == "short": print(f"✓ {project_id} → gn_asset #{asset.get('id')} [{asset.get('status')}]") if result.get("task_created"): print(f" task #{result['task_id']} created") else: print(f"✓ {project_id} → gn_asset #{asset.get('id')} ({asset.get('name')})") if verbose: script_len = len(asset.get("script") or "") print( f" server.script: {script_len} chars | fps={asset.get('fps')} res={asset.get('resolution')}" ) return 0 def _build_parent_payload(project: dict, project_path: Path, verbose: bool) -> dict: # Read the manuscript file if one is specified script_content = None manuscript_str = project.get("manuscript") if manuscript_str: manuscript_path = project_path / manuscript_str if manuscript_path.exists(): try: script_content = manuscript_path.read_text(encoding="utf-8") except UnicodeDecodeError: script_content = manuscript_path.read_text(encoding="latin-1") print(f" Warning: manuscript is not UTF-8, read as latin-1") print(f" manuscript: {len(script_content)} chars") else: print(f" Warning: manuscript not found: {manuscript_path}") else: if verbose: print(f" no manuscript field in project.json") return { "project_id": project["id"], "name": project["name"], "description": project.get("description"), "coursecode": project.get("coursecode"), "script_content": script_content, "resolution": project.get("resolution"), "fps": project.get("fps"), "duration_seconds": project.get("duration_seconds"), "hook": project.get("hook"), "platform_targets": project.get("platform_targets"), "status": project.get("status"), "youtube_url": project.get("youtube_url"), "shorts": project.get("shorts", []), } def _build_short_payload(project: dict, project_path: Path, verbose: bool) -> dict: # Read the script file if one is specified script_content = None script_path_str = project.get("script") if script_path_str: script_path = project_path / script_path_str if script_path.exists(): script_content = script_path.read_text() if verbose: print(f" Read script: {script_path} ({len(script_content)} chars)") else: print(f" Warning: script file not found: {script_path}", file=sys.stderr) return { "project_id": project["id"], "name": project["name"], "description": project.get("description"), "parent_project": project["parent_project"], "hook": project.get("hook"), "script_content": script_content, "platform_targets": project.get("platform_targets", ["youtube"]), "resolution": project.get("resolution"), "fps": project.get("fps"), "duration_seconds": project.get("duration_seconds"), }