EduTrack Online — Bunny Stream Integration

Target Audience: Next.js Frontend Developers Library ID: 725542 CDN Hostname: vz-2fe271ec-aba.b-cdn.net Embed Player: https://player.mediadelivery.net/embed/725542/{video_id} TUS Upload Endpoint: https://video.bunnycdn.com/tusupload Security: MediaCage Basic DRM (embed-only playback) — token auth OFF (embed + CDN); embed-token signing supported via BUNNY_STREAM_TOKEN_AUTH_KEY if ever enabled


Overview

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} │ │

Table of Contents

  1. Create Video Record
  2. Get Upload Credentials
  3. TUS Direct Upload
  4. Poll Encoding Status
  5. Play a Video
  6. Update/Rename a Video
  7. Delete a Video
  8. List Videos
  9. Thumbnail URL
  10. Error Handling Reference
  11. Full Lifecycle Summary

1. Create Video Record

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_id is null until you call create-upload/. The video is just a metadata placeholder at this point.


2. Get Upload Credentials

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_time is a Unix timestamp in SECONDS, not milliseconds. JavaScript's Date.now() returns milliseconds. If you multiply expiration_time by 1000 or pass Date.now() directly to the AuthorizationExpire header, 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)


3. TUS Direct Upload

Upload the video file directly from the browser to Bunny Stream using tus-js-client. Zero bytes pass through your backend.

Installation

npm install tus-js-client

Full Upload Function

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(); }

How Pause/Resume Works

TUS stores upload progress in the browser's localStorage. If the upload is interrupted:

  1. Manual pause: Call upload.abort() → progress saved to localStorage
  2. Browser crash / internet drop: On next page load, findPreviousUploads() returns the saved progress
  3. Resume: resumeFromPreviousUpload(previousUploads[0]) continues from the exact byte where it stopped

No backend endpoint needed for pause/resume — it's built into the TUS protocol.

How Cancellation Works

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.

Re-uploading (Starting Over)

If a previous upload failed or was cancelled:

  1. Call POST /courses/videos/<id>/create-upload/ again — it returns new TUS credentials for the same bunny_video_id
  2. Start a new tus.Upload — Bunny replaces the video content

4. Poll Encoding Status

After 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:

  1. Does the video have a bunny_video_id?
  2. Is the current status not terminal (not in READY_STATUSES {3,4,9,10} and not in FAILED_STATUSES {5,8})?
  3. If both yes → backend calls Bunny's API to get the real status → updates the DB if changed. A local status 6/7 (uploaded) is never downgraded back to 0 (0 is ambiguous: "created, no file" vs "queued for encoding after upload").

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 = true and thumbnail_url becomes available. Failed codes (5, 8) set is_active = false.


5. Play a Video

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_url for the player iframe.

Token expiry: when BUNNY_STREAM_TOKEN_AUTH_KEY is set, embed_url carries ?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_at are null and embed_url is unsigned.

Access Rules by Role

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

Error Responses

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

Watching Times

Each purchase includes a maximum number of 6-hour viewing sessions (default: 4 per lecture).

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.


6. Update/Rename a Video

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:

  1. Local DB is updated immediately
  2. Backend also calls Bunny Stream's API to update the title in their system (Bunny dashboard stays in sync)
  3. If the Bunny update fails, the local name is still saved and the error is logged

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.


7. Delete a Video

Request:

DELETE /courses/videos/<id>/

What happens:

  1. Backend calls Bunny Stream's API to delete the video from their CDN
  2. Backend deletes the local database record
  3. If Bunny deletion fails (network error), the local record is still deleted and the error is logged
  4. The Bunny video becomes orphaned — no automatic cleanup retry

Cannot delete: Videos can always be deleted (no enrollment/purchase check on video level — the lecture-level checks handle that).


8. List Videos

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" } ] }

9. Thumbnail URL

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

10. Error Handling Reference

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

11. Full Lifecycle Summary

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 │ │ │ └─────────────────┘ └─────────────────────┘ └────────────────────────┘

Endpoint Summary

Teacher

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

Student

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=

System

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.

Deprecated

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