vrex-flow-engine 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,28 @@
1
+ """Per-instance job store.
2
+
3
+ Extracted from the module-level ``_jobs`` dict in jobs.py so each pool
4
+ instance owns an isolated namespace — jobs from account A never appear in
5
+ account B's job listing.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from typing import TYPE_CHECKING, Optional
10
+
11
+ if TYPE_CHECKING:
12
+ from flow_engine.jobs import Job
13
+
14
+
15
+ class JobStore:
16
+ """Simple dict-backed job registry, one per PoolInstance."""
17
+
18
+ def __init__(self) -> None:
19
+ self._jobs: dict[str, "Job"] = {}
20
+
21
+ def get(self, job_id: str) -> Optional["Job"]:
22
+ return self._jobs.get(job_id)
23
+
24
+ def put(self, job: "Job") -> None:
25
+ self._jobs[job.id] = job
26
+
27
+ def __len__(self) -> int:
28
+ return len(self._jobs)
flow_engine/jobs.py ADDED
@@ -0,0 +1,245 @@
1
+ """Async video job runner.
2
+
3
+ flowboard ran generation through a DB-backed request queue + worker. flow_engine
4
+ replaces that with per-request asyncio tasks and a per-instance JobStore.
5
+
6
+ The poll loop mirrors flowboard/agent worker `_handle_gen_video`: per-op
7
+ resolution with partial-failure aggregation (some variants can be blocked by
8
+ the content filter while others render). State is process-local; swap for a
9
+ shared store if you scale past one process.
10
+
11
+ All public functions accept explicit ``job_store``, ``media_store``, and
12
+ ``vc_store`` arguments instead of using module globals — this gives each
13
+ PoolInstance full isolation.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import asyncio
18
+ import base64
19
+ import logging
20
+ import os
21
+ import time
22
+ import uuid
23
+ from dataclasses import dataclass, field
24
+ from typing import Any, Optional
25
+
26
+ from flow_engine.bridge.flow_sdk import FlowSDK
27
+ from flow_engine.job_store import JobStore
28
+ from flow_engine.media_store import MediaStore
29
+ from flow_engine.video_context_store import VideoContextStore
30
+ from flow_engine.video_context import parse_seconds
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+ # Poll knobs — same budget as flowboard's worker (5 min: 30 cycles × 10s).
35
+ # Poll budget for waiting on Veo results. Default 72 × 10s = 12 min, to tolerate
36
+ # Flow's veo_*_low_priority queue (which can exceed the old 5-min budget and
37
+ # return timeout_waiting_video). Env-tunable without a code change.
38
+ VIDEO_POLL_INTERVAL_S = float(os.getenv("VIDEO_POLL_INTERVAL_S", "10"))
39
+ VIDEO_POLL_MAX_CYCLES = int(os.getenv("VIDEO_POLL_MAX_CYCLES", "72"))
40
+
41
+
42
+ @dataclass
43
+ class Job:
44
+ id: str
45
+ kind: str # "video"
46
+ status: str = "in_progress" # in_progress | completed | failed
47
+ created_at: int = field(default_factory=lambda: int(time.time()))
48
+ # Positional results aligned to dispatch order.
49
+ media_ids: list[Optional[str]] = field(default_factory=list)
50
+ slot_errors: list[Optional[str]] = field(default_factory=list)
51
+ error: Optional[str] = None
52
+ partial_error: Optional[str] = None
53
+ raw: dict[str, Any] = field(default_factory=dict)
54
+
55
+
56
+ # Generation mode → the FlowSDK dispatch coroutine it maps to.
57
+ _DISPATCH = {
58
+ "t2v": "gen_video_t2v",
59
+ "r2v": "gen_video_r2v",
60
+ "i2v": "gen_video",
61
+ "extension": "gen_video_extend",
62
+ "edit": "gen_video_edit",
63
+ }
64
+
65
+
66
+ def get(job_id: str, job_store: JobStore) -> Optional[Job]:
67
+ return job_store.get(job_id)
68
+
69
+
70
+ def _new_job(kind: str, job_store: JobStore) -> Job:
71
+ job = Job(id=f"{kind}_{uuid.uuid4().hex}", kind=kind)
72
+ job_store.put(job)
73
+ return job
74
+
75
+
76
+ def start_video_job(
77
+ sdk: FlowSDK,
78
+ *,
79
+ mode: str,
80
+ dispatch_kwargs: dict[str, Any],
81
+ job_store: JobStore,
82
+ media_store: MediaStore,
83
+ vc_store: VideoContextStore,
84
+ ) -> Job:
85
+ """Create a job and kick off dispatch+poll in the background.
86
+
87
+ ``mode`` ∈ {t2v, i2v, r2v, extension, edit} selects the FlowSDK dispatch
88
+ method; ``dispatch_kwargs`` is forwarded to it.
89
+ """
90
+ job = _new_job("video", job_store)
91
+ asyncio.create_task(
92
+ _run_video_job(job, sdk, mode, dispatch_kwargs, job_store, media_store, vc_store)
93
+ )
94
+ return job
95
+
96
+
97
+ async def _run_video_job(
98
+ job: Job,
99
+ sdk: FlowSDK,
100
+ mode: str,
101
+ kwargs: dict[str, Any],
102
+ job_store: JobStore,
103
+ media_store: MediaStore,
104
+ vc_store: VideoContextStore,
105
+ ) -> None:
106
+ try:
107
+ dispatch = await getattr(sdk, _DISPATCH[mode])(**kwargs)
108
+ except Exception as exc: # noqa: BLE001
109
+ logger.exception("video dispatch crashed")
110
+ job.status, job.error = "failed", str(exc)[:200]
111
+ return
112
+
113
+ if dispatch.get("error"):
114
+ job.status = "failed"
115
+ job.error = str(dispatch["error"])[:200]
116
+ job.raw = {"dispatch": dispatch}
117
+ return
118
+
119
+ op_names: list[str] = dispatch.get("operation_names") or []
120
+ if not op_names:
121
+ job.status, job.error = "failed", "no_operations_returned"
122
+ job.raw = {"dispatch": dispatch}
123
+ return
124
+ workflows = dispatch.get("workflows") or None
125
+
126
+ done_by_name = {name: False for name in op_names}
127
+ entry_by_name: dict[str, dict] = {}
128
+ op_errors: dict[str, str] = {}
129
+ last_poll: dict = {}
130
+ attempts = 0
131
+
132
+ while attempts < VIDEO_POLL_MAX_CYCLES and not all(done_by_name.values()):
133
+ await asyncio.sleep(VIDEO_POLL_INTERVAL_S)
134
+ attempts += 1
135
+ last_poll = await sdk.check_async(
136
+ op_names, workflows=workflows, project_id=kwargs.get("project_id")
137
+ )
138
+ if last_poll.get("error"):
139
+ continue
140
+ for op in last_poll.get("operations") or []:
141
+ if not isinstance(op, dict):
142
+ continue
143
+ name = op.get("name")
144
+ if not isinstance(name, str) or done_by_name.get(name, False):
145
+ continue
146
+ err = op.get("error")
147
+ if isinstance(err, str) and err:
148
+ done_by_name[name] = True
149
+ op_errors[name] = err
150
+ continue
151
+ if op.get("done"):
152
+ done_by_name[name] = True
153
+ for e in op.get("media_entries") or []:
154
+ if isinstance(e, dict) and e.get("media_id"):
155
+ entry_by_name[name] = e
156
+ break
157
+
158
+ # Unresolved after budget → timeout.
159
+ for name in op_names:
160
+ if not done_by_name.get(name) and name not in op_errors:
161
+ op_errors[name] = "timeout_waiting_video"
162
+
163
+ positional_ids: list[Optional[str]] = []
164
+ slot_errors: list[Optional[str]] = []
165
+ succeeded: list[dict] = []
166
+ for name in op_names:
167
+ e = entry_by_name.get(name)
168
+ if isinstance(e, dict) and isinstance(e.get("media_id"), str):
169
+ positional_ids.append(e["media_id"])
170
+ succeeded.append(e)
171
+ slot_errors.append(None)
172
+ else:
173
+ positional_ids.append(None)
174
+ slot_errors.append(op_errors.get(name))
175
+
176
+ if not any(positional_ids):
177
+ job.status = "failed"
178
+ job.error = next(iter(op_errors.values()), "timeout_waiting_video")
179
+ job.slot_errors = slot_errors
180
+ job.raw = {"dispatch": dispatch, "last_poll": last_poll}
181
+ return
182
+
183
+ # Ingest what rendered into this instance's MediaStore.
184
+ url_entries = [e for e in succeeded if isinstance(e, dict) and e.get("url")]
185
+ if url_entries:
186
+ try:
187
+ media_store.ingest_urls(url_entries)
188
+ except Exception: # noqa: BLE001
189
+ logger.exception("ingest_urls failed for video job %s", job.id)
190
+ for e in succeeded:
191
+ encoded, mid = e.get("encoded_video"), e.get("media_id")
192
+ if isinstance(encoded, str) and isinstance(mid, str):
193
+ try:
194
+ media_store.ingest_inline_bytes(
195
+ mid,
196
+ base64.b64decode(encoded, validate=False),
197
+ kind="video",
198
+ mime="video/mp4",
199
+ )
200
+ except Exception: # noqa: BLE001
201
+ logger.exception("inline ingest failed for %s", mid)
202
+
203
+ # Stash extend/edit context into this instance's VideoContextStore.
204
+ wf_by_media = {
205
+ w.get("primary_media_id"): w.get("name")
206
+ for w in (workflows or [])
207
+ if isinstance(w, dict)
208
+ }
209
+ finished_ids = [
210
+ e["media_id"]
211
+ for e in succeeded
212
+ if isinstance(e, dict) and isinstance(e.get("media_id"), str)
213
+ ]
214
+ rich_meta: dict[str, dict] = {}
215
+ if finished_ids:
216
+ project_id = kwargs.get("project_id")
217
+ if isinstance(project_id, str):
218
+ try:
219
+ rich_meta = (
220
+ await sdk.fetch_video_meta(finished_ids, project_id)
221
+ ).get("meta") or {}
222
+ except Exception: # noqa: BLE001
223
+ logger.exception("fetch_video_meta failed for job %s", job.id)
224
+
225
+ dispatched_scene = dispatch.get("scene_id")
226
+ for mid in finished_ids:
227
+ rm = rich_meta.get(mid) or {}
228
+ vc_store.remember(
229
+ mid,
230
+ workflow_id=wf_by_media.get(mid) or rm.get("workflow_id"),
231
+ operation_id=rm.get("operation_id"),
232
+ scene_id=dispatched_scene or rm.get("scene_id"),
233
+ duration_sec=parse_seconds(rm.get("length")),
234
+ )
235
+
236
+ if op_errors:
237
+ unique = sorted({v for v in op_errors.values()})
238
+ job.partial_error = (
239
+ f"{len(op_errors)}/{len(op_names)} variants blocked: {', '.join(unique)}"
240
+ )
241
+
242
+ job.media_ids = positional_ids
243
+ job.slot_errors = slot_errors
244
+ job.status = "completed"
245
+ job.raw = {"dispatch": dispatch, "last_poll": last_poll, "video_meta": rich_meta}
flow_engine/main.py ADDED
@@ -0,0 +1,187 @@
1
+ """flow_engine FastAPI app.
2
+
3
+ Hosts the extension bridge (WS :9223) and an OpenAI-compatible HTTP surface
4
+ (:8101) for Google Flow image/video generation.
5
+
6
+ Boot-time checks:
7
+ - FLOW_ENGINE_API_KEY (or legacy FLOWPROXY_API_KEY) must be non-empty.
8
+ - WS_HOST must be loopback (the extension WS is unauthenticated by design).
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import logging
14
+ from contextlib import asynccontextmanager
15
+
16
+ from fastapi import Depends, FastAPI, HTTPException, Request
17
+ from fastapi.middleware.cors import CORSMiddleware
18
+ from fastapi.responses import FileResponse, Response
19
+
20
+ from flow_engine.config import API_KEY, WS_HOST
21
+ from flow_engine.openai import images, models, uploads, videos
22
+ from flow_engine.openai._util import require_api_key
23
+ from flow_engine.pool import pool
24
+ from flow_engine.posthog_client import flush_posthog
25
+
26
+ # The WS server is unauthenticated — refuse to boot on a non-loopback bind.
27
+ if WS_HOST not in ("127.0.0.1", "localhost", "::1"):
28
+ raise RuntimeError(
29
+ f"FLOWPROXY_WS_HOST must be loopback (got {WS_HOST!r}); the extension WS "
30
+ "is unauthenticated by design and must not be network-reachable."
31
+ )
32
+
33
+ logging.basicConfig(
34
+ level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s"
35
+ )
36
+ logger = logging.getLogger(__name__)
37
+
38
+
39
+ @asynccontextmanager
40
+ async def lifespan(app: FastAPI):
41
+ # Enforce non-empty API key at server startup, not at import time, so that
42
+ # `pip install -e .` and plain imports never crash (only the running server).
43
+ if not API_KEY:
44
+ raise RuntimeError(
45
+ "FLOW_ENGINE_API_KEY must be set (or legacy FLOWPROXY_API_KEY). "
46
+ "Set the environment variable before starting flow-engine."
47
+ )
48
+
49
+ from flow_engine.bridge.ws_server import run_ws_server
50
+
51
+ ws_task = asyncio.create_task(run_ws_server(), name="ext-ws-server")
52
+ logger.info("flow_engine started (ws bridge + openai surface)")
53
+ try:
54
+ yield
55
+ finally:
56
+ ws_task.cancel()
57
+ await asyncio.gather(ws_task, return_exceptions=True)
58
+ flush_posthog() # drain queued events before process exits
59
+ logger.info("flow_engine stopped")
60
+
61
+
62
+ app = FastAPI(title="flow_engine", version="0.0.1", lifespan=lifespan)
63
+
64
+ app.add_middleware(
65
+ CORSMiddleware,
66
+ allow_origins=["*"],
67
+ allow_credentials=True,
68
+ allow_methods=["*"],
69
+ allow_headers=["*"],
70
+ )
71
+
72
+ app.include_router(images.router)
73
+ app.include_router(videos.router)
74
+ app.include_router(uploads.router)
75
+ app.include_router(models.router)
76
+
77
+
78
+ @app.get("/api/health")
79
+ def health() -> dict:
80
+ instances = pool.status()
81
+ connected = any(i["connected"] for i in instances)
82
+ return {
83
+ "ok": True,
84
+ "extension_connected": connected,
85
+ "instances": len(instances),
86
+ }
87
+
88
+
89
+ @app.get("/api/pool/status")
90
+ def pool_status(_: str | None = Depends(require_api_key)) -> dict:
91
+ """Admin view of all registered pool instances (API-key gated)."""
92
+ return {"instances": pool.status()}
93
+
94
+
95
+ @app.post("/api/ext/callback")
96
+ async def ext_callback(
97
+ request: Request,
98
+ x_callback_secret: str | None = None,
99
+ ) -> dict:
100
+ """HTTP callback the extension uses to deliver API responses.
101
+
102
+ Routes to the correct PoolInstance by searching all instances' pending
103
+ futures for a matching request id.
104
+ """
105
+ # Read the secret from the header manually so the endpoint stays flexible.
106
+ x_callback_secret = request.headers.get("x-callback-secret")
107
+ if not x_callback_secret:
108
+ raise HTTPException(status_code=401, detail="missing X-Callback-Secret")
109
+
110
+ # Validate secret against any registered instance — each has its own.
111
+ matched_instance = None
112
+ for inst in pool.status():
113
+ # pool.status() returns dicts; we need the live instances.
114
+ pass
115
+ # Access live instances directly from the pool registry.
116
+ import hmac
117
+ valid = False
118
+ for inst_obj in pool._instances.values():
119
+ if hmac.compare_digest(x_callback_secret, inst_obj.client.callback_secret):
120
+ valid = True
121
+ break
122
+ if not valid:
123
+ raise HTTPException(status_code=401, detail="invalid callback secret")
124
+
125
+ try:
126
+ payload = await request.json()
127
+ except Exception:
128
+ raise HTTPException(status_code=400, detail="invalid json body")
129
+ if not isinstance(payload, dict) or "id" not in payload:
130
+ raise HTTPException(status_code=400, detail="missing id")
131
+
132
+ resolved = pool.resolve_callback(payload)
133
+ return {"ok": resolved}
134
+
135
+
136
+ @app.get("/media/{media_id}")
137
+ async def serve_media(media_id: str, request: Request) -> Response:
138
+ """Serve cached media bytes. Routes to the correct instance's MediaStore
139
+ by trying each registered instance in pool order."""
140
+ from flow_engine.media import normalize_media_id, is_valid_media_id
141
+
142
+ media_id = normalize_media_id(media_id)
143
+ if not is_valid_media_id(media_id):
144
+ raise HTTPException(status_code=404, detail="invalid media id")
145
+
146
+ # Try per-instance stores first (multi-tenant path).
147
+ for inst_obj in pool._instances.values():
148
+ path = await inst_obj.media.ensure_cached(media_id)
149
+ if path is not None:
150
+ return FileResponse(path, media_type=inst_obj.media.media_mime(media_id))
151
+
152
+ # Fallback: module-level media store (populated by single-tenant code paths
153
+ # that haven't been migrated yet, or for legacy compatibility).
154
+ from flow_engine import media as media_service
155
+ path = await media_service.ensure_cached(media_id)
156
+ if path is None:
157
+ raise HTTPException(status_code=404, detail="media not available")
158
+ return FileResponse(path, media_type=media_service.media_mime(media_id))
159
+
160
+
161
+ @app.get("/media/{media_id}/status")
162
+ def media_status(media_id: str) -> dict:
163
+ from flow_engine.media import normalize_media_id, is_valid_media_id
164
+
165
+ media_id = normalize_media_id(media_id)
166
+ if not is_valid_media_id(media_id):
167
+ return {"available": False, "has_url": False, "reason": "invalid_id"}
168
+
169
+ for inst_obj in pool._instances.values():
170
+ s = inst_obj.media.status(media_id)
171
+ if s.get("available") or s.get("has_url"):
172
+ return s
173
+
174
+ from flow_engine import media as media_service
175
+ return media_service.status(media_id)
176
+
177
+
178
+ def main() -> None:
179
+ import uvicorn
180
+
181
+ from flow_engine.config import HTTP_PORT
182
+
183
+ uvicorn.run("flow_engine.main:app", host="127.0.0.1", port=HTTP_PORT, reload=False)
184
+
185
+
186
+ if __name__ == "__main__":
187
+ main()
flow_engine/media.py ADDED
@@ -0,0 +1,227 @@
1
+ """Media cache — DB-free port of flowboard's media service.
2
+
3
+ flowboard persisted media to a SQLModel ``Asset`` table; flow_engine keeps the
4
+ same behaviour with a process-local registry plus an on-disk byte cache under
5
+ ``storage/media``. Flow returns short-TTL signed ``flow-content.google`` URLs
6
+ (and, for workflow-mode video, inline base64 MP4), so we fetch/plant bytes
7
+ locally and serve stable ``/media/<id>`` URLs to OpenAI clients.
8
+
9
+ Registry is in-memory and rebuilt from disk on startup — good enough for a
10
+ single-tenant proxy. Swap for Redis/SQLite if you need persistence across
11
+ restarts or a horizontal pool.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import logging
16
+ import re
17
+ import threading
18
+ from dataclasses import dataclass
19
+ from pathlib import Path
20
+ from typing import Any, Optional
21
+
22
+ import httpx
23
+
24
+ from flow_engine.config import STORAGE_DIR
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+ MEDIA_CACHE_DIR = STORAGE_DIR / "media"
29
+ MEDIA_CACHE_DIR.mkdir(parents=True, exist_ok=True)
30
+
31
+ # Media id is a hex-with-dashes UUID in the GCS path.
32
+ _MEDIA_ID_RE = re.compile(r"^[0-9a-fA-F-]{1,64}$")
33
+
34
+ # Flow serves user-generated media from its `flow-content.google` CDN (signed
35
+ # with short-TTL query params); the response includes the signed URL at
36
+ # `data.media[].image.generatedImage.fifeUrl`.
37
+ _ALLOWED_URL_PREFIXES: tuple[str, ...] = ("https://flow-content.google/",)
38
+
39
+ _EXT_BY_MIME = {
40
+ "image/jpeg": ".jpg",
41
+ "image/png": ".png",
42
+ "image/webp": ".webp",
43
+ "image/gif": ".gif",
44
+ "video/mp4": ".mp4",
45
+ "video/webm": ".webm",
46
+ }
47
+
48
+
49
+ @dataclass
50
+ class Asset:
51
+ media_id: str
52
+ url: Optional[str] = None
53
+ kind: str = "image"
54
+ local_path: Optional[str] = None
55
+ mime: Optional[str] = None
56
+
57
+
58
+ _registry: dict[str, Asset] = {}
59
+ _lock = threading.Lock()
60
+
61
+
62
+ def _url_allowed(url: Any) -> bool:
63
+ return isinstance(url, str) and any(url.startswith(p) for p in _ALLOWED_URL_PREFIXES)
64
+
65
+
66
+ def is_valid_media_id(media_id: str) -> bool:
67
+ return bool(_MEDIA_ID_RE.fullmatch(media_id or ""))
68
+
69
+
70
+ def normalize_media_id(raw: str) -> str:
71
+ """Accept either ``media/<uuid>`` or the bare uuid."""
72
+ if raw.startswith("media/"):
73
+ return raw.split("/", 1)[1]
74
+ return raw
75
+
76
+
77
+ def _cache_glob(media_id: str) -> Optional[Path]:
78
+ if not is_valid_media_id(media_id):
79
+ return None
80
+ for p in MEDIA_CACHE_DIR.glob(f"{media_id}.*"):
81
+ if p.is_file():
82
+ return p
83
+ return None
84
+
85
+
86
+ def cached_path(media_id: str) -> Optional[Path]:
87
+ return _cache_glob(media_id)
88
+
89
+
90
+ def _get(media_id: str) -> Optional[Asset]:
91
+ with _lock:
92
+ return _registry.get(media_id)
93
+
94
+
95
+ def ingest_urls(urls: list[dict[str, Any]]) -> int:
96
+ """Register Asset rows keyed by uuid media_id. Returns count touched."""
97
+ touched = 0
98
+ with _lock:
99
+ for entry in urls or []:
100
+ if not isinstance(entry, dict):
101
+ continue
102
+ media_id = entry.get("media_id") or entry.get("mediaId")
103
+ url = entry.get("url")
104
+ kind = entry.get("mediaType") or entry.get("kind") or "image"
105
+ if not isinstance(media_id, str) or not is_valid_media_id(media_id):
106
+ continue
107
+ if not _url_allowed(url):
108
+ logger.warning("media: skip non-allowed url for %s: %r", media_id, (url or "")[:80])
109
+ continue
110
+ row = _registry.get(media_id)
111
+ if row is None:
112
+ _registry[media_id] = Asset(media_id=media_id, url=url, kind=kind)
113
+ else:
114
+ row.url = url
115
+ if not row.kind:
116
+ row.kind = kind
117
+ touched += 1
118
+ if touched:
119
+ logger.info("media: ingested %d url(s)", touched)
120
+ return touched
121
+
122
+
123
+ def ingest_inline_bytes(
124
+ media_id: str, data: bytes, *, kind: str = "video", mime: str = "video/mp4"
125
+ ) -> bool:
126
+ """Cache pre-fetched media bytes (workflow-mode base64 MP4) and mark the
127
+ Asset as locally available."""
128
+ if not is_valid_media_id(media_id) or not data:
129
+ return False
130
+ ext = _EXT_BY_MIME.get(mime, ".mp4")
131
+ path = MEDIA_CACHE_DIR / f"{media_id}{ext}"
132
+ try:
133
+ path.write_bytes(data)
134
+ except OSError as exc:
135
+ logger.error("failed to write inline cache %s: %s", path, exc)
136
+ return False
137
+ with _lock:
138
+ row = _registry.get(media_id) or Asset(media_id=media_id, kind=kind)
139
+ row.local_path = str(path)
140
+ row.mime = mime
141
+ if not row.kind:
142
+ row.kind = kind
143
+ _registry[media_id] = row
144
+ logger.info("media: ingested %d inline bytes for %s", len(data), media_id)
145
+ return True
146
+
147
+
148
+ async def fetch_and_cache(media_id: str) -> Optional[tuple[bytes, str, Path]]:
149
+ """If the Asset has a URL and no cached file, fetch bytes, cache, return
150
+ ``(bytes, mime, path)``; else ``None``."""
151
+ if not is_valid_media_id(media_id):
152
+ return None
153
+ row = _get(media_id)
154
+ if row is None or not row.url:
155
+ return None
156
+ url = row.url
157
+ if not _url_allowed(url):
158
+ logger.warning("refusing to fetch non-allowed URL: %s", url[:60])
159
+ return None
160
+ try:
161
+ async with httpx.AsyncClient(timeout=30.0) as client:
162
+ resp = await client.get(url)
163
+ except Exception as exc: # noqa: BLE001
164
+ logger.warning("GCS fetch failed for %s: %s", media_id, exc)
165
+ return None
166
+ if resp.status_code != 200:
167
+ logger.warning("GCS fetch %s returned %d", media_id, resp.status_code)
168
+ return None
169
+ mime = resp.headers.get("content-type", "application/octet-stream").split(";")[0].strip()
170
+ if not (mime.startswith("image/") or mime.startswith("video/")):
171
+ logger.warning("refusing non-media mime from GCS: %s (media_id=%s)", mime, media_id)
172
+ return None
173
+ ext = _EXT_BY_MIME.get(mime, ".bin")
174
+ path = MEDIA_CACHE_DIR / f"{media_id}{ext}"
175
+ try:
176
+ path.write_bytes(resp.content)
177
+ except OSError as exc:
178
+ logger.error("failed to write cache %s: %s", path, exc)
179
+ return None
180
+ with _lock:
181
+ r = _registry.get(media_id)
182
+ if r is not None:
183
+ r.local_path = str(path)
184
+ r.mime = mime
185
+ return resp.content, mime, path
186
+
187
+
188
+ async def ensure_cached(media_id: str) -> Optional[Path]:
189
+ """Return a local file path for the media, fetching from the signed URL
190
+ on first access. ``None`` if unknown or the fetch fails."""
191
+ media_id = normalize_media_id(media_id)
192
+ cached = _cache_glob(media_id)
193
+ if cached is not None:
194
+ return cached
195
+ got = await fetch_and_cache(media_id)
196
+ return got[2] if got else None
197
+
198
+
199
+ def status(media_id: str) -> dict:
200
+ media_id = normalize_media_id(media_id)
201
+ if not is_valid_media_id(media_id):
202
+ return {"available": False, "has_url": False, "reason": "invalid_id"}
203
+ cached = _cache_glob(media_id)
204
+ if cached is not None:
205
+ return {"available": True, "has_url": True, "mime": _mime_from_ext(cached.suffix)}
206
+ row = _get(media_id)
207
+ if row is None:
208
+ return {"available": False, "has_url": False, "reason": "unknown_media"}
209
+ if row.url:
210
+ return {"available": False, "has_url": True, "reason": "not_cached_yet"}
211
+ return {"available": False, "has_url": False, "reason": "no_url_yet"}
212
+
213
+
214
+ def media_mime(media_id: str) -> str:
215
+ media_id = normalize_media_id(media_id)
216
+ cached = _cache_glob(media_id)
217
+ if cached is not None:
218
+ return _mime_from_ext(cached.suffix)
219
+ row = _get(media_id)
220
+ return (row.mime if row and row.mime else "application/octet-stream")
221
+
222
+
223
+ def _mime_from_ext(ext: str) -> str:
224
+ for mime, e in _EXT_BY_MIME.items():
225
+ if e == ext:
226
+ return mime
227
+ return "application/octet-stream"