Files
gnommo/gnommo/push.py
T
2026-03-26 10:46:05 +01:00

265 lines
9.2 KiB
Python

"""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"),
}