216 lines
8.1 KiB
Python
216 lines
8.1 KiB
Python
"""Pull project metadata from gnommoweb server.
|
|
|
|
Usage:
|
|
gnommo pull -p video1 # pull parent video project
|
|
gnommo pull -p short_pixelated_universe # pull a short project
|
|
gnommo pull -p myproject --force # force pull, overwrite local
|
|
|
|
For a parent project: updates name, description, and the shorts index
|
|
(list of slugs) in project.json.
|
|
|
|
For a short project: updates title, hook, platform_targets, resolution,
|
|
fps, duration_seconds. Preserves local script path reference.
|
|
|
|
Conflict detection:
|
|
- If local project.json mtime > last_pushed_at → local has unpushed 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)
|
|
"""
|
|
|
|
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_pull(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:
|
|
local_project = json.load(f)
|
|
|
|
project_id = local_project.get("id")
|
|
if not project_id:
|
|
print("Error: project.json missing 'id'.", file=sys.stderr)
|
|
return 1
|
|
|
|
# ── Conflict check ────────────────────────────────────────────────────────
|
|
if not force:
|
|
sync = _read_sync(project_path, prod)
|
|
last_pushed_at = _parse_ts(sync.get("last_pushed_at"))
|
|
local_mtime = datetime.fromtimestamp(
|
|
project_file.stat().st_mtime, tz=timezone.utc
|
|
)
|
|
if last_pushed_at and local_mtime > last_pushed_at:
|
|
print(
|
|
f"⚠ project.json has local changes since last push "
|
|
f"({local_mtime.strftime('%Y-%m-%d %H:%M')} > "
|
|
f"{last_pushed_at.strftime('%Y-%m-%d %H:%M')}).",
|
|
file=sys.stderr,
|
|
)
|
|
print(
|
|
" Push first with `gnommo push -p` or use `gnommo pull -p --force`.",
|
|
file=sys.stderr,
|
|
)
|
|
return 1
|
|
|
|
# ── Fetch from server ─────────────────────────────────────────────────────
|
|
if verbose:
|
|
print(f"Pulling {project_id} from {api_url}…")
|
|
|
|
try:
|
|
r = requests.get(
|
|
f"{api_url}/api/projects/{project_id}",
|
|
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:
|
|
if r.status_code == 404:
|
|
print(f"✗ Project '{project_id}' not found on server. Push it first.")
|
|
else:
|
|
try:
|
|
body = r.json()
|
|
except Exception:
|
|
body = r.text[:500]
|
|
print(f"✗ Server returned {r.status_code}: {body}")
|
|
return 1
|
|
|
|
server = r.json()
|
|
server_updated_at = server.get("updated_at")
|
|
project_type = server.get("type")
|
|
|
|
# ── Merge into project.json ───────────────────────────────────────────────
|
|
if project_type == "parent":
|
|
_merge_parent(local_project, server, verbose)
|
|
count = len(server.get("shorts", []))
|
|
print(f"✓ Pulled {project_id} (parent video) — {count} short(s) in index")
|
|
elif project_type == "short":
|
|
_merge_short(local_project, server, verbose)
|
|
print(f"✓ Pulled {project_id} (short) — [{server.get('status')}]")
|
|
else:
|
|
print(f"Error: unexpected project type: {project_type}", file=sys.stderr)
|
|
return 1
|
|
|
|
# ── Write back ────────────────────────────────────────────────────────────
|
|
with open(project_file, "w") as f:
|
|
json.dump(local_project, f, indent=2, ensure_ascii=False)
|
|
f.write("\n")
|
|
|
|
now_iso = datetime.now(tz=timezone.utc).isoformat(timespec="seconds")
|
|
existing_sync = _read_sync(project_path, prod)
|
|
_write_sync(project_path, {
|
|
**existing_sync,
|
|
"last_pulled_at": now_iso,
|
|
"server_updated_at": server_updated_at,
|
|
"last_pushed_at": existing_sync.get("last_pushed_at"),
|
|
}, prod)
|
|
|
|
return 0
|
|
|
|
|
|
def _merge_parent(local: dict, server: dict, verbose: bool):
|
|
"""Update parent project.json: name, description, shorts index (slugs)."""
|
|
local["name"] = server.get("title", local.get("name"))
|
|
local["description"] = server.get("description") or local.get("description")
|
|
# shorts is a list of slugs — update from server's shorts list
|
|
server_shorts = server.get("shorts", [])
|
|
local["shorts"] = [s["project_id"] for s in server_shorts]
|
|
if verbose:
|
|
print(f" shorts index: {local['shorts']}")
|
|
|
|
|
|
def _merge_short(local: dict, server: dict, verbose: bool):
|
|
"""Update short project.json: name, hook, platform_targets, resolution, fps, duration."""
|
|
local["name"] = server.get("title", local.get("name"))
|
|
if server.get("hook"):
|
|
local["hook"] = server["hook"]
|
|
if server.get("platform_targets"):
|
|
local["platform_targets"] = server["platform_targets"]
|
|
if server.get("resolution"):
|
|
local["resolution"] = server["resolution"]
|
|
if server.get("fps"):
|
|
local["fps"] = server["fps"]
|
|
if server.get("duration_seconds"):
|
|
local["duration_seconds"] = server["duration_seconds"]
|
|
if server.get("parent_project_id"):
|
|
local["parent_project"] = server["parent_project_id"]
|
|
# Never overwrite local script path — that stays local
|