SalexAI commited on
Commit
cd032a4
·
verified ·
1 Parent(s): 5cc97a0

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +193 -156
app.py CHANGED
@@ -1,161 +1,198 @@
1
- from fastapi import FastAPI
2
- from fastapi.middleware.cors import CORSMiddleware
3
- import httpx
4
- import json
5
- import logging
6
-
7
- app = FastAPI()
8
- logging.basicConfig(level=logging.INFO)
9
-
10
- # Enable CORS for all origins
11
- app.add_middleware(
12
- CORSMiddleware,
13
- allow_origins=["*"],
14
- allow_methods=["*"],
15
- allow_headers=["*"],
16
- )
17
-
18
- BASE_62_MAP = {c: i for i, c in enumerate("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz")}
19
-
20
- async def get_client() -> httpx.AsyncClient:
21
- if not hasattr(app.state, "client"):
22
- app.state.client = httpx.AsyncClient(timeout=15.0)
23
- return app.state.client
24
-
25
- def base62_to_int(token: str) -> int:
26
- result = 0
27
- for ch in token:
28
- result = result * 62 + BASE_62_MAP[ch]
29
- return result
30
-
31
- async def get_base_url(token: str) -> str:
32
- first = token[0]
33
- if first == "A":
34
- n = base62_to_int(token[1])
35
- else:
36
- n = base62_to_int(token[1:3])
37
- return f"https://p{n:02d}-sharedstreams.icloud.com/{token}/sharedstreams/"
38
-
39
- ICLOUD_HEADERS = {
40
- "Origin": "https://www.icloud.com",
41
- "Content-Type": "text/plain"
42
- }
43
- ICLOUD_PAYLOAD = '{"streamCtag":null}'
44
-
45
- async def get_redirected_base_url(base_url: str, token: str) -> str:
46
- client = await get_client()
47
- resp = await client.post(
48
- f"{base_url}webstream", headers=ICLOUD_HEADERS, data=ICLOUD_PAYLOAD, follow_redirects=False
49
- )
50
- if resp.status_code == 330:
51
- try:
52
- body = resp.json()
53
- host = body.get("X-Apple-MMe-Host")
54
- if not host:
55
- raise ValueError("Missing X-Apple-MMe-Host in 330 response")
56
- logging.info(f"Redirected to {host}")
57
- return f"https://{host}/{token}/sharedstreams/"
58
- except Exception as e:
59
- logging.error(f"Redirect parsing failed: {e}")
60
- raise
61
- elif resp.status_code == 200:
62
- return base_url
63
- else:
64
- resp.raise_for_status()
65
-
66
- async def post_json(path: str, base_url: str, payload: str) -> dict:
67
- client = await get_client()
68
- resp = await client.post(f"{base_url}{path}", headers=ICLOUD_HEADERS, data=payload)
69
- resp.raise_for_status()
70
- return resp.json()
71
-
72
- async def get_metadata(base_url: str) -> list:
73
- data = await post_json("webstream", base_url, ICLOUD_PAYLOAD)
74
- return data.get("photos", [])
75
-
76
- async def get_asset_urls(base_url: str, guids: list) -> dict:
77
- payload = json.dumps({"photoGuids": guids})
78
- data = await post_json("webasseturls", base_url, payload)
79
- return data.get("items", {})
80
-
81
- @app.get("/album/{token}")
82
- async def get_album(token: str):
83
- try:
84
- base_url = await get_base_url(token)
85
- base_url = await get_redirected_base_url(base_url, token)
86
-
87
- metadata = await get_metadata(base_url)
88
- guids = [photo["photoGuid"] for photo in metadata]
89
- asset_map = await get_asset_urls(base_url, guids)
90
-
91
- videos = []
92
- for photo in metadata:
93
- if photo.get("mediaAssetType", "").lower() != "video":
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94
  continue
95
-
96
- derivatives = photo.get("derivatives", {})
97
- best = max(
98
- (d for k, d in derivatives.items() if k.lower() != "posterframe"),
99
- key=lambda d: int(d.get("fileSize") or 0),
100
- default=None
101
- )
102
- if not best:
103
  continue
104
 
105
- checksum = best.get("checksum")
106
- info = asset_map.get(checksum)
107
- if not info:
 
108
  continue
109
- video_url = f"https://{info['url_location']}{info['url_path']}"
110
-
111
- poster = None
112
- pf = derivatives.get("PosterFrame")
113
- if pf:
114
- pf_info = asset_map.get(pf.get("checksum"))
115
- if pf_info:
116
- poster = f"https://{pf_info['url_location']}{pf_info['url_path']}"
117
-
118
- videos.append({
119
- "caption": photo.get("caption", ""),
120
- "url": video_url,
121
- "poster": poster or ""
122
- })
123
-
124
- return {"videos": videos}
125
-
126
- except Exception as e:
127
- logging.exception("Error in get_album")
128
- return {"error": str(e)}
129
-
130
- @app.get("/album/{token}/raw")
131
- async def get_album_raw(token: str):
132
- try:
133
- base_url = await get_base_url(token)
134
- base_url = await get_redirected_base_url(base_url, token)
135
- metadata = await get_metadata(base_url)
136
- guids = [photo["photoGuid"] for photo in metadata]
137
- asset_map = await get_asset_urls(base_url, guids)
138
- return {"metadata": metadata, "asset_urls": asset_map}
139
-
140
-
141
-
142
-
143
-
144
-
145
-
146
-
147
-
148
-
149
-
150
-
151
-
152
-
153
-
154
-
155
-
156
-
157
-
158
 
159
- except Exception as e:
160
- logging.exception("Error in get_album_raw")
161
- return {"error": str(e)}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import FastAPI, HTTPException, Header
2
+ from pydantic import BaseModel, Field
3
+ from typing import Optional, Dict, Any, List
4
+ from uuid import uuid4
5
+ from datetime import datetime, timezone
6
+ import asyncio
7
+
8
+ app = FastAPI(title="Create3 Robot Command Bridge", version="1.0.0")
9
+
10
+ # ====== Simple shared secret auth (optional but recommended) ======
11
+ API_TOKEN = "change-me-please" # set this to your own token
12
+
13
+ def check_token(auth_header: Optional[str]):
14
+ if not API_TOKEN:
15
+ return
16
+ if not auth_header or not auth_header.startswith("Bearer "):
17
+ raise HTTPException(status_code=401, detail="Missing bearer token")
18
+ token = auth_header.split(" ", 1)[1].strip()
19
+ if token != API_TOKEN:
20
+ raise HTTPException(status_code=403, detail="Invalid token")
21
+
22
+ # ====== In-memory command store ======
23
+ # For HF Space demo/testing this is OK.
24
+ # If you restart the app, queue clears.
25
+ commands: Dict[str, Dict[str, Any]] = {}
26
+ queue: List[str] = []
27
+ queue_lock = asyncio.Lock()
28
+
29
+ # ====== Models ======
30
+ class CommandCreate(BaseModel):
31
+ command: str = Field(..., description="Robot command name")
32
+ args: Dict[str, Any] = Field(default_factory=dict, description="Command arguments")
33
+ source: Optional[str] = Field(default="ai", description="Who sent the command")
34
+ priority: int = Field(default=0, description="Higher = earlier in queue")
35
+ ttl_seconds: Optional[int] = Field(default=120, description="Optional expiration")
36
+
37
+ class CommandStatusUpdate(BaseModel):
38
+ status: str = Field(..., description="queued | running | done | failed | expired")
39
+ result: Optional[Dict[str, Any]] = None
40
+ error: Optional[str] = None
41
+ robot_id: Optional[str] = None
42
+
43
+ # ====== Helpers ======
44
+ def now_iso() -> str:
45
+ return datetime.now(timezone.utc).isoformat()
46
+
47
+ def command_expired(cmd: Dict[str, Any]) -> bool:
48
+ ttl = cmd.get("ttl_seconds")
49
+ if not ttl:
50
+ return False
51
+ created = datetime.fromisoformat(cmd["created_at"])
52
+ age = (datetime.now(timezone.utc) - created).total_seconds()
53
+ return age > ttl
54
+
55
+ # ====== API ======
56
+
57
+ @app.get("/")
58
+ async def root():
59
+ return {"ok": True, "service": "create3-command-bridge"}
60
+
61
+ @app.post("/commands")
62
+ async def create_command(cmd: CommandCreate, authorization: Optional[str] = Header(default=None)):
63
+ check_token(authorization)
64
+
65
+ cmd_id = str(uuid4())
66
+ record = {
67
+ "id": cmd_id,
68
+ "command": cmd.command,
69
+ "args": cmd.args,
70
+ "source": cmd.source,
71
+ "priority": cmd.priority,
72
+ "ttl_seconds": cmd.ttl_seconds,
73
+ "status": "queued",
74
+ "result": None,
75
+ "error": None,
76
+ "created_at": now_iso(),
77
+ "updated_at": now_iso(),
78
+ "claimed_by": None,
79
+ }
80
+
81
+ async with queue_lock:
82
+ commands[cmd_id] = record
83
+
84
+ # Insert by priority (higher first)
85
+ inserted = False
86
+ for i, queued_id in enumerate(queue):
87
+ if record["priority"] > commands[queued_id]["priority"]:
88
+ queue.insert(i, cmd_id)
89
+ inserted = True
90
+ break
91
+ if not inserted:
92
+ queue.append(cmd_id)
93
+
94
+ return {"ok": True, "command_id": cmd_id, "status": "queued"}
95
+
96
+ @app.get("/commands")
97
+ async def list_commands(limit: int = 20, authorization: Optional[str] = Header(default=None)):
98
+ check_token(authorization)
99
+ # latest updated first
100
+ items = sorted(commands.values(), key=lambda x: x["updated_at"], reverse=True)[:limit]
101
+ return {"ok": True, "items": items}
102
+
103
+ @app.get("/commands/{command_id}")
104
+ async def get_command(command_id: str, authorization: Optional[str] = Header(default=None)):
105
+ check_token(authorization)
106
+ if command_id not in commands:
107
+ raise HTTPException(status_code=404, detail="Command not found")
108
+ return {"ok": True, "item": commands[command_id]}
109
+
110
+ @app.post("/commands/next")
111
+ async def claim_next_command(
112
+ robot_id: str,
113
+ authorization: Optional[str] = Header(default=None),
114
+ ):
115
+ """
116
+ Client polls this endpoint to claim the next command.
117
+ Returns one command and marks it running.
118
+ """
119
+ check_token(authorization)
120
+
121
+ async with queue_lock:
122
+ # Clean expired queued commands while scanning
123
+ while queue:
124
+ cmd_id = queue.pop(0)
125
+ cmd = commands.get(cmd_id)
126
+ if not cmd:
127
  continue
128
+ if cmd["status"] != "queued":
 
 
 
 
 
 
 
129
  continue
130
 
131
+ if command_expired(cmd):
132
+ cmd["status"] = "expired"
133
+ cmd["error"] = "TTL expired before execution"
134
+ cmd["updated_at"] = now_iso()
135
  continue
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
136
 
137
+ cmd["status"] = "running"
138
+ cmd["claimed_by"] = robot_id
139
+ cmd["updated_at"] = now_iso()
140
+ return {"ok": True, "item": cmd}
141
+
142
+ return {"ok": True, "item": None}
143
+
144
+ @app.post("/commands/{command_id}/status")
145
+ async def update_command_status(
146
+ command_id: str,
147
+ update: CommandStatusUpdate,
148
+ authorization: Optional[str] = Header(default=None),
149
+ ):
150
+ check_token(authorization)
151
+
152
+ cmd = commands.get(command_id)
153
+ if not cmd:
154
+ raise HTTPException(status_code=404, detail="Command not found")
155
+
156
+ # Basic state update
157
+ cmd["status"] = update.status
158
+ cmd["result"] = update.result
159
+ cmd["error"] = update.error
160
+ if update.robot_id:
161
+ cmd["claimed_by"] = update.robot_id
162
+ cmd["updated_at"] = now_iso()
163
+
164
+ return {"ok": True}
165
+
166
+ @app.post("/commands/{command_id}/cancel")
167
+ async def cancel_command(command_id: str, authorization: Optional[str] = Header(default=None)):
168
+ check_token(authorization)
169
+
170
+ cmd = commands.get(command_id)
171
+ if not cmd:
172
+ raise HTTPException(status_code=404, detail="Command not found")
173
+
174
+ if cmd["status"] in ("done", "failed", "expired"):
175
+ return {"ok": True, "status": cmd["status"], "message": "Already finished"}
176
+
177
+ cmd["status"] = "failed"
178
+ cmd["error"] = "Cancelled by operator"
179
+ cmd["updated_at"] = now_iso()
180
+
181
+ # Remove from queue if still queued
182
+ async with queue_lock:
183
+ if command_id in queue:
184
+ queue.remove(command_id)
185
+
186
+ return {"ok": True, "status": "failed"}
187
+
188
+ @app.get("/health")
189
+ async def health():
190
+ queued = sum(1 for c in commands.values() if c["status"] == "queued")
191
+ running = sum(1 for c in commands.values() if c["status"] == "running")
192
+ return {
193
+ "ok": True,
194
+ "queued": queued,
195
+ "running": running,
196
+ "total": len(commands),
197
+ "time": now_iso(),
198
+ }