# Shukr Public Media Service — AI Context > This document is written for LLMs and AI agents (MCP servers, Claude, ChatGPT, Cursor, etc.) to > understand the Shukr Public Media Service API — its endpoints, request/response shapes, rules, and > scope. It is the AI-ready companion to the human docs and the OpenAPI reference. All fields are > `snake_case`. Treat this file as authoritative over any older description that uses camelCase, > `type: image|video`, or paths like `/media/complete-upload`. Token verification is done locally > (HS256), with no external cache or auth-service round-trip. ## Overview The media service is a **Cloudflare Workers** microservice (Hono + Zod). It never proxies file bytes: clients upload and download **directly to Cloudflare R2** via short-lived **presigned URLs**, and videos are handed to a streaming provider for transcoding. Asset metadata lives in **Neon Postgres**. Auth uses the same JWT access tokens issued by **shukr-api**. Typical lifecycle: 1. `POST /api/v1/media/upload-url` → creates a `pending` asset + returns upload instructions. 2. Client uploads the bytes directly (R2 `PUT` for files, resumable TUS for Bunny/Cloudflare video). 3. `POST /api/v1/media/{id}/complete` → verifies the upload, marks it `uploaded` (starts encoding for video). 4. Video only: the provider transcodes and calls a webhook; `stream_status` goes `pending_upload → processing → ready` (or `failed`). 5. Deliver: `POST /api/v1/media/{id}/download-url` for files, or the playback endpoints for video. Base path: **`/api/v1`** for all business endpoints. `/health` is at the root. Interactive/OpenAPI docs (`/doc`, `/swagger`, `/reference`) are available only when `ENVIRONMENT` != `production`. ## Authentication Every `/api/v1/media/*` endpoint requires **one** of these (never both). Playback-delivery and webhook endpoints are NOT behind this guard. - **User JWT** — `Authorization: Bearer ` from shukr-api. Verified locally (HS256, no external calls). The token must include `user_id` and `type: "access"` (refresh tokens are rejected). The token's user becomes the asset owner. - **Service key (M2M)** — `X-Service-Key: ` (case-insensitive header). Service callers **must** pass `owner_user_id` (in the body for writes, query for `GET /media`) and bypass per-asset ownership checks. **Ownership**: for single-asset ops a user caller may only act on assets they own (else `403`); a service caller is trusted. Use `external_ref` to link an asset to your own entity id (place id, post id, etc.). Error codes (in `error.error_code`): `BAD_REQUEST` (400), `UNAUTHORIZED` (401), `FORBIDDEN` (403), `NOT_FOUND` (404), `INTERNAL_ERROR` (500). ## Response envelope Every JSON endpoint (except raw playback delivery) wraps its payload: ```json // Success { "success": true, "message": "...", "code": 200, "data": { /* endpoint-specific */ }, "request_id": "req_...", "timestamp": "2026-07-29T04:15:30.000Z" } // Error { "success": false, "message": "...", "code": 404, "error": { "error_code": "NOT_FOUND", "details": null }, "request_id": "req_...", "timestamp": "2026-07-29T04:15:30.000Z" } ``` `request_id` is also returned as a response header for log correlation. ## Media model A `MediaAsset` is echoed by most endpoints (under `data.asset`, `data.items[]`, etc.): - `id` (uuid), `owner_user_id`, `object_key` (`public/{content_type}/{owner_user_id}/{id}{ext}`) - `content_type`: **`place` | `post`** — a business grouping (where the media attaches), NOT a MIME type. `place` = media for a Maps location; `post` = media for a user post / feedback / note. - `mime_type`: the real MIME type (see allowed list). - `asset_kind`: **`file`** (stored in R2) | **`stream`** (video routed to a provider). - `status`: `pending` → `uploaded` → `deleted` (soft delete). - `visibility`: `public` | `private` (only affects Cloudflare signed playback; R2 URLs are always signed). - `stream_provider`: `bunny` | `onprem` | `cloudflare` | null. `provider_asset_id`, `stream_status` (`pending_upload`|`processing`|`ready`|`failed`|null), `playback` (`{hls,dash,embed,thumbnail}`|null). - `object_upload_mode`: `single_presigned_put` | `multipart_presigned_put` | `tus` | null (mirrors `upload.upload_mode`). - `file_size_bytes`, `external_ref`, `etag`, `created_at`, `updated_at`, `uploaded_at`. **Allowed `mime_type`** (normalized: lower-cased, `;params` stripped): - Image: `image/jpeg`, `image/png`, `image/webp`, `image/gif`, `image/svg+xml` - Audio: `audio/mpeg`, `audio/wav`, `audio/ogg`, `audio/mp4`, `audio/aac`, `audio/webm` - Video: `video/mp4`, `video/webm`, `video/quicktime`, `video/x-msvideo`, `video/x-matroska`, `video/ogg` A **video** MIME type makes a `stream` asset; anything else makes a `file` asset. Unsupported types → `400`. ## Upload modes `upload.upload_mode` in the `upload-url` response tells the client how to send bytes: - `single_presigned_put` — one `PUT` to `upload.upload_url` with the exact `Content-Type`. - `multipart_presigned_put` — `PUT` each entry in `upload.parts`, keep the ETags, then `complete` with them. Chosen when `upload_strategy: multipart`, or `auto` with `file_size_bytes ≥ R2_MULTIPART_THRESHOLD` (default 100 MiB). Part size = `R2_MULTIPART_PART_SIZE` (default 10 MiB). - `tus` — resumable upload to a streaming provider (Bunny / Cloudflare video). `upload_strategy`: `auto` (default) | `single` | `multipart` (applies to file and on-prem-video assets). ## Streaming providers (video) Selected via `stream_provider` (defaults to `DEFAULT_STREAM_PROVIDER`): - **bunny** — resumable **TUS** to Bunny Stream; auto-transcode; signed HLS CDN playback (`dash` is always null). Requires `BUNNY_STREAM_API_KEY`, `BUNNY_STREAM_LIBRARY_ID`, `BUNNY_STREAM_CDN_HOSTNAME`, `BUNNY_STREAM_READ_ONLY_API_KEY` (all four or Bunny is unconfigured). - **cloudflare** — **TUS direct-creator** to Cloudflare Stream; **`file_size_bytes` is required**; signed HLS/DASH. Uses `R2_ACCOUNT_ID` as the account id. - **onprem** — source uploaded to R2 (single/multipart, like a file); on `complete` the service calls your encoder (`ONPREM_ENCODER_API_URL`); the encoder writes HLS/DASH/thumb outputs back to R2 and calls the on-prem webhook. Playback is proxied through the service's `/playback/*` routes. Playback URLs for Bunny/Cloudflare are **re-signed live** on each `GET /media/{id}/playback` call (fetch just before playback; default expiry 3600s). On-prem returns stable proxy URLs whose segments carry short-lived presigned tokens. ## Batch semantics `POST /media/download-urls` and `POST /media/playbacks` accept `{ asset_ids: uuid[], owner_user_id?, expires_in? }` (`expires_in` only for download-urls). They return **`200` with partial success**: each item has an `error` field — `not_found`, `forbidden`, `not_ready`, or `not_stream` (playback of a file asset), or `null` on success. Max ids per request: `BATCH_MEDIA_MAX_ASSETS` (default 50); `asset_ids` is de-duplicated and results keep input order. --- ## Detailed API Endpoints ### 0. Health check - **Endpoint:** `GET /health` · **Auth:** none. - **Purpose:** Liveness probe. - **Response:** `data: { "status": "ok", "service": "shukr-public-media-service" }`. ### 1. Create presigned upload URL - **Endpoint:** `POST /api/v1/media/upload-url` · **Auth:** Bearer JWT or `X-Service-Key`. - **Purpose (What):** Creates a `pending` asset and returns direct-to-storage upload instructions. - **Use Case (When):** The first step to store any media — a Maps place photo/video (`content_type: place`), an image/file on a post, feedback, or note (`content_type: post`), audio, PDFs, or any allowed file for another Shukr service. - **Implementation (How) — Body (JSON):** - `filename` (string, required, 1–512) - `content_type` (string, required): `place` | `post` - `mime_type` (string, required): an allowed MIME type - `visibility` (string, optional, default `public`): `public` | `private` - `external_ref` (string, optional, ≤255): your correlation id - `owner_user_id` (string, optional): **required for service-key** callers; ignored for JWT - `stream_provider` (string, optional): `bunny` | `onprem` | `cloudflare` (video only) - `file_size_bytes` (integer, optional, > 0): required for multipart and for Cloudflare - `upload_strategy` (string, optional, default `auto`): `auto` | `single` | `multipart` - **Example Request:** ```json POST /api/v1/media/upload-url { "filename": "masjid.jpg", "content_type": "place", "mime_type": "image/jpeg", "external_ref": "place_4821" } ``` - **Example Response (201, single):** ```json { "success": true, "code": 201, "data": { "asset": { "id": "550e8400-...", "status": "pending", "asset_kind": "file", "object_upload_mode": "single_presigned_put", "content_type": "place", "mime_type": "image/jpeg" }, "upload": { "upload_mode": "single_presigned_put", "upload_url": "https://.r2.cloudflarestorage.com/...?X-Amz-Signature=...", "expires_in": 3600, "required_headers": { "Content-Type": "image/jpeg" } } } } ``` `upload` is a discriminated union on `upload_mode` — `multipart_presigned_put` adds `upload_id`, `part_size`, `part_count`, `parts[]`; `tus` adds `stream_provider` and a `tus` object (`endpoint`, `video_id`, `metadata`, and for Bunny `library_id`/`authorization_signature`/`authorization_expire`). - **Scope:** JWT (owns the new asset) or service key (`owner_user_id` required). ### 2. Complete upload - **Endpoint:** `POST /api/v1/media/{id}/complete` · **Auth:** Bearer (owner) or service key. - **Purpose (What):** Verifies the uploaded bytes and flips the asset `pending → uploaded` (starts encoding for video). - **Use Case (When):** Immediately after the client finishes the storage `PUT` / TUS upload. - **Implementation (How) — Body (JSON, optional):** - `multipart_parts` (array, required only for multipart): `[{ part_number: 1–10000, etag: string }]` — every part, no gaps or duplicates. Omit for single/TUS. - **Example Request:** `POST /api/v1/media/550e8400-.../complete` (single: empty body). - **Example Response (200):** `data: { "asset": { "id": "550e8400-...", "status": "uploaded", "etag": "...", "uploaded_at": "..." } }` - **Notes:** Idempotent — a repeat call on an `uploaded` asset is a no-op. - **Scope:** Owner (JWT) or service key. ### 3. Presign multipart parts - **Endpoint:** `POST /api/v1/media/{id}/upload-parts` · **Auth:** Bearer (owner) or service key. - **Purpose (What):** Re-issues presigned `PUT` URLs for specific multipart parts. - **Use Case (When):** Multipart uploads only — refresh part URLs that expired mid-upload, or retry a failed part. (The create call already returns all part URLs inline.) - **Implementation (How) — Body (JSON):** `part_numbers` (integer[], required, each 1–10000, ≥1 item; ≤ `R2_MULTIPART_MAX_PARTS_PER_REQUEST`, default 50). - **Example Response (200):** `data: { "asset_id": "550e8400-...", "parts": [{ "part_number": 2, "upload_url": "https://...", "expires_in": 3600 }] }` - **Scope:** Owner (JWT) or service key. Asset must be `pending` and multipart. ### 4. Abort upload - **Endpoint:** `POST /api/v1/media/{id}/abort-upload` · **Auth:** Bearer (owner) or service key. - **Purpose (What):** Cancels a pending upload and cleans up the R2 multipart session. - **Use Case (When):** The user cancels, or the upload fails and won't be retried, before `complete`. - **Implementation (How):** No body. - **Example Response (200):** `data: { "asset": { "id": "550e8400-...", "status": "pending" }, "aborted": true }` — `aborted: false` if already uploaded (no-op). - **Scope:** Owner (JWT) or service key. ### 5. Create download URL and/or playback - **Endpoint:** `POST /api/v1/media/{id}/download-url` · **Auth:** Bearer (owner) or service key. - **Purpose (What):** Returns a time-limited GET URL for a file, or playback info for a video. - **Use Case (When):** Serving a single item — show a place photo, download a note attachment, start a video. - **Implementation (How) — Body (JSON, optional):** `expires_in` (integer, 60–86400; default `R2_SIGNED_URL_EXPIRY` = 3600). - **Example Response (200):** ```json { "success": true, "code": 200, "data": { "asset_id": "550e8400-...", "download_url": "https://...?X-Amz-Signature=...", "expires_in": 900, "stream_provider": null, "stream_status": null, "playback": null } } ``` Stream assets return `download_url: null` (except on-prem, which also returns a source GET URL) and `playback` when `stream_status: ready`. - **Scope:** Owner (JWT) or service key. Asset must be `uploaded` (else `400`). ### 6. Batch download URLs and/or playback - **Endpoint:** `POST /api/v1/media/download-urls` · **Auth:** Bearer or service key. - **Purpose (What):** Resolves download URLs / playback for many assets in one call (partial success). - **Use Case (When):** A gallery or feed — all photos of a place, every attachment on a post. - **Implementation (How) — Body (JSON):** `asset_ids` (uuid[], 1–100, deduped, ≤ `BATCH_MEDIA_MAX_ASSETS`), `owner_user_id` (required for service key), `expires_in` (optional, 60–86400). - **Example Response (200):** `data: { "items": [ { "asset_id": "...", "download_url": "https://...", "expires_in": 900, "error": null }, { "asset_id": "...", "download_url": null, "error": "not_ready" } ] }` - **Scope:** JWT (own assets) or service key (`owner_user_id`). ### 7. Batch playback URLs - **Endpoint:** `POST /api/v1/media/playbacks` · **Auth:** Bearer or service key. - **Purpose (What):** Freshly signed HLS/DASH/embed/thumbnail URLs for many video assets (partial success). - **Use Case (When):** A grid of videos. File assets return `error: not_stream`; still-encoding ones `not_ready`. - **Implementation (How) — Body (JSON):** `asset_ids` (uuid[], ≤ `BATCH_MEDIA_MAX_ASSETS`), `owner_user_id` (required for service key). - **Example Response (200):** `data: { "items": [ { "asset_id": "...", "stream_provider": "cloudflare", "stream_status": "ready", "playback": { "hls": "...", "dash": "...", "embed": null, "thumbnail": "..." }, "error": null } ] }` - **Scope:** JWT (own) or service key. ### 8. List media assets - **Endpoint:** `GET /api/v1/media` · **Auth:** Bearer or service key. - **Purpose (What):** Lists a user's assets, newest first, with offset pagination. - **Use Case (When):** An owner's media library / management view. - **Implementation (How) — Query params:** - `owner_user_id` (string, required for service key; ignored for JWT) - `status` (`pending` | `uploaded` | `deleted`) — omit to hide deleted; pass `deleted` to see them - `limit` (integer, 1–100, default 20), `offset` (integer, ≥0, default 0) - **Example Request:** `GET /api/v1/media?status=uploaded&limit=20&offset=0` - **Example Response (200):** `data: { "items": [ MediaAsset, ... ], "limit": 20, "offset": 0 }` - **Scope:** JWT lists your own; service key lists `owner_user_id`. ### 9. Get media asset - **Endpoint:** `GET /api/v1/media/{id}` · **Auth:** Bearer (owner) or service key. - **Purpose (What):** Fetches one asset's stored metadata. - **Use Case (When):** Poll `status` / `stream_status` after an upload; read metadata. Does not re-sign provider playback (may be stale — use endpoint 10 to refresh). - **Example Response (200):** `data: { "asset": MediaAsset }`. `404` if missing/deleted, `403` if a JWT caller doesn't own it. - **Scope:** Owner (JWT) or service key. ### 10. Get HLS/DASH playback URLs - **Endpoint:** `GET /api/v1/media/{id}/playback` · **Auth:** Bearer (owner) or service key. - **Purpose (What):** Freshly signed HLS/DASH/embed/thumbnail URLs for one video. - **Use Case (When):** Right before playing a video (Bunny/Cloudflare URLs re-signed each call). - **Example Response (200):** `data: { "asset_id": "...", "stream_provider": "cloudflare", "stream_status": "ready", "playback": { "hls": "...", "dash": "...", "embed": null, "thumbnail": "..." } }` - **Notes:** Stream assets only; `400` until `stream_status: ready` or on a file asset. - **Scope:** Owner (JWT) or service key. ### 11. Delete media asset - **Endpoint:** `DELETE /api/v1/media/{id}` · **Auth:** Bearer (owner) or service key. - **Purpose (What):** Soft-deletes the asset and removes the object from R2 or the streaming provider. - **Use Case (When):** The user removes a photo, video, or file. - **Example Response (200):** `data: { "asset": { "id": "...", "status": "deleted" } }` - **Notes:** Record kept as `status: deleted`; existing URLs stop working. Idempotent — a second delete returns `404`. - **Scope:** Owner (JWT) or service key. ### 12. Playback delivery (public, no auth token) These are the URLs a player requests. They serve mostly **on-prem** stream outputs (Bunny/Cloudflare serve from their own CDNs). Access is bounded by asset visibility and short-lived signed segment URLs. Param is `assetId`. - `GET /api/v1/playback/{assetId}/hls.m3u8` → HLS master playlist (`application/vnd.apple.mpegurl`), rewritten so segments route back through the service. `Cache-Control: public, max-age=60`. - `GET /api/v1/playback/{assetId}/hls/*` → child manifest **or** `302` redirect to a presigned R2 segment URL. - `GET /api/v1/playback/{assetId}/dash.mpd` → DASH manifest (`application/dash+xml`), rewritten. `max-age=60`. - `GET /api/v1/playback/{assetId}/dash/{filename}` → `302` redirect to a presigned R2 segment (e.g. `chunk-video-00001.m4s`). - `GET /api/v1/playback/{assetId}/thumb.jpg` → `302` redirect to a presigned R2 thumbnail. - `GET /api/v1/playback/{assetId}/embed` → iframe-friendly HTML player (hls.js + quality selector), `text/html`, `max-age=300`. ### 13. Webhooks (provider → service) - **Endpoints:** `POST /api/v1/webhooks/stream/bunny`, `POST /api/v1/webhooks/stream/cloudflare`, `POST /api/v1/webhooks/stream/onprem` - **Purpose (What):** Inbound provider callbacks that advance a video's `stream_status` and store playback URLs when ready. - **Use Case (When):** Server-to-server only — you **register** these URLs with the provider; never call them from a client. - **Implementation (How) — signature verification (over the raw body):** - **bunny** — v1 HMAC-SHA256 using `BUNNY_STREAM_READ_ONLY_API_KEY`; headers `X-BunnyStream-Signature` (+ `-Version: v1`, `-Algorithm: hmac-sha256`). Key is **mandatory** — invalid/missing → `401`. Status 3/4/9/10 → `ready`, 7 → processing, 5/8 → `failed`. - **cloudflare** — HMAC-SHA256 over `{time}.{rawBody}` in `Webhook-Signature: time=…,sig1=…`; secret `CLOUDFLARE_STREAM_WEBHOOK_SECRET` (if unset, verification is skipped). Register once per account via `PUT /accounts/{id}/stream/webhook`. - **onprem** — `sha256(rawBody + secret)` in `X-Onprem-Signature` (or `X-Webhook-Signature`); secret `ONPREM_WEBHOOK_SECRET` (if unset, skipped). Body carries `asset_id`, `status`, and `playback` URLs. - **Response (200):** `data: { "processed": true }` — `true` when a matching asset was updated, `false` for a valid-but-unmatched/unactionable payload. `401` if the provider is not configured or the signature is invalid. - **Scope:** Public; authenticated by signature. --- ## Config knobs (defaults) `R2_SIGNED_URL_EXPIRY`=3600s · `R2_MULTIPART_THRESHOLD`=104857600 (100 MiB) · `R2_MULTIPART_PART_SIZE`=10485760 (10 MiB) · `R2_MULTIPART_MAX_PARTS_PER_REQUEST`=50 · `BATCH_MEDIA_MAX_ASSETS`=50 · `BUNNY_STREAM_TUS_EXPIRY`=86400s · `BUNNY_STREAM_PLAYBACK_URL_EXPIRY`/`CLOUDFLARE_STREAM_PLAYBACK_URL_EXPIRY`=3600s · `DEFAULT_STREAM_PROVIDER` selects the provider when a video request omits `stream_provider`.