Target Audience: Next.js Frontend Developers Library ID: 725542 CDN Hostname:
vz-2fe271ec-aba.b-cdn.netEmbed Player:https://player.mediadelivery.net/embed/725542/{video_id}TUS Upload Endpoint:https://video.bunnycdn.com/tusuploadSecurity: MediaCage Basic DRM (embed-only playback) — token auth OFF (embed + CDN); embed-token signing supported viaBUNNY_STREAM_TOKEN_AUTH_KEYif ever enabled
All videos on EduTrack are hosted on Bunny Stream. The flow is:
Teacher Browser Backend (Django) Bunny Stream
│ │ │
│ 1. POST /courses/videos/ │ │
│ {lecture, name, order} │ │
│ ─────────────────────────► │ │
│ ◄── {id, bunny_video_id:null}│ │
│ │ │
│ 2. POST /videos/<id>/ │ │
│ create-upload/ │ │
│ ─────────────────────────► │── POST /library/{id}/videos─►│
│ │◄── {guid, status} ──────────│
│ ◄── TUS credentials ─────────│ │
│ {library_id, signature, │ │
│ expiration_time} │ │
│ │ │
│ 3. tus-js-client uploads │ │
│ to Bunny CDN │ │
│ ──────────────────────────────────────────────────────► │
│ │ │
│ 4. Poll GET /videos/<id>/ │ │
│ (self-healing — syncs │ │
│ status from Bunny API) │ │
│ ─────────────────────────► │── GET /library/{id}/videos ─►│
│ │◄── {status: 4} ────────────│
│ ◄── {bunny_status: 3, │ │
│ is_active: true} │ │
Create a metadata record in EduTrack before uploading. This gives you a local ID to reference.
Request:
POST /courses/videos/
Content-Type: application/json
{
"lecture": 5,
"name": "Introduction to Chemical Reactions",
"order": 1,
"is_active": true
}
| Field | Type | Required | Description |
|---|---|---|---|
lecture |
Integer | ✅ | ID of the lecture this video belongs to |
name |
String | ✅ | Video title (max 200 chars) |
order |
Integer | ❌ | Display order within the lecture (default: auto-assigned) |
is_active |
Boolean | ❌ | Whether the video is active (default: true) |
Response — 201 Created:
{
"id": 42,
"lecture": 5,
"name": "Introduction to Chemical Reactions",
"bunny_video_id": null,
"bunny_status": null,
"bunny_status_display": "Pending",
"thumbnail_url": null,
"order": 1,
"is_active": true,
"created_at": "2026-07-16T10:00:00.000000+03:00",
"updated_at": "2026-07-16T10:00:00.000000+03:00"
}
Note:
bunny_video_idisnulluntil you callcreate-upload/. The video is just a metadata placeholder at this point.
Generates a Bunny Stream video entry and returns TUS credentials. The API key stays on the server — only a time-limited signature reaches the frontend.
Request:
POST /courses/videos/<id>/create-upload/
No request body
Response — 201 Created (first time):
{
"video_id": 42,
"bunny_video_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"library_id": 725542,
"expiration_time": 1721400000,
"signature": "7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8"
}
Response — 200 OK (already created, returns fresh credentials):
{
"video_id": 42,
"message": "Upload entry already exists. Use these TUS credentials to upload or re-create the video entry.",
"bunny_video_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"library_id": 725542,
"expiration_time": 1721400000,
"signature": "7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8"
}
Error Responses:
| Status | Error | Cause |
|---|---|---|
| 400 | "This teacher does not have a Bunny Stream collection assigned." |
SiteOwner must assign a collection first via POST /accounts/teachers/<id>/create-collection/ |
| 400 | "This video has already been finalized." |
Video is already in a ready status (3/4). Delete and re-create to upload a new file. |
| 403 | "You can only upload videos to your own courses." |
Wrong teacher |
| 404 | "No Video matches the given query." |
Video ID doesn't exist |
| 409 | "Another upload is already in progress for this video." |
Someone else is uploading. Wait for encoding to finish. Uses select_for_update() to prevent race conditions. |
| 502 | "Failed to create video on Bunny Stream." |
Bunny API error (retry) |
⚠️ Concurrent upload protection: Once a Bunny video entry is created (status = 0),
create-upload/always returns 409 Conflict until the encoding finishes or the video record is deleted. To re-upload after a failed/abandoned upload: delete the video record (DELETE /courses/videos/<id>/) and create a new one.
⚠️ CRITICAL:
expiration_timeis a Unix timestamp in SECONDS, not milliseconds. JavaScript'sDate.now()returns milliseconds. If you multiplyexpiration_timeby 1000 or passDate.now()directly to theAuthorizationExpireheader, Bunny will reject every upload with 401.✅ Correct:
headers: { AuthorizationExpire: expiration_time }(pass as-is, it's already seconds) ❌ Wrong:headers: { AuthorizationExpire: expiration_time * 1000 }(would be 1000× expired) ❌ Wrong:headers: { AuthorizationExpire: Date.now() }(would be in milliseconds)
Upload the video file directly from the browser to Bunny Stream using tus-js-client. Zero bytes pass through your backend.
npm install tus-js-client
import * as tus from 'tus-js-client';
async function uploadVideo(file, videoId, onProgress, onStatusChange) {
// Step 1: Get TUS credentials
const { bunny_video_id, library_id, expiration_time, signature } =
await fetch(`/courses/videos/${videoId}/create-upload/`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
}).then(r => {
if (!r.ok) throw new Error(`create-upload failed: ${r.status}`);
return r.json();
});
onStatusChange('uploading');
// Step 2: Create TUS upload
const upload = new tus.Upload(file, {
endpoint: 'https://video.bunnycdn.com/tusupload',
// 5 MB chunks — balances speed vs WiFi usability
chunkSize: 5 * 1024 * 1024,
// Auto-retry with exponential backoff
retryDelays: [0, 3000, 5000, 10000, 20000, 60000],
// TUS auth headers (provided by our backend)
headers: {
AuthorizationSignature: signature,
AuthorizationExpire: expiration_time, // ← SECONDS, do NOT multiply by 1000
VideoId: bunny_video_id,
LibraryId: library_id,
},
metadata: {
filetype: file.type,
title: file.name,
},
// Upload progress
onProgress(bytesUploaded, bytesTotal) {
const pct = Math.round((bytesUploaded / bytesTotal) * 100);
onProgress(pct);
},
// 300ms delay between chunks — prevents WiFi saturation
onChunkComplete(chunkSize, bytesAccepted, bytesTotal) {
return new Promise(resolve => setTimeout(resolve, 300));
},
onSuccess() {
onStatusChange('uploaded');
// OPTIONAL: Tell the backend the upload is complete (faster status detection)
// Not required — the polling in step 4 will auto-detect status anyway.
fetch(`/courses/videos/${videoId}/upload-complete/`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
}).catch(() => {});
// Start polling for encoding status
startPolling(videoId);
},
onError(error) {
console.error('TUS upload error:', error);
onStatusChange('error');
},
});
// Auto-resume if previous upload was interrupted
const previousUploads = await upload.findPreviousUploads();
if (previousUploads.length) {
upload.resumeFromPreviousUpload(previousUploads[0]);
}
upload.start();
}
TUS stores upload progress in the browser's localStorage. If the upload is interrupted:
upload.abort() → progress saved to localStoragefindPreviousUploads() returns the saved progressresumeFromPreviousUpload(previousUploads[0]) continues from the exact byte where it stoppedNo backend endpoint needed for pause/resume — it's built into the TUS protocol.
To cancel an upload:
upload.abort(true); // true = remove stored progress too
This stops the upload and clears the TUS localStorage entry. The Bunny video entry remains in the database with bunny_status=0 (Created, never uploaded). To retry, just start a new upload — the existing create-upload/ credentials still work.
If a previous upload failed or was cancelled:
POST /courses/videos/<id>/create-upload/ again — it returns new TUS credentials for the same bunny_video_idtus.Upload — Bunny replaces the video contentAfter the upload completes, Bunny processes the video. Poll the video detail endpoint — it automatically syncs the current status from Bunny's API on each request, so you always get the latest status.
async function startPolling(videoId) {
const poll = async () => {
const resp = await fetch(`/courses/videos/${videoId}/`, {
headers: { Authorization: `Bearer ${token}` },
});
const video = await resp.json();
switch (video.bunny_status) {
case 0:
setStatus('Queued (waiting for upload / encoding)');
break;
case 1:
setStatus('Processing...');
break;
case 2:
setStatus('Encoding...');
break;
case 3:
case 4:
setStatus('Ready ✅');
clearInterval(interval);
setIsReady(true);
break;
case 5:
setStatus('Failed ❌');
clearInterval(interval);
break;
case 6:
setStatus('Uploading...');
break;
case 7:
setStatus('Uploaded (encoding pending)');
break;
case 8:
setStatus('Upload Failed ❌');
clearInterval(interval);
break;
default:
setStatus('Unknown');
}
};
const interval = setInterval(poll, 5000); // Poll every 5 seconds
}
How it works: Each call to GET /courses/videos/<id>/ checks:
bunny_video_id?This makes the polling self-healing — you don't need POST /courses/videos/<id>/upload-complete/ or the webhook. The polling loop discovers status changes automatically.
Status Reference (current official scheme):
bunny_status |
bunny_status_display |
Meaning | is_active set? |
|---|---|---|---|
null |
Pending | Video record created, no Bunny entry yet | ❌ |
0 |
Queued | Bunny entry created (upload pending) — or queued for encoding after upload | ❌ |
1 |
Processing | Bunny is processing the video | ❌ |
2 |
Encoding | Bunny is encoding (transcoding to HLS) | ❌ |
3 |
Finished | Encoding finished — ready for playback | ✅ |
4 |
Ready | Resolution finished (first signal the video is playable) | ✅ |
5 |
Failed | Encoding failed (delete and re-upload) | ❌ (deactivated) |
6 |
Uploading | Presigned/TUS upload started | ❌ |
7 |
Uploaded | TUS upload complete — backend sets this on upload-complete/ |
❌ |
8 |
UploadFailed | Upload failed | ❌ (deactivated) |
9 |
CaptionsGenerated | AI captions generated | ✅ (treated as ready) |
10 |
Generated | AI title/description generated | ✅ (treated as ready) |
When status reaches a ready code (3, 4, 9, 10) the backend automatically sets
is_active = trueandthumbnail_urlbecomes available. Failed codes (5, 8) setis_active = false.
The player is Bunny's embed iframe (library 725542 uses MediaCage Basic DRM → embed-only playback). Build the iframe from the video's bunny_video_id:
https://player.mediadelivery.net/embed/725542/{guid}
Request:
GET /courses/videos/<id>/play/
Response — 200 OK:
{
"playback_url": "https://vz-2fe271ec-aba.b-cdn.net/{guid}/playlist.m3u8",
"embed_url": "https://player.mediadelivery.net/embed/725542/{guid}?token=…&expires=…",
"token": "… | null",
"expires_at": 1721400000,
"expires_in": 7200
}
Playback model (library 725542): MediaCage Basic DRM is enabled → playback works only through the embed player; direct HLS access and third-party players are disabled by Bunny. Access control is still enforced server-side before the URL is issued. Use
embed_urlfor the player iframe.Token expiry: when
BUNNY_STREAM_TOKEN_AUTH_KEYis set,embed_urlcarries?token=…&expires=…(SHA256_HEX(token_key + guid + expires)). For students, the expiry is tied to their active 6-hour viewing session —expires_in = max(session_remaining + 15 min, 1 hour)— so a shared link dies with the session window. Teachers/assistants/siteowner previews get a flat 2 hours (expires_in: 7200). If the token key is unset,token/expires_atarenullandembed_urlis unsigned.
| Role | Can play? | Requirements |
|---|---|---|
| Student | ✅ | Must have purchased the lecture (not expired, have active viewing session, prerequisites passed) |
| Teacher | ✅ | Must own the course (preview — no purchase needed) |
| Assistant | ✅ | Must belong to the course teacher |
| SiteOwner | ✅ | Any video |
| Unauthenticated | ❌ | 401 |
| Status | Error | Cause |
|---|---|---|
| 404 | "Video is not available." |
Video not found, inactive, or not yet encoded |
| 401 | "Authentication credentials were not provided." |
Not logged in |
| 403 | "You have not purchased this lecture." |
Student didn't buy |
| 403 | "Your access to this lecture has expired." |
Purchase expired (teacher can reopen) |
| 403 | "No active viewing session. Click \"Start watching\" to begin a 6-hour session." |
No active 6-hour viewing session |
| 403 | "You must pass the quiz (ID X) before accessing this video." |
Prerequisite quiz not passed |
| 403 | "You must pass the exam (ID X) before accessing this video." |
Prerequisite exam not passed |
| 403 | "You can only preview videos in your own courses." |
Wrong teacher |
| 502 | "Failed to generate playback URL." |
Bunny Stream error |
Each purchase includes a maximum number of 6-hour viewing sessions (default: 4 per lecture).
max_watch_count, default 4).LectureViewingSession, expires_at = started_at + 6h) during which ALL videos in the lecture are freely accessible — page refreshes and internet cuts are fine.sessions_used == max_watch_count), watching is blocked (403) — the student must ask the teacher to reopen.The teacher can reopen an expired purchase (PATCH /courses/purchases/<pk>/reopen/, max 2 times): it grants 1 extra day of access AND resets sessions_used to 0 and deletes all LectureViewingSession records — the student gets 4 fresh sessions.
Request:
PATCH /courses/videos/<id>/
Content-Type: application/json
{
"name": "Updated Video Title",
"order": 2,
"is_active": true
}
What happens when you change the name:
To update the image: Upload via multipart/form-data (see question images docs). Video thumbnail_url is auto-generated by Bunny from the video file — you cannot set it manually.
Request:
DELETE /courses/videos/<id>/
What happens:
Cannot delete: Videos can always be deleted (no enrollment/purchase check on video level — the lecture-level checks handle that).
Request:
GET /courses/videos/?lecture=5
| Parameter | Type | Description |
|---|---|---|
lecture |
Integer | Filter by lecture ID |
bunny_status |
Integer | Filter by encoding status (0-10) |
is_active |
Boolean | Filter by active status |
search |
String | Search by name |
ordering |
String | order or created_at |
Response — 200 OK:
{
"count": 3,
"results": [
{
"id": 42,
"lecture": 5,
"name": "Introduction",
"bunny_video_id": "a1b2c3d4-...",
"bunny_status": 4,
"bunny_status_display": "Finished",
"thumbnail_url": "https://vz-2fe271ec-aba.b-cdn.net/a1b2c3d4-.../thumbnail.jpg",
"order": 1,
"is_active": true,
"created_at": "2026-07-16T10:00:00.000000+03:00",
"updated_at": "2026-07-16T10:00:00.000000+03:00"
}
]
}
The thumbnail_url is auto-generated by Bunny Stream based on the video's GUID:
https://{cdn_hostname}/{bunny_video_id}/thumbnail.jpg
Example:
https://vz-2fe271ec-aba.b-cdn.net/a1b2c3d4-e5f6-7890-abcd-ef1234567890/thumbnail.jpg
null before encoding completes| Scenario | What happens | Recovery |
|---|---|---|
| Network drops mid-upload | TUS saves progress to localStorage | On retry, findPreviousUploads() finds saved progress → resume |
| Browser tab closed | Same as network drop | Same resume behavior |
| User clicks Cancel | Call upload.abort(true) |
Start fresh upload — create-upload/ still works |
| Upload reaches 100% but status never changes | upload-complete/ not called or webhook failed |
No action needed — polling GET /videos/<id>/ auto-syncs status from Bunny API on each request. The status will update within 5 seconds of the next poll cycle. |
| create-upload returns 502 | Bunny API is down | Retry with exponential backoff (3 retries, 1s/2s/4s delays) |
| create-upload returns 409 (status 1,2,6,7) | Upload/encoding already in progress | Wait for encoding to finish. The second user will see the first user's upload when the video reaches a ready status (3/4). |
| create-upload returns 409 (status 0) | A Bunny entry exists but no file has completed encoding | Delete the video record and create a new one to start fresh. |
| create-upload returns 409 (status 0 — webhook hint) | Upload may have completed; try GET /courses/videos/<id>/ first to trigger a status sync |
The detail endpoint polls Bunny's API and updates the DB. After calling it, try create-upload/ again. |
| create-upload returns 400 "finalized" | Video is already in a ready status (3/4) | Delete the video record and create a new one to upload a replacement. |
| Delete video fails | Network error when calling Bunny's delete API | Backend retries 3 times with 1s delay. If still failing, local record is deleted and Bunny video becomes orphaned. |
| create-upload returns 400 "no collection" | Teacher has no Bunny collection | SiteOwner must create one via POST /accounts/teachers/<id>/create-collection/ |
| play returns 502 | Bunny playback URL generation failed | Retry — temporary Bunny issue |
| play returns 404 | Video not active or not yet encoded | Wait for a ready status (3/4 — polling auto-detects it), then retry |
| Webhook never fires | Bunny failed to call back | No problem — polling GET /videos/<id>/ syncs status from Bunny API directly. The webhook is just a faster real-time notification. |
| Encoding fails (status=5) | Bunny couldn't encode the video | Delete and re-upload. Ensure video is in a supported format (MP4, MOV, AVI, MKV) |
| Upload fails (status=8) | TUS upload didn't complete properly | Delete and re-upload, or retry the TUS upload |
| TUS upload gets 401 | expiration_time passed as milliseconds |
Fix: Pass expiration_time directly without multiplying by 1000. See ⚠️ note in Step 2 |
1. CREATE video record → POST /courses/videos/ → {id, bunny_video_id: null}
2. GET upload credentials → POST /courses/videos/<id>/create-upload/ → {bunny_video_id, signature, expiration_time}
3. UPLOAD file → tus-js-client → https://video.bunnycdn.com/tusupload
4. POLL for encoding (self-healing) → GET /courses/videos/<id>/ (every 5s) → {bunny_status: 6/7 → 0 → 1 → 2 → 3/4, is_active: true}
5. PLAY video → embed iframe https://player.mediadelivery.net/embed/725542/{guid} (DRM library: embed-only playback)
6. RENAME video (optional) → PATCH /courses/videos/<id>/ → updated video object
7. DELETE video (optional) → DELETE /courses/videos/<id>/ → 204 No Content
↓ ↓ ↓
Frontend does Backend does Bunny does
┌─────────────────┐ ┌─────────────────────┐ ┌────────────────────────┐
│ POST /videos/ │───►│ Creates Video row │ │ │
│ │ │ │ │ │
│ POST create- │───►│ POST /library/... │───►│ Creates video entry │
│ upload/ │ │ Returns TUS creds │ │ Returns GUID │
│ │ │ │ │ │
│ tus-js-client │────────────────────────────────►│ Receives file chunks │
│ uploads chunks │ │ │ │ Auto-assembles file │
│ │ │ │ │ Starts encoding │
│ │ │ │ │ │
│ GET /videos/id/ │───►│ GET /library/... │───►│ Returns real status │
│ (or webhook) │ │ Updates DB if │ │ │
│ │ │ status changed │ │ │
│ GET /videos/id/ │───►│ Checks permissions │ │ │
│ /play/ │ │ Signs playback URL │ │ │
└─────────────────┘ └─────────────────────┘ └────────────────────────┘
| Action | Method | Endpoint | Notes |
|---|---|---|---|
| Create video record | POST |
/courses/videos/ |
Creates local DB row only |
| List videos | GET |
/courses/videos/ |
Filter by ?lecture=, ?bunny_status=, ?search= |
| Get video detail | GET |
/courses/videos/<id>/ |
Includes thumbnail_url, bunny_status_display. Self-healing — syncs status from Bunny API on each poll |
| Update video | PUT/PATCH |
/courses/videos/<id>/ |
Rename syncs to Bunny Stream |
| Delete video | DELETE |
/courses/videos/<id>/ |
Removes from Bunny + local DB |
| Get upload credentials | POST |
/courses/videos/<id>/create-upload/ |
Returns TUS credentials (no file upload here) |
| Upload file | TUS | https://video.bunnycdn.com/tusupload |
Use tus-js-client with credentials from above |
| Mark upload complete | POST |
/courses/videos/<id>/upload-complete/ |
Optional. Call after TUS onSuccess for faster status detection (faster than waiting for next poll cycle) |
| Preview video | GET |
/courses/videos/<id>/play/ |
Skips purchase checks for own courses |
| Action | Method | Endpoint | Notes |
|---|---|---|---|
| Play purchased video | GET |
/courses/videos/<id>/play/ |
Checks purchase, expiry, watch limit, prerequisites |
| Track watch progress | POST |
/courses/progress/update/ |
Body: {video, progress_seconds, cumulative_watch_seconds, duration_seconds} — cumulative_watch_seconds sent on milestones (25/50/75/90%) |
| Get watch progress | GET |
/courses/progress/<video_id>/ |
Own progress only |
| List progress | GET |
/courses/progress/ |
Filter by ?video=, ?is_completed= |
| Action | Method | Endpoint | Notes |
|---|---|---|---|
| Bunny encoding callback | POST |
/webhooks/bunny-encoded/ |
Bunny calls this. v1 signature: headers X-BunnyStream-Signature-Version: v1 + X-BunnyStream-Signature-Algorithm: hmac-sha256 + X-BunnyStream-Signature = lowercase hex HMAC-SHA256 of the raw body with the library's Read-Only API key (constant-time compare). Legacy X-Bunny-Signature (full API key) accepted as fallback. |
| Action | Method | Endpoint | Why |
|---|---|---|---|
| Upload via proxy | POST |
/courses/videos/<id>/upload/ |
Returns 410 Gone. Replaced by TUS direct upload |
| Check upload status | GET |
/courses/videos/<id>/upload-status/ |
Returns 410 Gone. TUS handles resume natively |