265 lines
9.2 KiB
Python
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"),
|
|
}
|