Media API
Handles media uploads (audio, images) via a two-step flow: presigned URL request followed by direct R2 upload.
Base Paths:
/api/media- Image uploads (cover, header, profile, banner)/api/media/audio- Audio file uploads
Authentication: All endpoints require Bearer token in Authorization header.
Architecture Overview
Audio Endpoints
POST /api/media/audio/presigned-url
Generate upload metadata for audio files.
Request Body:
{
podcastId: string; // UUID
episodeId: string; // UUID
contentType: 'audio/mpeg' | 'audio/mp4' | 'audio/x-m4a' | 'audio/wav' | 'audio/x-wav';
filename: string;
fileSize: number; // Max 500MB (524288000 bytes)
}Response:
{
"uploadId": "uuid",
"uploadPath": "podcasts/{podcastId}/e/{episodeId}/audio/original.mp3",
"finalPath": "podcasts/{podcastId}/e/{episodeId}/audio/original.mp3",
"publicUrl": "https://media.podcasterplus.com/podcasts/.../audio/original.mp3",
"maxSize": 524288000,
"acceptedTypes": ["audio/mpeg", "audio/mp4", "audio/x-m4a", "audio/wav", "audio/x-wav"],
"uploadEndpoint": "/api/media/audio/upload/{uploadId}"
}Note:
uploadId,uploadPath,finalPath, andpublicUrlhere are advisory only. The PUT finalize ignores the route:uploadIdfor the object key and generates its own server-side object id, storing at a versioned key.../audio/{server-uuid}.{ext}(so a caller cannot targetoriginal.{ext}or an existing object). It persists audio metadata server-side and returns the realpath/publicUrl.maxSizereports the 500 MB spec ceiling, but uploads are currently capped at 5 MB (413) because the body is proxied through the app; this is lifted when presigned direct-to-R2 lands.
Validations:
- User must be an
admin+ member of the podcast (#293: media upload routes are Host/Producer work) - Episode must belong to the specified podcast
- Content-Type must be valid audio format
- File size must not exceed 500MB (interim proxy cap: 5MB →
413)
PUT /api/media/audio/upload/:uploadId
Direct R2 upload endpoint for audio files.
Request:
- Raw binary audio data in body
- Query parameters:
podcastId,episodeId,filename,duration(optional, integer seconds) - Headers:
Content-Type: Valid audio MIME typeAuthorization:Bearer <token>
Response:
{
"success": true,
"path": "podcasts/{podcastId}/e/{episodeId}/audio/{server-uuid}.mp3",
"publicUrl": "https://media.podcasterplus.com/podcasts/.../audio/{server-uuid}.mp3",
"fileSize": 5242880,
"contentType": "audio/mpeg",
"durationSeconds": 1830
}Process:
- Validates UUIDs, content-type, authorization, the 5 MB interim proxy cap (
Content-Lengthfast-path + a streaming ceiling,413if over), and rejects an empty body (400) - Re-checks
storage_gb_per_accountagainst the actual received bytes (early409if over) - Uploads to a server-generated versioned key (
.../audio/{server-uuid}.{ext}) — never overwrites the existing object, and the caller's:uploadIdis not used for the key - Verifies the new object via
head(); the authoritative size is the R2 HEAD result and must equal the received byte count (else fail closed,500, and roll back the object) - Persists audio metadata server-side via the service-role client under an optimistic
audio_urlguard (only finalizes ifaudio_urlis unchanged since the pre-check):audio_url, the R2-measuredaudio_file_size_bytes(never client-supplied),audio_content_type, andaudio_duration_seconds(from thedurationquery param). The episodes storage + finalized-audio triggers enforce the account cap and the publish invariant on this write. - On a denied / errored / zero-row finalize: rolls back the just-uploaded object and returns
409(cap or concurrent replace),404(episode gone), or500(other DB error) — the previous audio is left intact - On success: invalidates the published feed (for a
publishedepisode — always, even on first audio), then cleans up the previous object. The previous object is deleted inline only for episodes that were never publicly served; for apublished/archivedepisode it is retained and reclaimed by the storage reconciler's age-guarded orphan sweep, so a cached enclosure URL never 404s
Audio removal: there is no public audio-delete API route. Audio is removed via the episode page's
removeAudioaction (SvelteKit form action), which refuses to strip audio from apublished/scheduledepisode (409— unpublish / cancel the schedule first), and otherwise clears the DB audio columns (under an optimistic guard) and deletes the R2 object scoped to that episode (anarchivedepisode's object is retained for the reconciler sweep). The audio metadata columns are locked to server-side writers — a browser-JWT write is rejected with42501; and a native self-hosted episode cannot bepublished/scheduledwithout finalized audio (PT428). See migrations20260619120000and20260622120000.
Image Endpoints
POST /api/media/presigned-url
Generate upload metadata for image files.
Request Body:
{
podcastId: string; // UUID
imageType: 'cover' | 'header' | 'banner' | 'episode_cover';
episodeId?: string; // UUID — required when imageType is 'episode_cover'
contentType: string; // Varies by type (see table below)
filename: string;
}Image Type Specifications:
| Type | Accepted Formats | Max Size | Min Dimensions | Aspect Ratio |
|---|---|---|---|---|
cover | JPEG, PNG | 5 MB | 1400×1400 | 1:1 (2% tolerance) |
header | JPEG, PNG, WebP | 2 MB | 1200×200 | 6.4:1 |
banner | JPEG, PNG, WebP | 2 MB | 600×315 | 1.91:1 |
episode_cover | JPEG, PNG | 5 MB | 1400×1400 | 1:1 (2% tolerance) |
For episode_cover, the upload endpoint also requires episodeId as a query param and verifies the episode belongs to the role-checked podcast (404 otherwise). Replacing a cover with a different format deletes the stale sibling key, so exactly one cover object exists per episode. The URL is then persisted to episodes.cover_image_url by the episode page's updateCoverImage action (removal: removeCoverImage clears the column and deletes the R2 objects), and the RSS worker emits it as an item-level <itunes:image> with the show artwork as fallback. Externally-hosted podcasts use the same upload flow — their cover feeds the Publish Handoff page instead of our feed.
GET /api/media/episode-cover/:episodeId/download
Streams the artwork the episode resolves to (its own cover, falling back to the show cover) as a Content-Disposition: attachment download named {episode-slug}-cover.{ext}. Exists because the media domain is cross-origin to the app, so an anchor download attribute is ignored; the Publish Handoff's Download action fetches this same-origin endpoint with a Bearer token instead. Member role via episode→podcast resolver. Only keys inside the episode's own podcasts/{podcastId}/ subtree are served — foreign imported artwork URLs and cross-tenant paths 404 (the handoff hides Download for those via the server-computed artworkDownloadable flag).
Response:
{
"uploadId": "uuid",
"uploadPath": "podcasts/{podcastId}/images/cover/{uuid}.jpg",
"finalPath": "podcasts/{podcastId}/images/cover/{uuid}.jpg",
"publicUrl": "https://media.podcasterplus.com/.../cover/{uuid}.jpg?v=1736123456789",
"maxSize": 5242880,
"dimensions": {
"width": 3000,
"height": 3000,
"minWidth": 1400,
"minHeight": 1400
},
"uploadEndpoint": "/api/media/upload/{uploadId}"
}PUT /api/media/upload/:uploadId
Direct R2 upload endpoint for images with server-side dimension validation.
Request:
- Raw binary image data in body
- Query parameters:
podcastId,imageType - Headers:
Content-Type: Valid image MIME typeAuthorization:Bearer <token>
Server-Side Dimension Parsing:
The API parses image dimensions directly from binary data without external libraries:
| Format | Detection Method |
|---|---|
| JPEG | Reads SOF0/SOF1/SOF2 markers (0xFFC0-0xFFC3) |
| PNG | Reads IHDR chunk at bytes 16-23 |
| WebP | Parses VP8/VP8L/VP8X chunk headers |
Response:
{
"success": true,
"path": "podcasts/{podcastId}/images/cover/{uuid}.jpg",
"publicUrl": "https://media.podcasterplus.com/.../cover/{uuid}.jpg?v=1736123456789"
}DELETE /api/media/image/:podcastId/:imageType
Delete image files for a specific type.
Authorization: Requires owner or admin role.
Response:
{
"success": true,
"deletedCount": 1
}POST /api/media/import-artwork
Import artwork from an external URL (used during RSS feed import).
Request Body:
{
url: string; // Valid HTTP/HTTPS URL
podcastId?: string; // UUID (optional for new podcasts)
imageType?: 'cover' | 'header' | 'profile' | 'banner'; // Default: 'cover'
}Validations:
- 30-second fetch timeout
- Accepts only
image/jpeg,image/png,image/webp - File size and dimension validation per type
- Aspect ratio tolerance: 5% (more lenient than direct uploads)
Response:
{
"success": true,
"path": "imports/{userId}/{uuid}.jpg",
"publicUrl": "https://media.podcasterplus.com/imports/.../uuid.jpg?v=1736123456789",
"dimensions": {
"width": 3000,
"height": 3000
},
"isTemporary": true
}Note: For new podcasts (no podcastId), artwork is stored in a temporary location under imports/{userId}/. Use /move-artwork after podcast creation to finalize.
POST /api/media/move-artwork
Move imported artwork from temporary to permanent location.
Request Body:
{
sourcePath: string; // Path from import-artwork response
podcastId: string; // UUID of created podcast
imageType: 'cover' | 'header' | 'profile' | 'banner';
}Authorization: Requires owner or admin role on target podcast.
Process:
- Validates ownership of target podcast
- Fetches source object from R2
- Copies to proper location preserving metadata
- Deletes source file (cleanup)
Response:
{
"success": true,
"path": "podcasts/{podcastId}/images/cover/{uuid}.jpg",
"publicUrl": "https://media.podcasterplus.com/.../cover/{uuid}.jpg?v=1736123456789"
}R2 Storage Structure
podcasterplus-media/
├── podcasts/
│ └── {podcast-id}/
│ ├── images/
│ │ ├── cover.{jpg|png} # 1400x1400+ show cover art
│ │ ├── header.{jpg|png|webp} # 1200x200+ header
│ │ └── banner.{jpg|png|webp} # 600x315+ social banner
│ └── e/
│ └── {episode-id}/
│ ├── audio/
│ │ └── original.{mp3|m4a|wav} # Episode audio
│ └── images/
│ └── cover.{jpg|png} # Per-episode cover art
└── imports/
└── {user-id}/
└── {uuid}.{ext} # Temporary artwork importsSupported File Types
Audio Formats
| MIME Type | Extension | Notes |
|---|---|---|
audio/mpeg | .mp3 | Most common, recommended |
audio/mp4 | .m4a | AAC audio |
audio/x-m4a | .m4a | AAC audio (alternate) |
audio/wav | .wav | Uncompressed |
audio/x-wav | .wav | Uncompressed (alternate) |
Max Size: 500 MB
Image Formats
| MIME Type | Extension | Supported Types |
|---|---|---|
image/jpeg | .jpg | All types |
image/png | .png | All types |
image/webp | .webp | header, profile, banner only |
Note: Cover images must be JPEG or PNG for RSS feed compatibility.
Cache Strategy
All uploaded files use immutable cache headers for optimal CDN performance:
Cache-Control: public, max-age=31536000, immutableTo prevent stale content:
- Audio uses a server-generated versioned object key per upload (
.../audio/{server-uuid}.{ext}); the key changes each upload, so the prior object is superseded and reclaimed by the storage reconciler's orphan sweep (retained, not deleted inline, for episodes that were publicly served) — no?v=parameter is used. - Images / other media append a cache-busting query parameter
?v=<Date.now()>, e.g.https://media.podcasterplus.com/.../cover/{uuid}.jpg?v=1736123456789.
Error Responses
| Code | Error | Cause |
|---|---|---|
400 | Invalid content type | Unsupported MIME type for file type |
400 | File too large | Exceeds size limit for type |
400 | Image dimensions too small | Below minimum dimensions |
400 | Invalid aspect ratio | Outside tolerance range |
401 | Unauthorized | Missing or invalid Bearer token |
403 | Forbidden | User lacks required role |
404 | Episode not found | Episode doesn't exist or wrong podcast |
408 | Request timeout | Artwork import fetch exceeded 30s |
500 | Upload failed | R2 operation error |
Error Response Format:
{
"error": "Error description",
"details": "Additional context (optional)",
"code": "ERROR_CODE (optional)"
}TypeScript Client Usage
import { createApiClient } from '$api/client';
const client = createApiClient(fetch);
const token = 'your-bearer-token';
// Audio Upload Flow
async function uploadAudio(file: File, podcastId: string, episodeId: string) {
// 1. Get upload metadata
const presignRes = await client.api.media.audio['presigned-url'].$post(
{
json: {
podcastId,
episodeId,
contentType: file.type as 'audio/mpeg',
filename: file.name,
fileSize: file.size
}
},
{
headers: { Authorization: `Bearer ${token}` }
}
);
if (!presignRes.ok) throw new Error('Failed to get upload URL');
const { uploadId, publicUrl } = await presignRes.json();
// 2. Upload directly to R2
const uploadRes = await client.api.media.audio.upload[':uploadId'].$put(
{
param: { uploadId },
query: { podcastId, episodeId, filename: file.name }
},
{
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': file.type
},
body: file
}
);
if (!uploadRes.ok) throw new Error('Upload failed');
return publicUrl;
}
// Image Upload Flow
async function uploadImage(
file: File,
podcastId: string,
imageType: 'cover' | 'header' | 'profile' | 'banner'
) {
// 1. Get upload metadata
const presignRes = await client.api.media['presigned-url'].$post(
{
json: {
podcastId,
imageType,
contentType: file.type as 'image/jpeg',
filename: file.name
}
},
{
headers: { Authorization: `Bearer ${token}` }
}
);
if (!presignRes.ok) throw new Error('Failed to get upload URL');
const { uploadId, publicUrl, dimensions } = await presignRes.json();
// 2. Upload directly to R2
const uploadRes = await client.api.media.upload[':uploadId'].$put(
{
param: { uploadId },
query: { podcastId, imageType }
},
{
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': file.type
},
body: file
}
);
if (!uploadRes.ok) throw new Error('Upload failed');
return publicUrl;
}
// Import artwork from URL (RSS import flow)
async function importArtwork(url: string, podcastId?: string) {
const res = await client.api.media['import-artwork'].$post(
{
json: { url, podcastId, imageType: 'cover' }
},
{
headers: { Authorization: `Bearer ${token}` }
}
);
if (!res.ok) throw new Error('Artwork import failed');
return res.json();
}
// Move imported artwork after podcast creation
async function moveArtwork(sourcePath: string, podcastId: string) {
const res = await client.api.media['move-artwork'].$post(
{
json: { sourcePath, podcastId, imageType: 'cover' }
},
{
headers: { Authorization: `Bearer ${token}` }
}
);
if (!res.ok) throw new Error('Move failed');
return res.json();
}Frontend Upload Component Example
<script lang="ts">
import { createApiClient } from '$api/client';
import { createClient } from '$lib/supabase/client';
let { podcastId, episodeId } = $props<{ podcastId: string; episodeId: string }>();
let uploading = $state(false);
let progress = $state(0);
let error = $state<string | null>(null);
async function handleAudioUpload(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
// Validate client-side
if (file.size > 500 * 1024 * 1024) {
error = 'File exceeds 500MB limit';
return;
}
uploading = true;
error = null;
progress = 0;
try {
// Get auth token
const supabase = createClient();
const {
data: { session }
} = await supabase.auth.getSession();
if (!session) throw new Error('Not authenticated');
const client = createApiClient(fetch);
// 1. Get presigned URL
const presignRes = await client.api.media.audio['presigned-url'].$post(
{
json: {
podcastId,
episodeId,
contentType: file.type as 'audio/mpeg',
filename: file.name,
fileSize: file.size
}
},
{
headers: { Authorization: `Bearer ${session.access_token}` }
}
);
if (!presignRes.ok) {
const err = await presignRes.json();
throw new Error(err.error || 'Failed to get upload URL');
}
const { uploadId, publicUrl } = await presignRes.json();
// 2. Upload with progress tracking using XHR
const uploadUrl = `/api/media/audio/upload/${uploadId}?podcastId=${podcastId}&episodeId=${episodeId}&filename=${encodeURIComponent(file.name)}`;
await new Promise<void>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
progress = Math.round((e.loaded / e.total) * 100);
}
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve();
} else {
reject(new Error(xhr.responseText || 'Upload failed'));
}
};
xhr.onerror = () => reject(new Error('Network error'));
xhr.open('PUT', uploadUrl);
xhr.setRequestHeader('Authorization', `Bearer ${session.access_token}`);
xhr.setRequestHeader('Content-Type', file.type);
xhr.send(file);
});
// 3. Update episode with audio URL
await updateEpisode(episodeId, { audio_url: publicUrl });
} catch (e) {
error = e instanceof Error ? e.message : 'Upload failed';
} finally {
uploading = false;
}
}
</script>
<div class="upload-container">
<input
type="file"
accept="audio/mpeg,audio/mp4,audio/x-m4a,audio/wav,audio/x-wav"
onchange={handleAudioUpload}
disabled={uploading}
/>
{#if uploading}
<div class="progress-bar">
<div class="progress-fill" style:width="{progress}%"></div>
</div>
<span>{progress}%</span>
{/if}
{#if error}
<p class="error">{error}</p>
{/if}
</div>Environment Bindings
The media routes require these Cloudflare bindings:
interface Env {
MEDIA_BUCKET: R2Bucket; // Cloudflare R2 bucket
PUBLIC_SUPABASE_URL: string; // Supabase project URL
SUPABASE_SECRET_KEY?: string; // Admin Supabase key (production)
SUPABASE_SERVICE_ROLE_KEY?: string; // Service role key (fallback)
R2_PUBLIC_URL?: string; // Custom R2 public URL
// (defaults to media.podcasterplus.com)
}Related
- Cloudflare R2 - Storage infrastructure
- RSS Feed Worker - Uses audio metadata
- Architecture Overview -
$lib/utils/image-url.tsfor transforms