# Media Upload & Chunked Video Upload — Frontend Guide

**Audience:** admin-dashboard, trainer, learner web and mobile frontend teams
**Backend source:** `modules/Media` (routes in `modules/Media/routes/api.php`, config in `modules/Media/config/media.php`)
**Related:** [books-dashboard.md](./books-dashboard.md#conventions) (media-hash convention), [courses-admin.md](./courses-admin.md), [courses-trainer.md](./courses-trainer.md), [training-programs-admin.md](./training-programs-admin.md)

---

## Table of contents

1. [How media works here (read this first)](#1-how-media-works-here-read-this-first)
2. [Which upload flow to use](#2-which-upload-flow-to-use)
3. [Common conventions](#3-common-conventions)
4. [Single-shot upload — `POST /media`](#4-single-shot-upload--post-media)
5. [Chunked video upload — concepts](#5-chunked-video-upload--concepts)
6. [Chunked video upload — endpoint reference](#6-chunked-video-upload--endpoint-reference)
7. [Choosing a chunk size](#7-choosing-a-chunk-size)
8. [The client algorithm, step by step](#8-the-client-algorithm-step-by-step)
9. [Reference implementation (TypeScript)](#9-reference-implementation-typescript)
10. [Attaching uploaded media to an entity](#10-attaching-uploaded-media-to-an-entity)
11. [Displaying media](#11-displaying-media)
12. [Listing and deleting your own uploads](#12-listing-and-deleting-your-own-uploads)
13. [Limits cheat sheet](#13-limits-cheat-sheet)
14. [Error reference](#14-error-reference)
15. [Edge cases and recovery playbook](#15-edge-cases-and-recovery-playbook)
16. [Mobile clients](#16-mobile-clients)
17. [Integration checklist](#17-integration-checklist)

---

## 1. How media works here (read this first)

Entity endpoints never take files directly. Uploading is always a **two-step process**:

```
 ┌──────────────┐   1. upload file     ┌────────────────────┐
 │   Frontend   │ ───────────────────► │  /media  (or       │
 │              │ ◄─────────────────── │  /media/videos/*)  │
 │              │   { hash, url, … }   └────────────────────┘
 │              │
 │              │   2. send the hash as a field value
 │              │   POST/PUT /api/v1/.../courses   { "image": "<hash>", "demo_video": "<hash>" }
 │              │ ───────────────────► ┌────────────────────┐
 │              │ ◄─────────────────── │  Entity endpoint   │
 └──────────────┘   entity with URLs   └────────────────────┘
```

1. **Upload** the file to the media module. You get back a media record whose important field is `hash` (32 hex characters).
2. **Attach** it by sending that `hash` as the value of the media field (`image`, `demo_video`, `video`, `file`, `cover_image`, …) in the normal JSON body of the entity's create/update request.

Things that follow from this design:

- **An uploaded file is "temporary" until it's attached.** Unattached uploads are deleted automatically **6 hours** after upload (a cleanup job runs every 2 minutes). If a user uploads, then leaves the form open for longer than that, the hash stops working and the entity request fails with `The media file was not found.` — upload again.
- **A hash can be attached to exactly one record.** Reusing a hash that's already attached to another record fails with `The media file is already associated with another record.` Re-sending the hash that's already attached to *the same* record is fine (no-op).
- **Entity responses return URLs, not hashes.** After attaching, `GET` on the entity gives you the file's URL in the same field (`"image": "https://…/storage/uploads/…webp"`).
- **Large videos use a different upload endpoint** (chunked, resumable), but the end result is the same media record with the same `hash`, attached the same way.

---

## 2. Which upload flow to use

| What you're uploading | Flow | Why |
|---|---|---|
| Images (avatars, covers, sliders, category/partner images, flags) | Single-shot `POST /media` | Small; server converts to WebP and makes thumbnail/medium/large variants. |
| PDFs / course materials | Single-shot `POST /media` | ≤ 10 MB. |
| Book `digital_file` (private PDF) | Single-shot `POST /media` with `collection=digital_file` | Stored on a private disk; admins only. See §4.4. |
| **Any video** (course `demo_video`, program `demo_video`, lesson `video`) | **Chunked** `POST /media/videos/chunk` | Resumable, retryable, real progress, up to 2 GB. Use it even for small videos — one code path, and a 1-chunk upload is just one request. |
| Audio (`mp3`, `wav`) | Single-shot `POST /media` | ≤ 10 MB. |

> The single-shot endpoint also accepts video extensions, but it's capped at 10 MB and gives you no resume. Don't use it for videos.

---

## 3. Common conventions

### 3.1 Base URL — note: no `/api/v1` prefix

The media module is mounted at the **host root**, not under `/api/v1`:

| Variable | Example | Used for |
|---|---|---|
| `{{media_base_url}}` | `https://api.example.com` | `/media`, `/media/videos/*` |
| `{{base_url}}` | `https://api.example.com/api/v1` | Entity endpoints where you send the hash |

So it's `POST https://api.example.com/media`, **not** `…/api/v1/media`.

### 3.2 Required headers

| Header | Value | Notes |
|---|---|---|
| `Authorization` | `Bearer <jwt>` | Every media endpoint requires login (`auth:api`) and an active account. |
| `Accept` | `application/json` | **Mandatory here.** Because these routes aren't under `/api/`, the server decides JSON-vs-HTML from this header. Without it, some errors (401, 404 on upload sessions) come back as HTML pages or redirects. |
| `Content-Type` | *don't set it* for uploads | Let the browser/HTTP client set `multipart/form-data; boundary=…` from the `FormData` body. Setting it by hand drops the boundary and the server sees no file. |
| `Accept-Language` | `en` \| `ar` | Error messages are localized. |

### 3.3 Response envelope

Every response uses the project envelope:

```json
{ "status": "success" | "fail" | "error", "message": "…", "data": … }
```

- `success` — 2xx.
- `fail` — client-side problem (validation, auth, throttling). **Validation errors carry only the first error message** in `message`, with `data: null` — there is no per-field error map. Show `message` to the user.
- `error` — server-side / domain error (e.g. upload session not found), `data` may hold `{ "error_code": "…" }`.

**Always branch on the HTTP status code**, not only on `status` — one endpoint (`DELETE /media/{id}` when nothing matches) returns `status: "success"` with HTTP 404. See §12.2.

### 3.4 Rate limits

| Endpoints | Limit | Keyed by |
|---|---|---|
| `POST /media/videos/chunk`, `GET /media/videos/{id}/status`, `DELETE /media/videos/{id}` | **120 requests / minute** | the logged-in user |
| `GET/POST/DELETE /media` | none currently | — |

The 120/min budget is shared by *all* the chunk, status and cancel calls a user makes, across tabs and parallel uploads. A throttled request returns HTTP `429` with `"Too many requests, please try again after a while!"`. Treat a 429 as retryable after a wait (see §9); **the response doesn't reliably carry a `Retry-After` header**, so fall back to waiting ~20 s.

> ⚠️ **Side effect to be aware of:** the server currently counts these requests against the same per-user counter as other throttled endpoints (for example `POST /notifications/stream-ticket`, limit 20/min, and the book reader, limit 30/min). While a big upload is running fast, those endpoints can return `429` for the same user. The live notification stream itself is not throttled this way (see [notifications-integration.md](./notifications-integration.md)); only the optional ticket endpoint is. Don't treat this as a fatal error.

---

## 4. Single-shot upload — `POST /media`

### 4.1 Request

`POST {{media_base_url}}/media` — `multipart/form-data`

| Field | Type | Required | Rules |
|---|---|---|---|
| `file` | file | yes | Max **10 MB** (10240 KB). Extensions: `jpg, jpeg, png, gif, webp, pdf, avi, mov, wmv, flv, mkv, mp4, mp3, wav`. |
| `collection` | string | yes | Max 255 chars. A label for where the file is stored. **Send the name of the field you'll attach it to** (`image`, `cover_image`, `file`, …). The one value with special meaning is `digital_file` (see §4.4). |

The `collection` you send at upload time is only a storage folder. When you attach the hash to an entity, the server re-files it under that entity's field name, so a mismatch doesn't break anything, except for `digital_file`.

```ts
const form = new FormData();
form.append('file', file);            // File/Blob from <input type="file">
form.append('collection', 'image');

const res = await fetch(`${MEDIA_BASE_URL}/media`, {
  method: 'POST',
  headers: { Accept: 'application/json', Authorization: `Bearer ${token}` },
  body: form,
});
const body = await res.json();
if (res.status !== 201) throw new Error(body.message);
const media = body.data; // { id, hash, url, … }
```

### 4.2 Success — `201 Created`

```json
{
  "status": "success",
  "message": "Media uploaded successfully",
  "data": {
    "id": 812,
    "hash": "9f2c4b1ae07d4c55b8a3f1d2e6c7b901",
    "url": "https://api.example.com/storage/uploads/image/1f0c…e2.webp",
    "mime_type": "image/webp",
    "size": 48213,
    "original_name": "profile-photo.jpg",
    "variants": {
      "thumbnail": "https://api.example.com/storage/uploads/image/variants/1f0c…e2_thumbnail.webp",
      "medium":    "https://api.example.com/storage/uploads/image/variants/1f0c…e2_medium.webp",
      "large":     "https://api.example.com/storage/uploads/image/variants/1f0c…e2_large.webp"
    }
  }
}
```

| Field | Meaning |
|---|---|
| `id` | Numeric media id. **Only** used for `DELETE /media/{id}`. |
| `hash` | **What you send to entity endpoints.** Keep it in form state. |
| `url` | Public URL. Use it for an instant preview. |
| `mime_type` | The type the server detected from the file content. For converted images it's the *new* type (`image/webp`). |
| `size` | Bytes stored. For converted images this is the size of the WebP, not of the original. |
| `original_name` | The name the user picked. Display only. |
| `variants` | Only for images the server processed (see below). Omitted otherwise. |

### 4.3 What the server does to images

- `jpeg`, `png` and `gif` uploads are **re-encoded to WebP** (quality 82). `webp` uploads are stored as-is.
- Three variants are generated for converted images:

  | Variant | Box | Fit |
  |---|---|---|
  | `thumbnail` | 150 × 150 | cover (cropped to fill) |
  | `medium` | 800 × 800 | contain (never upscaled) |
  | `large` | 1920 × 1920 | contain (never upscaled) |

- **Animated GIFs become a single static frame.** If animation matters, don't upload a GIF.
- EXIF orientation isn't applied, so a phone photo taken in portrait may come out rotated. If you care, normalize orientation on the client (draw to a canvas) before uploading.
- Variant URLs are **only returned in this upload response**. Entity endpoints return the main `url` only (§11).

### 4.4 Private files — book `digital_file`

Paid book PDFs must never be publicly reachable, so they go to a private disk:

- Upload with `collection=digital_file`. The file must be a PDF.
- Only **super admins and supervisors** may upload into this collection. Anyone else gets `403` `"This action is unauthorized."`.
- The returned `url` isn't publicly usable. Serve the file through the book endpoints (`GET …/books/{book}/file`, see [books-dashboard.md](./books-dashboard.md)).
- Attaching a PDF uploaded under any **other** collection to `digital_file` fails with `Upload the digital file with collection=digital_file so it is stored privately.` Attaching doesn't move files, so you have to re-upload.

### 4.5 Storage quota

Each user has a **500 MB** storage quota, counted over **every** file they've ever uploaded, attached or not (image variants included). Uploading past it fails with HTTP `422` and, currently, the generic message `"Something went wrong"`. The same applies to chunked uploads, where the check runs when the final chunk is merged (§8.6). Freeing space means deleting old uploads (§12.2).

> This quota applies to admins and trainers too. A trainer uploading several long lesson videos can reach 500 MB quickly. If you see unexplained 422s on large uploads, this is the likely cause. Raise it with the backend team rather than retrying.

---

## 5. Chunked video upload — concepts

### 5.1 The model

The client cuts the video into numbered pieces (**chunks**) and sends each one in its own request. The server keeps the chunks in a private scratch folder called an **upload session**, identified by an `upload_id`. When a request arrives and the server sees that **every chunk from `0` to `total_chunks - 1` is present**, it joins them in order, checks that the result is a real video, stores it as a normal media record, deletes the scratch chunks, and returns the media (with its `hash`) in that same response.

```
file (73 MB), chunk size 5 MiB  →  15 chunks: 0 … 14

POST chunk 0   → 202  missing [1..14]
POST chunk 1   → 202  missing [2..14]
   …                         (any order, up to N in parallel)
POST chunk 13  → 202  missing [14]
POST chunk 14  → 201  completed: true, media: { hash, url, … }   ← merge happens here
```

### 5.2 Properties you can rely on

| Property | Meaning for the client |
|---|---|
| **Any order** | Chunks may arrive in any order. Only indexes matter. |
| **Idempotent chunks** | Sending the same `chunk_index` again **overwrites** it. Retrying a chunk is always safe. |
| **Resumable** | The session survives dropped connections, page reloads and app restarts. Ask the status endpoint which chunks are missing and send only those. |
| **Merge on completion** | There's no separate "finish" call. The request that delivers the last missing chunk triggers the merge, and its response contains the media. |
| **Session lifetime** | An abandoned session is purged **24 hours after its last chunk was written**. After that, status returns 404 and you have to start over. |
| **Session is deleted after success** | Once merged, the session is gone. Status on a completed upload returns **404**, not "completed". |

### 5.3 What the client owns

- **The `upload_id`.** Generate it on the client (`crypto.randomUUID()`) before the first chunk. The server will generate one if you omit it, but then you can't send chunks in parallel from the start, and you can't resume if the first response is lost. Allowed: letters, digits and `-`, max 64 chars. A UUID fits. **Use a new `upload_id` for every file.** The server trusts your indexes, so reusing an id could merge leftover chunks of a previous file into the new one.
- **The chunk boundaries.** `chunk_index i` = bytes `[i × chunkSize, min((i+1) × chunkSize, file.size))`. The server doesn't care about chunk sizes, but it concatenates in index order, so **a session must keep the same `chunkSize` for its whole life, including after resume.** Store it together with the `upload_id`.
- **`total_chunks`.** `Math.ceil(file.size / chunkSize)`. Send the **same value on every request** of a session.

---

## 6. Chunked video upload — endpoint reference

### 6.1 Upload one chunk — `POST /media/videos/chunk`

`POST {{media_base_url}}/media/videos/chunk` — `multipart/form-data`

| Field | Type | Required | Rules / notes |
|---|---|---|---|
| `upload_id` | string | recommended | `^[A-Za-z0-9-]+$`, max 64. Omit only if you accept a server-generated id (read it from the response). |
| `chunk` | file (Blob) | yes | The bytes of this chunk. Max **20 MB** per chunk at the application level. The real limit may be lower, see §7. |
| `chunk_index` | integer | yes | `0 … total_chunks - 1`. |
| `total_chunks` | integer | yes | `1 … 10000`. Same value on every request of the session. |
| `file_name` | string | yes | The original file name, e.g. `intro lecture.mp4`. Max 255. **Extension must be one of `mp4, m4v, mov, avi, mkv, webm`** (case-insensitive), or the very first chunk is rejected. Send it on every request. |
| `total_size` | integer | recommended | `file.size` in bytes. Max 2 GB (2147483648). Sending it rejects a too-big file **on the first chunk** instead of after it's all uploaded. |
| `collection` | string | optional | Max 100. Defaults to `videos`. Send the field name you'll attach to (`demo_video`, `video`), same reasoning as §4.1. |

When you append a `Blob` slice, pass a filename as the third argument so it's sent as a file part: `form.append('chunk', blob, 'chunk')`.

**Response — `202 Accepted`** (chunk stored, still waiting for others):

```json
{
  "status": "success",
  "message": "Video chunk uploaded successfully",
  "data": {
    "upload_id": "3f6c2a9e-8f7b-4c1d-9a0e-5b2d7c4e1f00",
    "total_chunks": 15,
    "received_chunks": 9,
    "missing_chunks": [3, 7, 10, 11, 12, 13],
    "completed": false,
    "media": null
  }
}
```

**Response — `201 Created`** (this chunk completed the set; video merged and stored):

```json
{
  "status": "success",
  "message": "Video uploaded successfully",
  "data": {
    "upload_id": "3f6c2a9e-8f7b-4c1d-9a0e-5b2d7c4e1f00",
    "total_chunks": 15,
    "received_chunks": 15,
    "missing_chunks": [],
    "completed": true,
    "media": {
      "id": 813,
      "hash": "b7e0d9c2a4f14e6b9c3d2a1f0e8b7c6d",
      "url": "https://api.example.com/storage/uploads/demo_video/8a1f…c3.mp4",
      "mime_type": "video/mp4",
      "size": 76546048,
      "original_name": "intro-lecture.mp4"
    }
  }
}
```

Notes:
- Branch on `data.completed` (or on HTTP `201` vs `202`).
- `media` has the same shape as a single-shot upload, without `variants` (videos aren't transcoded).
- `original_name` is **normalized**: path stripped, base name slugified, extension lower-cased, e.g. `"My Intro (final).MOV"` → `"my-intro-final.mov"`. Non-Latin names may be transliterated or fall back to `video.<ext>`. Show the user's own file name in the UI, not this.
- `size` equals `file.size` exactly. Videos are stored byte-for-byte.
- **The 201 response can be slow.** The merge (joining chunks, content sniffing, hashing, copying to storage) runs inside that request. For a 2 GB file this can take tens of seconds or more. Switch the UI to a "Processing…" state once the last chunk's bytes are sent, and give that request a long timeout (§8.6).

### 6.2 Get session status — `GET /media/videos/{upload_id}/status`

`GET {{media_base_url}}/media/videos/{upload_id}/status?total_chunks={n}`

| Param | In | Required | Notes |
|---|---|---|---|
| `upload_id` | path | yes | |
| `total_chunks` | query | yes | Same value the session uses. The server needs it to work out what's missing. |

**Response — `200 OK`**

```json
{
  "status": "success",
  "message": "Video upload status retrieved successfully",
  "data": {
    "upload_id": "3f6c2a9e-8f7b-4c1d-9a0e-5b2d7c4e1f00",
    "total_chunks": 15,
    "received_chunks": 12,
    "missing_chunks": [3, 7, 14],
    "completed": false,
    "media": null
  }
}
```

- `completed` is **always `false`** and `media` is always `null` here. Status never merges.
- `missing_chunks: []` with `completed: false` means all chunks are buffered but no merge has succeeded yet. Either a merge is running right now (you sent the last chunk and are waiting for its response), or a merge failed. To trigger the merge again, re-send any one chunk (§8.6).

**Response — `404`** — there's no session with that id:

```json
{ "status": "error", "message": "Upload session not found", "data": { "error_code": "ENTITY_NOT_FOUND" } }
```

A 404 means one of:
1. the upload **already completed** (sessions are deleted after a successful merge),
2. the merged file was **rejected** (not a video / too large, and the session was dropped),
3. it was **cancelled**,
4. it **expired** (24 h without activity), or
5. no chunk was ever received.

The status endpoint can't tell these apart. §8.7 shows how to find out whether the video actually made it.

### 6.3 Cancel a session — `DELETE /media/videos/{upload_id}`

`DELETE {{media_base_url}}/media/videos/{upload_id}`

- `200` → `{ "status": "success", "message": "Video upload cancelled successfully", "data": [] }`. All buffered chunks are deleted.
- `404` → session doesn't exist (same body as §6.2). Treat as success: there's nothing left to clean up.

Call this when the user cancels, removes the file from the form, or you give up. The server would purge it after 24 h anyway, but a 2 GB half-upload shouldn't sit on disk for a day.

Cancelling **doesn't** delete an already-completed video. That's a normal media record; delete it with `DELETE /media/{id}` (§12.2).

---

## 7. Choosing a chunk size

**Recommended default: 5 MiB (5 × 1024 × 1024 bytes).**

The application accepts chunks up to 20 MB, but the **effective** limit is the smallest of:

| Limit | Where it lives | What you see when exceeded |
|---|---|---|
| App rule: 20 MB per chunk | `media.video.max_chunk_size` | `422` "The chunk may not be greater than 20480 kilobytes." |
| PHP `upload_max_filesize` | server `php.ini` | `422` "The chunk failed to upload." |
| PHP `post_max_size` | server `php.ini` | `413` with message "Something went wrong" |
| Reverse-proxy body limit (e.g. nginx `client_max_body_size`) | server / proxy config | `413`, often an **HTML** body (JSON parse fails) |

The server-level limits vary by environment. **Confirm them with the backend team per environment** and keep the chunk size in frontend config (`VITE_VIDEO_CHUNK_SIZE` or similar) rather than hard-coding it. Also leave room for the multipart overhead of the other form fields.

Other trade-offs:

| Smaller chunks (1–5 MiB) | Larger chunks (10–20 MiB) |
|---|---|
| Less work lost when a request fails | Fewer requests, so less HTTP overhead |
| Smoother progress bar | Stays well under the 120 req/min throttle on fast connections |
| More requests, so you can hit 429 on very fast links | A failed request re-sends more data |

The rate-limit math: at 5 MiB per chunk, a user on a 100 Mbit/s uplink (~12 MB/s) finishes ~2.4 chunks/s ≈ 140 requests/min and will hit the 120/min limit. Two ways to handle it:
- simply handle `429` with a wait and retry (the reference code does), or
- adapt: if chunks consistently finish in under ~0.6 s, use a bigger chunk size for the **next** upload. Never change it mid-session.

Maximum file size check: `Math.ceil(file.size / chunkSize)` must be ≤ 10000. At 5 MiB that's ~48 GB, so the 2 GB file limit always wins.

---

## 8. The client algorithm, step by step

### 8.1 Validate before sending anything

Reject early, on the client:

- extension in `mp4, m4v, mov, avi, mkv, webm` (case-insensitive),
- `file.size > 0`,
- `file.size ≤ 2 GB` (2147483648 bytes), and ≤ the target field's own limit (§13, e.g. 500 MB for lesson and demo videos),
- the extension is accepted by the **target field** (course/program/lesson videos: `mp4, mov, webm`, see §10.2).

The server re-checks the extension on the first chunk and the real content type at merge time (a renamed `.txt` → `.mp4` is rejected after upload), but a client-side check saves the user from uploading 500 MB first.

### 8.2 Plan the session

```
chunkSize   = configured size (or the one saved for this file, when resuming)
totalChunks = ceil(file.size / chunkSize)
uploadId    = crypto.randomUUID()   (or the saved one, when resuming)
```

Persist `{ uploadId, chunkSize, totalChunks, updatedAt }` keyed by a **file fingerprint** (`name + size + lastModified`) in `localStorage` (web) or app storage (mobile). This is what makes resume after reload possible.

### 8.3 On resume: ask the server what's missing

If you found saved state for this fingerprint and it's less than ~20 hours old:

```
GET /media/videos/{uploadId}/status?total_chunks={totalChunks}
  200 → pending = data.missing_chunks        (if empty, pending = [totalChunks - 1]; see §8.6)
  404 → the session is gone → check whether it completed (§8.7); if not, start fresh with a new uploadId
```

A browser can't reopen a file after a reload, so the user has to **pick the same file again**. The fingerprint match is what lets you continue instead of restarting. Tell the user this is happening ("Resuming upload — 64% already on the server").

### 8.4 Send all but one chunk, in parallel

Keep one chunk aside as the **final chunk** (the highest pending index). Send the rest with a small concurrency pool (**2–4** in flight; 3 is a good default).

**Why hold one back:** the merge is triggered by whichever request finds nothing missing. If the last two chunks land at the same moment, both requests can see a complete set and both try to merge. That gives you a duplicate media record, or one of them fails with `Chunk N could not be read, please upload it again.` Sending the final chunk alone, **after every other chunk has returned 202**, removes that race.

Per chunk:

```
form = { upload_id, chunk: file.slice(start, end), chunk_index, total_chunks, file_name, total_size, collection }
POST /media/videos/chunk
  202 → mark done, update progress
  201 → (shouldn't happen before the final chunk, but if it does) you're finished; use data.media
  429 / 5xx / network error / timeout → retry this chunk with backoff (same index, same bytes: always safe)
  401 → refresh the token if your app supports it, then retry once; otherwise stop and send the user to login
  403 → account deactivated; stop
  other 4xx (422 …) → stop; show data.message (e.g. bad extension, total_size too big)
```

Retry policy (per chunk): up to 5 attempts, exponential backoff `1s, 2s, 4s, 8s, 16s` (+ random jitter), capped at 30 s. For 429, wait at least 20 s (or the `Retry-After` header if you can read it).

### 8.5 Progress

- **Bytes on the server** = sum of the sizes of acknowledged chunks (plus, on resume, the sizes of chunks the status call said were already there).
- **Live bytes** = the in-flight chunks' `upload.onprogress` values (needs `XMLHttpRequest` or axios; `fetch` doesn't expose upload progress).
- `percent = (acknowledged + in-flight) / file.size`.
- Once the final chunk's bytes are fully sent, switch to an **indeterminate "Processing…"** state until its response arrives. The merge can take a while (§6.1).

### 8.6 Send the final chunk, and handle what can happen

Send the held-back chunk **alone**, with a **long timeout** (recommend 10 minutes). Don't blindly retry it: if the first attempt is still merging on the server, a second one can race it.

| Outcome | What it means | What to do |
|---|---|---|
| `201`, `completed: true` | Done | Use `data.media.hash`. Clear saved resume state. |
| `202`, `completed: false`, `missing_chunks` non-empty | A chunk you thought was stored isn't there (rare: lost on disk, or you mis-tracked) | Send the missing ones (in parallel), then send the final chunk again. |
| `422` "Chunk N could not be read, please upload it again." | A buffered chunk vanished between the check and the merge | Call status, send what's missing, then the final chunk again. |
| `422` "The merged file is not a valid video." | The content isn't a video (renamed file, corrupt export) | Fatal. The server already deleted the session. Tell the user the file isn't a valid video. |
| `422` "The video may not be greater than 2097152 kilobytes." | Merged file > 2 GB | Fatal. The session is deleted. |
| `422` "Something went wrong" | Most likely the **storage quota** (§4.5) | Call status: if the session still exists with `missing_chunks: []`, it's the quota. Cancel the session (free the disk) and tell the user. Not retryable. |
| **No response**: timeout, network drop, or `502/503/504` from a proxy | **Unknown.** The server may still be merging, or may have finished | Don't resend yet. Poll status every ~10 s: 404 → go to §8.7; `missing_chunks` non-empty → send those + final chunk; `missing_chunks: []` → keep polling (merge still running) for up to ~5 min, then resend the final chunk once. |
| Other `5xx` with a JSON body | The server finished the request but failed | Call status and continue as in the row above, but with no wait: nothing is still merging. |

### 8.7 "Did it actually complete?" — recovering a lost 201

If the connection dropped on the final chunk and status now returns `404`, the video may well have been stored. Only the response was lost. To check:

```
GET /media?page=1           (your own uploads, newest first, 15 per page)
look for an item with  size === file.size  &&  mime_type starts with "video/"
  found     → that's your media; use its hash
  not found → it was rejected, cancelled or expired → start a fresh upload (new uploadId)
```

Matching on exact byte size is reliable in practice for a just-finished upload. `original_name` can be used as a tie-breaker, but remember it's slugified (§6.1).

### 8.8 After success

1. Put `media.hash` in the form field (`demo_video`, `video`, …).
2. Clear the saved resume state.
3. Submit the entity create/update request **within 6 hours** (§1).
4. If the user removes the video from the form before saving, `DELETE /media/{media.id}` so it doesn't count against their quota (it would be purged after 6 h, but the quota counts it until then).

### 8.9 Cancel / leave

- **User clicks cancel:** abort in-flight requests (`AbortController` / `xhr.abort()`), then `DELETE /media/videos/{uploadId}` (ignore 404), then clear saved state.
- **User navigates away / closes the tab:** keep the saved state. They can resume within ~24 h by choosing the same file. Optionally show a `beforeunload` warning while an upload is running.
- **Component unmount (SPA route change):** decide per product. Either keep the upload running in a global store/service (recommended for long videos), or abort without cancelling so it can be resumed later.

---

## 9. Reference implementation (TypeScript)

Framework-agnostic and dependency-free. Uses `XMLHttpRequest` for byte-level upload progress. Adapt the token source, config and error display to your app.

### 9.1 Types and HTTP helper

```ts
// media/types.ts
export type EnvelopeStatus = 'success' | 'fail' | 'error';

export interface ApiEnvelope<T> {
  status: EnvelopeStatus;
  message: string;
  data: T;
}

export interface UploadedMedia {
  id: number;
  hash: string;
  url: string;
  mime_type: string;
  size: number;
  original_name: string;
  variants?: Partial<Record<'thumbnail' | 'medium' | 'large', string>>;
}

export interface ChunkSessionState {
  upload_id: string;
  total_chunks: number;
  received_chunks: number;
  missing_chunks: number[];
  completed: boolean;
  media: UploadedMedia | null;
}

export class UploadError extends Error {
  constructor(
    message: string,
    /** HTTP status, or null when no response was received (network error / timeout). */
    public readonly httpStatus: number | null,
    public readonly retryable: boolean,
  ) {
    super(message);
    this.name = 'UploadError';
  }
}
```

```ts
// media/http.ts
import { ApiEnvelope, UploadError } from './types';

export const MEDIA_BASE_URL: string = import.meta.env.VITE_MEDIA_BASE_URL; // host only, no /api/v1

export interface HttpResult<T> {
  httpStatus: number;
  body: ApiEnvelope<T>;
  retryAfterMs: number | null;
}

export interface SendOptions {
  method: 'GET' | 'POST' | 'DELETE';
  path: string;
  token: string;
  body?: FormData;
  timeoutMs?: number;
  signal?: AbortSignal;
  onUploadProgress?: (loadedBytes: number) => void;
}

export const abortError = () => new DOMException('Upload aborted', 'AbortError');

/** One HTTP call. Resolves for any HTTP response; rejects only for network errors, timeouts and aborts. */
export function send<T>(opts: SendOptions): Promise<HttpResult<T>> {
  return new Promise((resolve, reject) => {
    if (opts.signal?.aborted) return reject(abortError());

    const xhr = new XMLHttpRequest();
    xhr.open(opts.method, `${MEDIA_BASE_URL}${opts.path}`);
    xhr.setRequestHeader('Accept', 'application/json'); // mandatory for /media routes (§3.2)
    xhr.setRequestHeader('Authorization', `Bearer ${opts.token}`);
    xhr.timeout = opts.timeoutMs ?? 0;

    if (opts.onUploadProgress) {
      xhr.upload.onprogress = (e) => opts.onUploadProgress!(e.loaded);
    }

    const onAbort = () => xhr.abort();
    opts.signal?.addEventListener('abort', onAbort, { once: true });
    const done = () => opts.signal?.removeEventListener('abort', onAbort);

    xhr.onload = () => {
      done();
      let body: ApiEnvelope<T>;
      try {
        body = JSON.parse(xhr.responseText);
      } catch {
        // e.g. an HTML 413 page from the reverse proxy
        body = { status: 'error', message: xhr.statusText || `HTTP ${xhr.status}`, data: null as T };
      }
      const retryAfter = Number(xhr.getResponseHeader('Retry-After'));
      resolve({
        httpStatus: xhr.status,
        body,
        retryAfterMs: Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1000 : null,
      });
    };
    xhr.onerror = () => { done(); reject(new UploadError('Network error', null, true)); };
    xhr.ontimeout = () => { done(); reject(new UploadError('Request timed out', null, true)); };
    xhr.onabort = () => { done(); reject(abortError()); };

    xhr.send(opts.body ?? null);
  });
}

export function sleep(ms: number, signal?: AbortSignal): Promise<void> {
  return new Promise((resolve, reject) => {
    if (signal?.aborted) return reject(abortError());
    const t = setTimeout(resolve, ms);
    signal?.addEventListener('abort', () => { clearTimeout(t); reject(abortError()); }, { once: true });
  });
}
```

### 9.2 Single-shot upload

```ts
// media/uploadFile.ts
import { send } from './http';
import { UploadedMedia, UploadError } from './types';

export async function uploadFile(
  file: File,
  collection: string,
  token: string,
  opts: { signal?: AbortSignal; onProgress?: (percent: number) => void } = {},
): Promise<UploadedMedia> {
  if (file.size > 10 * 1024 * 1024) {
    throw new UploadError('File is larger than 10 MB.', null, false);
  }

  const form = new FormData();
  form.append('file', file);
  form.append('collection', collection);

  const res = await send<UploadedMedia>({
    method: 'POST',
    path: '/media',
    token,
    body: form,
    signal: opts.signal,
    onUploadProgress: (loaded) => opts.onProgress?.(Math.min(100, (loaded / file.size) * 100)),
  });

  if (res.httpStatus !== 201) {
    throw new UploadError(res.body.message, res.httpStatus, res.httpStatus === 429 || res.httpStatus >= 500);
  }
  return res.body.data;
}
```

### 9.3 Resume-state persistence

```ts
// media/resumeStore.ts
export interface ResumeState {
  uploadId: string;
  chunkSize: number;
  totalChunks: number;
  updatedAt: number; // ms epoch
}

const PREFIX = 'video-upload:';
const MAX_AGE_MS = 20 * 60 * 60 * 1000; // stay safely under the server's 24h session TTL

const keyFor = (file: File) => `${PREFIX}${file.name}|${file.size}|${file.lastModified}`;

export function loadResumeState(file: File): ResumeState | null {
  try {
    const raw = localStorage.getItem(keyFor(file));
    if (!raw) return null;
    const state = JSON.parse(raw) as ResumeState;
    return Date.now() - state.updatedAt < MAX_AGE_MS ? state : null;
  } catch {
    return null; // private mode / blocked storage: resume just won't be available
  }
}

export function saveResumeState(file: File, state: ResumeState): void {
  try { localStorage.setItem(keyFor(file), JSON.stringify(state)); } catch { /* ignore */ }
}

export function clearResumeState(file: File): void {
  try { localStorage.removeItem(keyFor(file)); } catch { /* ignore */ }
}
```

### 9.4 Chunked video uploader

```ts
// media/uploadVideo.ts
import { send, sleep } from './http';
import { ChunkSessionState, UploadedMedia, UploadError } from './types';
import { clearResumeState, loadResumeState, saveResumeState } from './resumeStore';

export const VIDEO_EXTENSIONS = ['mp4', 'm4v', 'mov', 'avi', 'mkv', 'webm'];
export const MAX_VIDEO_BYTES = 2 * 1024 * 1024 * 1024; // 2 GB
const DEFAULT_CHUNK_SIZE = 5 * 1024 * 1024;              // 5 MiB (§7)
const CHUNK_TIMEOUT_MS = 2 * 60 * 1000;
const FINAL_CHUNK_TIMEOUT_MS = 10 * 60 * 1000;            // the merge runs inside this request
const MAX_ATTEMPTS = 5;
const STATUS_POLL_INTERVAL_MS = 10_000;
const STATUS_POLL_MAX = 30;                               // ~5 minutes

export type UploadPhase = 'uploading' | 'processing';

export interface VideoUploadOptions {
  file: File;
  /** Called before every request, so a refreshed token is picked up during long uploads. */
  getToken: () => string | Promise<string>;
  /** The entity field you'll attach to: 'demo_video' | 'video'. */
  collection?: string;
  chunkSize?: number;
  concurrency?: number;
  /** Extra per-field limit, e.g. 500 MB for lesson videos (§13). */
  maxBytes?: number;
  signal?: AbortSignal;
  onProgress?: (p: { percent: number; uploadedBytes: number; totalBytes: number; phase: UploadPhase; resumed: boolean }) => void;
}

export async function uploadVideo(opts: VideoUploadOptions): Promise<UploadedMedia> {
  const { file, signal } = opts;
  const concurrency = Math.max(1, Math.min(opts.concurrency ?? 3, 4));

  // ── 8.1 validate ──────────────────────────────────────────────────────────
  const ext = file.name.split('.').pop()?.toLowerCase() ?? '';
  if (!VIDEO_EXTENSIONS.includes(ext)) throw new UploadError(`Unsupported video type ".${ext}".`, null, false);
  if (file.size <= 0) throw new UploadError('The file is empty.', null, false);
  if (file.size > Math.min(MAX_VIDEO_BYTES, opts.maxBytes ?? Infinity)) {
    throw new UploadError('The video is too large.', null, false);
  }

  // ── 8.2 / 8.3 plan or resume ──────────────────────────────────────────────
  const saved = loadResumeState(file);
  const chunkSize = saved?.chunkSize ?? opts.chunkSize ?? DEFAULT_CHUNK_SIZE;
  const totalChunks = Math.ceil(file.size / chunkSize);
  let uploadId = saved?.uploadId ?? crypto.randomUUID();
  let pending: number[] = Array.from({ length: totalChunks }, (_, i) => i);
  let resumed = false;

  const chunkBytes = (i: number) => Math.min(chunkSize, file.size - i * chunkSize);
  const acked = new Set<number>();
  const inflight = new Map<number, number>();
  let phase: UploadPhase = 'uploading';

  const report = () => {
    let bytes = 0;
    acked.forEach((i) => (bytes += chunkBytes(i)));
    inflight.forEach((loaded, i) => (bytes += Math.min(loaded, chunkBytes(i))));
    opts.onProgress?.({
      percent: Math.min(100, (bytes / file.size) * 100),
      uploadedBytes: bytes,
      totalBytes: file.size,
      phase,
      resumed,
    });
  };

  const persist = () => saveResumeState(file, { uploadId, chunkSize, totalChunks, updatedAt: Date.now() });

  const fetchStatus = async (): Promise<ChunkSessionState | null> => {
    const res = await send<ChunkSessionState>({
      method: 'GET',
      path: `/media/videos/${encodeURIComponent(uploadId)}/status?total_chunks=${totalChunks}`,
      token: await opts.getToken(),
      signal,
      timeoutMs: 30_000,
    });
    if (res.httpStatus === 404) return null;
    if (res.httpStatus !== 200) throw new UploadError(res.body.message, res.httpStatus, res.httpStatus >= 500 || res.httpStatus === 429);
    return res.body.data;
  };

  /** §8.7: the session is gone. Did the merge succeed before we lost the response? */
  const findCompleted = async (): Promise<UploadedMedia | null> => {
    const res = await send<UploadedMedia[]>({ method: 'GET', path: '/media?page=1', token: await opts.getToken(), signal });
    if (res.httpStatus !== 200 || !Array.isArray(res.body.data)) return null;
    return res.body.data.find((m) => m.size === file.size && m.mime_type.startsWith('video/')) ?? null;
  };

  const finish = (media: UploadedMedia) => {
    clearResumeState(file);
    phase = 'processing';
    acked.clear();
    for (let i = 0; i < totalChunks; i++) acked.add(i);
    report();
    return media;
  };

  if (saved) {
    const status = await fetchStatus();
    if (status === null) {
      const done = await findCompleted();
      if (done) return finish(done);
      uploadId = crypto.randomUUID(); // expired / cancelled / rejected: start over
    } else {
      resumed = true;
      pending = status.missing_chunks.length > 0 ? status.missing_chunks : [totalChunks - 1];
      for (let i = 0; i < totalChunks; i++) if (!pending.includes(i)) acked.add(i);
    }
  }
  persist();
  report();

  // ── one chunk request, no retry ──────────────────────────────────────────
  const postChunk = async (index: number, timeoutMs: number) => {
    const start = index * chunkSize;
    const form = new FormData();
    form.append('upload_id', uploadId);
    form.append('chunk', file.slice(start, start + chunkSize), 'chunk');
    form.append('chunk_index', String(index));
    form.append('total_chunks', String(totalChunks));
    form.append('file_name', file.name);
    form.append('total_size', String(file.size));
    if (opts.collection) form.append('collection', opts.collection);

    inflight.set(index, 0);
    try {
      return await send<ChunkSessionState>({
        method: 'POST',
        path: '/media/videos/chunk',
        token: await opts.getToken(),
        body: form,
        timeoutMs,
        signal,
        onUploadProgress: (loaded) => {
          inflight.set(index, loaded);
          if (loaded >= chunkBytes(index) && timeoutMs === FINAL_CHUNK_TIMEOUT_MS) phase = 'processing';
          report();
        },
      });
    } finally {
      inflight.delete(index);
    }
  };

  // ── §8.4 a non-final chunk, with retries ───────────────────────────────────
  let completedEarly: UploadedMedia | null = null;

  const sendWithRetry = async (index: number): Promise<void> => {
    for (let attempt = 1; ; attempt++) {
      let failure: UploadError;
      try {
        const res = await postChunk(index, CHUNK_TIMEOUT_MS);
        if (res.httpStatus === 202 || res.httpStatus === 201) {
          acked.add(index);
          persist(); // refresh updatedAt
          report();
          if (res.body.data.completed && res.body.data.media) completedEarly = res.body.data.media;
          return;
        }
        const retryable = res.httpStatus === 429 || res.httpStatus === 423 || res.httpStatus >= 500;
        failure = new UploadError(res.body.message, res.httpStatus, retryable);
        if (res.httpStatus === 429) await sleep(Math.max(res.retryAfterMs ?? 0, 20_000), signal);
      } catch (e) {
        if (!(e instanceof UploadError)) throw e; // AbortError etc.
        failure = e;
      }
      if (!failure.retryable || attempt >= MAX_ATTEMPTS) throw failure;
      const backoff = Math.min(30_000, 1000 * 2 ** (attempt - 1)) + Math.random() * 500;
      await sleep(backoff, signal);
    }
  };

  const runPool = async (indexes: number[]) => {
    const queue = [...indexes];
    let failed: unknown = null;
    const worker = async () => {
      while (queue.length > 0 && failed === null && completedEarly === null) {
        const index = queue.shift()!;
        try {
          await sendWithRetry(index);
        } catch (e) {
          failed ??= e;
        }
      }
    };
    await Promise.all(Array.from({ length: Math.min(concurrency, queue.length) }, worker));
    if (failed !== null) throw failed;
  };

  // ── §8.4 everything except the final chunk ────────────────────────────────
  const finalIndex = pending[pending.length - 1];
  await runPool(pending.slice(0, -1));
  if (completedEarly) return finish(completedEarly);

  // ── §8.6 the final chunk ─────────────────────────────────────────────────
  for (let round = 0; round < 3; round++) {
    let outcome: 'unknown' | 'finished-with-error' = 'finished-with-error';
    let lastError: UploadError | null = null;

    try {
      const res = await postChunk(finalIndex, FINAL_CHUNK_TIMEOUT_MS);
      if (res.httpStatus === 201 && res.body.data.media) return finish(res.body.data.media);
      if (res.httpStatus === 202) {
        acked.add(finalIndex);
        phase = 'uploading';
        await runPool(res.body.data.missing_chunks.filter((i) => i !== finalIndex));
        continue; // then send the final chunk again
      }
      lastError = new UploadError(res.body.message, res.httpStatus, false);
      if (res.httpStatus === 401 || res.httpStatus === 403) throw lastError;
      if ([502, 503, 504].includes(res.httpStatus)) outcome = 'unknown'; // proxy gave up; PHP may still be merging
    } catch (e) {
      if (!(e instanceof UploadError)) throw e;
      if (e.httpStatus === 401 || e.httpStatus === 403) throw e;
      lastError = e;
      outcome = e.httpStatus === null ? 'unknown' : outcome;
    }

    // Ask the server where we are. If the outcome is unknown, a merge may still be running: wait for it.
    for (let poll = 0; ; poll++) {
      const status = await fetchStatus();

      if (status === null) {
        const done = await findCompleted();
        if (done) return finish(done);
        clearResumeState(file);
        throw new UploadError(lastError?.message ?? 'The upload was rejected by the server.', lastError?.httpStatus ?? null, false);
      }

      if (status.missing_chunks.length > 0) {
        phase = 'uploading';
        acked.clear();
        for (let i = 0; i < totalChunks; i++) if (!status.missing_chunks.includes(i)) acked.add(i);
        await runPool(status.missing_chunks.filter((i) => i !== finalIndex));
        break; // resend the final chunk
      }

      // All chunks present, not merged.
      if (outcome === 'finished-with-error' && lastError?.httpStatus === 422) {
        // §4.5: all chunks stored but the merge was refused, most likely the storage quota.
        await cancelVideoUpload(file, opts.getToken).catch(() => undefined);
        throw new UploadError('The video could not be stored. Your storage quota is probably full.', 422, false);
      }
      if (outcome === 'finished-with-error' || poll >= STATUS_POLL_MAX) break; // nothing is merging, so resend
      await sleep(STATUS_POLL_INTERVAL_MS, signal);
    }
  }

  throw new UploadError('The upload could not be completed. Please try again.', null, true);
}

/** §6.3 / §8.9: drop the server-side session for this file, if any. */
export async function cancelVideoUpload(file: File, getToken: () => string | Promise<string>): Promise<void> {
  const saved = loadResumeState(file);
  clearResumeState(file);
  if (!saved) return;
  await send({
    method: 'DELETE',
    path: `/media/videos/${encodeURIComponent(saved.uploadId)}`,
    token: await getToken(),
    timeoutMs: 30_000,
  }); // 200 or 404: both fine
}
```

### 9.5 Using it in a form (React example)

```tsx
function DemoVideoField({ value, onChange }: { value: string | null; onChange: (hash: string | null) => void }) {
  const [state, setState] = useState<{ percent: number; phase: UploadPhase } | null>(null);
  const [error, setError] = useState<string | null>(null);
  const abortRef = useRef<AbortController | null>(null);
  const fileRef = useRef<File | null>(null);

  const start = async (file: File) => {
    abortRef.current?.abort();
    const controller = new AbortController();
    abortRef.current = controller;
    fileRef.current = file;
    setError(null);

    try {
      const media = await uploadVideo({
        file,
        collection: 'demo_video',
        maxBytes: 500 * 1024 * 1024, // course demo_video limit (§13)
        getToken: () => auth.token,
        signal: controller.signal,
        onProgress: ({ percent, phase }) => setState({ percent, phase }),
      });
      onChange(media.hash); // goes into the course create/update body as "demo_video"
    } catch (e) {
      if ((e as Error).name !== 'AbortError') setError((e as Error).message);
    } finally {
      setState(null);
    }
  };

  const cancel = async () => {
    abortRef.current?.abort();
    if (fileRef.current) await cancelVideoUpload(fileRef.current, () => auth.token);
  };

  useEffect(() => () => abortRef.current?.abort(), []); // abort (not cancel) on unmount so the user can resume later

  return (
    <div>
      <input type="file" accept=".mp4,.mov,.webm,video/mp4,video/quicktime,video/webm"
             onChange={(e) => e.target.files?.[0] && start(e.target.files[0])} />
      {state && (
        <p>
          {state.phase === 'processing' ? 'Processing…' : `${state.percent.toFixed(0)}%`}
          <button type="button" onClick={cancel}>Cancel</button>
        </p>
      )}
      {error && <p role="alert">{error}</p>}
    </div>
  );
}
```

Then submit the entity normally:

```json
PUT {{base_url}}/admin/courses/recorded/{course}
{ "demo_video": "b7e0d9c2a4f14e6b9c3d2a1f0e8b7c6d", "…": "…" }
```

---

## 10. Attaching uploaded media to an entity

### 10.1 How attaching behaves

| You send | Result |
|---|---|
| `"image": "<new hash>"` | The new file is attached. For single-file fields (all current fields), **the previous file is deleted permanently**, storage included. |
| `"image": "<hash already attached to this record>"` | No-op. Safe to send the unchanged value back. |
| field omitted | Existing file kept. |
| `"image": null` or `""` | **Nothing happens.** The current file is *not* removed (some fields reject `null` outright, e.g. book `digital_file`). There's currently no "remove file" via entity endpoints. |
| a hash of an unattached upload older than 6 h | `422` "The media file was not found." |
| a hash attached to another record | `422` "The media file is already associated with another record." |
| a hash whose file type doesn't fit the field | `422` "The file must be of type: image" (or `video`, `application`, …) |
| a hash whose file is missing from storage | `422` "The physical file is missing from storage." |

**Edit forms:** since `GET` returns the **URL**, not the hash, don't send the media field back unless the user picked a new file. Send only fresh hashes.

### 10.2 Media fields by endpoint

"Type" is what the server checks at attach time (MIME family). "Recommended limits" come from each model's media config. The server doesn't re-check size or dimensions at attach time, so **enforce them on the client** before uploading.

| Endpoint(s) | Field | Required | Type checked | Recommended limits (client-side) | Upload via |
|---|---|---|---|---|---|
| `POST/PUT {{base_url}}/admin/courses/live`, `…/courses/recorded` | `image` | create: yes | image | ≤ 4 MB, ≤ 1200×800, jpg/jpeg/png/webp | `POST /media` |
| same | `demo_video` | no | video | ≤ 500 MB, mp4/mov/webm | chunked |
| `POST/PUT {{base_url}}/admin/programs` | `image` | no | image | ≤ 4 MB, ≤ 1600×1600, jpg/jpeg/png/webp | `POST /media` |
| same | `demo_video` | no | video | ≤ 500 MB, mp4/mov/webm | chunked |
| `POST {{base_url}}/trainer/courses/{course}/lessons/video`, `PUT …/trainer/lessons/video/{lesson}` | `video` | no | video | ≤ 500 MB, mp4/mov/webm | chunked |
| `POST {{base_url}}/trainer/courses/{course}/materials`, `PUT …/trainer/materials/{material}` | `file` | create: yes | application \| image | ≤ 50 MB (but `POST /media` caps at 10 MB), pdf/jpg/jpeg/png/webp. Must match `material_type` | `POST /media` |
| `POST {{base_url}}/trainer/programs/{program}/materials`, `PUT …/trainer/programs/materials/{material}` | `file` | create: yes | application \| video | ≤ 50 MB. `pdf` via `POST /media`; videos via chunked | either |
| `POST/PUT {{base_url}}/admin/books` | `cover_image` | no | image | ≤ 2 MB, ≤ 800×1200, jpg/jpeg/png/webp | `POST /media` |
| same (digital / research books only) | `digital_file` | see [books-dashboard.md](./books-dashboard.md) | application, **must be PDF on the private disk** | ≤ 10 MB (single-shot cap) | `POST /media` with `collection=digital_file` |
| `POST/PUT {{base_url}}/admin/categories` | `image` | no | image | ≤ 2 MB, ≤ 800×800 | `POST /media` |
| `POST/PUT {{base_url}}/admin/sliders` | `image` | no | image | ≤ 5 MB, ≤ 1920×800 | `POST /media` |
| `POST/PUT {{base_url}}/admin/partners` | `image` | no | image | ≤ 2 MB, ≤ 800×800 (svg isn't accepted by `POST /media`) | `POST /media` |
| `POST/PUT {{base_url}}/admin/countries` | `flag` | no | image | ≤ 512 KB, ≤ 128×128, png/webp | `POST /media` |
| `POST/PUT {{base_url}}/admin/static-pages` | `image` | no | image | ≤ 3 MB, ≤ 1200×630 | `POST /media` |
| `POST/PUT {{base_url}}/admin/users`, `…/admin/supervisors` | `image` | no | image | ≤ 2 MB, ≤ 500×500 | `POST /media` |
| `PUT {{base_url}}/admin/profile`, `…/trainer/profile`, `…/user/profile` | `image` | no | image | ≤ 2 MB, ≤ 500×500 | `POST /media` |

> Image dimensions: the server resizes variants but keeps the **main** image at its original resolution (only re-encoded to WebP). Downscale very large photos on the client before upload. It saves the user's bandwidth and their quota.

---

## 11. Displaying media

- Entity endpoints return each media field as a **plain URL string**: `"image": "https://…/storage/uploads/image/….webp"`.
- **Placeholders:** when nothing is attached, many image fields return a generated placeholder URL (`https://ui-avatars.com/api/?name=…`) instead of `null`. Some fields return `null` (e.g. book `cover_image` on some resources, videos). Handle both: treat a `ui-avatars.com` URL as "no image" if the UI needs to know.
- **Image variants** (`thumbnail`/`medium`/`large`) are only in the upload response. Use them for the immediate preview after upload. Entity responses only carry the main URL.
- **Video posters:** the server extracts a thumbnail frame for videos in the background, but it's **not exposed** in any API response yet. Use `<video preload="metadata">` (the browser shows the first frame), or draw a frame to a canvas on the client.
- **Private files** (book `digital_file`) have no public URL. Use the book file endpoint.
- URLs are served from `{{media_base_url}}/storage/…` with the file's real content type. Videos are plain progressive MP4/MOV/WebM files (no HLS/adaptive streaming), so `<video src>` works directly.

---

## 12. Listing and deleting your own uploads

### 12.1 List — `GET /media`

`GET {{media_base_url}}/media?page={n}`

Returns the media **you uploaded** or that belongs to your own user record, newest first, **15 per page**. `data` is a plain array of the §4.2 shape; there's **no pagination metadata**. Keep requesting the next `page` until you get an empty array. Other query params (`search`, `sort`, `filters`, `per_page`, `paginate`) are ignored.

```json
{ "status": "success", "message": "Media list retrieved successfully", "data": [ { "id": 813, "hash": "…", "url": "…", "mime_type": "video/mp4", "size": 76546048, "original_name": "intro-lecture.mp4" } ] }
```

Uses: the "did my video complete?" recovery (§8.7), a "my uploads" screen, finding files to delete to free quota.

### 12.2 Delete — `DELETE /media/{id}`

`DELETE {{media_base_url}}/media/{id}`. `{id}` is the **numeric `id`**, not the hash.

- `200` `{ "status": "success", "message": "Media deleted successfully", "data": [] }`. File and variants are removed from storage permanently.
- `404` `{ "status": "success", "message": "Media not found", "data": [] }`. Note **`status: "success"` with HTTP 404**. Returned when the id doesn't exist **or** you're not the uploader. Check the HTTP code.

⚠️ Deleting a media that's **attached** to an entity removes it from that entity too (the field falls back to its placeholder). Only delete files the user explicitly removed, or uploads that were never attached.

---

## 13. Limits cheat sheet

| Limit | Value |
|---|---|
| Single-shot file size | 10 MB |
| Single-shot extensions | jpg, jpeg, png, gif, webp, pdf, avi, mov, wmv, flv, mkv, mp4, mp3, wav |
| Chunked video: total size | 2 GB (application limit) |
| Course/program `demo_video`, lesson `video` | 500 MB, mp4/mov/webm (per field, enforce on client) |
| Chunked video: extensions (by file name) | mp4, m4v, mov, avi, mkv, webm |
| Chunked video: real content | must be detected as `video/*` at merge |
| Chunk size | ≤ 20 MB app limit; effective limit set by PHP/proxy (§7); recommended 5 MiB |
| Max chunks per upload | 10 000 |
| Chunk/status/cancel rate limit | 120 requests / minute / user |
| Per-user storage quota | 500 MB (all uploads, incl. image variants) |
| Unattached upload lifetime | 6 hours |
| Abandoned chunk session lifetime | 24 hours after the last chunk |
| `upload_id` format | `[A-Za-z0-9-]`, ≤ 64 chars (use a UUID) |
| Media `hash` format | 32 lowercase hex chars |

---

## 14. Error reference

| HTTP | `status` | `message` (en) | Endpoint | Cause | Client action |
|---|---|---|---|---|---|
| 401 | fail | Unauthenticated, you have to login first | all | Missing/expired token | Refresh token or go to login. Resume later. |
| 403 | fail | *(account-status message)* | all | Account deactivated | Stop; show message. |
| 403 | fail | This action is unauthorized. | `POST /media` | `collection=digital_file` by a non-admin | Hide the option for non-admins. |
| 422 | fail | The file field is required. / The file field must not be greater than 10240 kilobytes. / … must be a file of type: … | `POST /media` | Validation | Show message. |
| 422 | fail | The chunk field is required. | chunk | Blob not sent as a file part, or `Content-Type` set manually | Fix client (§3.2, §6.1). |
| 422 | fail | The chunk failed to upload. | chunk | Chunk larger than PHP `upload_max_filesize` | Lower the chunk size. |
| 422 | fail | The chunk field must not be greater than 20480 kilobytes. | chunk | Chunk > 20 MB | Lower the chunk size. |
| 422 | fail | The chunk index must be lower than the total number of chunks. | chunk | `chunk_index ≥ total_chunks` | Client bug. |
| 422 | fail | The total chunks field must not be greater than 10000. | chunk / status | Chunks too small | Increase the chunk size. |
| 422 | fail | The file name must be one of the following video types: mp4, m4v, mov, avi, mkv, webm. | chunk | Bad extension | Validate on the client. |
| 422 | fail | The total size field must not be greater than 2147483648. | chunk | File > 2 GB | Validate on the client. |
| 422 | fail | The upload id field format is invalid. | chunk | Bad characters in `upload_id` | Use a UUID. |
| 422 | fail | The merged file is not a valid video. | final chunk | Content isn't a video | Fatal; session already deleted. |
| 422 | fail | The video may not be greater than 2097152 kilobytes. | final chunk | Merged > 2 GB | Fatal; session already deleted. |
| 422 | fail | Chunk N could not be read, please upload it again. | final chunk | A buffered chunk disappeared (usually a parallel-final-chunk race) | Status → resend missing → final chunk. |
| 422 | fail | Something went wrong | `POST /media`, final chunk | Storage quota exceeded (§4.5) | Not retryable; free space. |
| 404 | error | Upload session not found (`data.error_code: ENTITY_NOT_FOUND`) | status / cancel | Completed, rejected, cancelled, expired or unknown | §8.7. For cancel: treat as success. |
| 404 | **success** | Media not found | `DELETE /media/{id}` | Not found / not yours | Check the HTTP code. |
| 413 | fail *(or HTML)* | Something went wrong | chunk / `POST /media` | Body larger than PHP `post_max_size` or proxy limit | Lower the chunk size. |
| 429 | fail | Too many requests, please try again after a while! | chunk / status / cancel | > 120 req/min | Wait ≥ 20 s, retry. |
| 5xx | error | Internal server error | any | Server fault | Retry with backoff (chunks); status-then-decide for the final chunk. |

Attach-time errors (on the entity endpoint, not the media endpoints) are listed in §10.1.

---

## 15. Edge cases and recovery playbook

**The user picks a different file while an upload is running.**
Abort the running upload and cancel its session (§8.9), then start a new one. Never reuse the `upload_id`.

**The same file is uploaded twice in two tabs.**
Both tabs find the same resume state (same fingerprint) and send chunks for the same session. It still works (chunks are idempotent), but both tabs may send the final chunk. Guard with a per-fingerprint lock (e.g. `navigator.locks.request(key, …)` or a `BroadcastChannel` "I'm uploading this" message) and show "This file is already uploading in another tab".

**The laptop sleeps / the network drops mid-upload.**
In-flight chunks fail with network errors and retry with backoff. If retries run out, show "Upload paused — Retry". Retry calls `uploadVideo` again with the same file: it resumes from the server's status.

**The page is reloaded.**
The user re-selects the file; the fingerprint matches the saved state; status returns the missing chunks; only those are sent. If more than ~24 h passed, the session is gone and the upload restarts from zero.

**The final request timed out, but the video was stored.**
Status returns 404 → `GET /media` finds the item by size → use its hash (§8.7). No re-upload needed.

**A user resumes after the server already merged, but the 6-hour orphan window has passed.**
`GET /media` won't find it (it was purged as an unattached upload) → upload again.

**The user renamed a `.mov` to `.mp4`.**
Fine. The server checks the real content type at merge, not the extension. The stored `mime_type` will be `video/quicktime`.

**The user renamed a non-video to `.mp4`.**
All chunks upload, then the final chunk fails with "The merged file is not a valid video.". Check `file.type.startsWith('video/')` on the client as a first line of defense (the browser's guess isn't authoritative, but catches obvious mistakes).

**Very large file on a slow connection.**
2 GB at 5 Mbit/s is ~55 minutes. Keep the upload in an app-level service (not inside a form component that may unmount), show time remaining, and warn before navigating away.

**Upload finished, but the entity save fails validation (e.g. a missing title).**
The hash is still valid for 6 hours. Keep it in form state and let the user fix the form. Don't re-upload.

**The user replaces a video on an existing lesson.**
Upload the new one, send its hash in the `PUT`. The old file is deleted automatically once the new one is attached.

**The user removes the video from the form without saving.**
If it was a fresh, unattached upload, `DELETE /media/{id}` to free quota immediately. If it's the already-saved video, there's currently no "detach" through the entity endpoint (§10.1). Don't call `DELETE /media/{id}` unless the product explicitly wants the file gone from the entity.

**Treat `upload_id` like a secret.**
Upload sessions aren't tied to a user on the server; anyone who has the id and is logged in can add chunks to or cancel the session. Don't log it to analytics or put it in URLs that get shared.

---

## 16. Mobile clients

The protocol is identical; only the plumbing differs.

- **Reading chunks:** don't load the whole video into memory. Read `[start, end)` byte ranges from the file handle (e.g. Flutter `RandomAccessFile.setPosition` + `read`, React Native `react-native-blob-util` / `expo-file-system` with `position`/`length`). Send each range as a multipart file part named `chunk`.
- **Fingerprint:** use the file's URI/path + size + modification date for resume state, stored in secure/app storage.
- **Background:** iOS and Android suspend apps quickly. For long uploads, use a background-capable transfer mechanism (iOS `URLSession` background configuration, Android `WorkManager`) that sends one chunk per task, or resume on foreground using the status endpoint. Chunking is what makes a suspended upload recoverable.
- **Cellular data:** consider asking before uploading large videos on a metered connection, and default to lower concurrency (1–2).
- **Headers:** the same `Accept: application/json` and `Authorization` rules apply. Many HTTP clients don't send `Accept` by default.

---

## 17. Integration checklist

- [ ] Media calls go to `{{media_base_url}}/media…` (host root, **not** `/api/v1`).
- [ ] Every media request sends `Accept: application/json` and `Authorization: Bearer …`, and **doesn't** set `Content-Type` manually.
- [ ] Images/PDFs use `POST /media` with a `collection`; **all videos use the chunked flow.**
- [ ] Client-side validation of extension, size (and dimensions for images) per target field (§10.2, §13).
- [ ] `upload_id` generated on the client with `crypto.randomUUID()`, new for every file.
- [ ] `chunkSize` comes from config, and is persisted with the session and reused on resume.
- [ ] Same `total_chunks`, `file_name`, `total_size` sent with every chunk.
- [ ] Non-final chunks sent with concurrency 2–4; the **final chunk is sent alone, after all others returned 202**.
- [ ] Per-chunk retry with exponential backoff; 429 waits ≥ 20 s; 4xx (except 429) aren't retried.
- [ ] Final chunk has a long timeout; UI shows "Processing…" after its bytes are sent; a lost response is resolved via status → `GET /media` (§8.6–8.7).
- [ ] Resume state saved per file fingerprint; resumes via the status endpoint; expires after ~20 h.
- [ ] Cancel aborts in-flight requests and calls `DELETE /media/videos/{upload_id}`.
- [ ] The returned `hash` is sent to the entity endpoint within 6 hours; edit forms don't send back unchanged media fields.
- [ ] Error `message` is shown to the user; logic branches on the **HTTP status code**.
- [ ] 429s from other endpoints (e.g. notifications) during a big upload are handled with backoff, not treated as fatal.
