Skip to content

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:

typescript
{
	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:

json
{
	"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, and publicUrl here are advisory only. The PUT finalize ignores the route :uploadId for the object key and generates its own server-side object id, storing at a versioned key .../audio/{server-uuid}.{ext} (so a caller cannot target original.{ext} or an existing object). It persists audio metadata server-side and returns the real path/publicUrl. maxSize reports 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: 5MB413)

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 type
    • Authorization: Bearer <token>

Response:

json
{
	"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:

  1. Validates UUIDs, content-type, authorization, the 5 MB interim proxy cap (Content-Length fast-path + a streaming ceiling, 413 if over), and rejects an empty body (400)
  2. Re-checks storage_gb_per_account against the actual received bytes (early 409 if over)
  3. Uploads to a server-generated versioned key (.../audio/{server-uuid}.{ext}) — never overwrites the existing object, and the caller's :uploadId is not used for the key
  4. 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)
  5. Persists audio metadata server-side via the service-role client under an optimistic audio_url guard (only finalizes if audio_url is unchanged since the pre-check): audio_url, the R2-measured audio_file_size_bytes (never client-supplied), audio_content_type, and audio_duration_seconds (from the duration query param). The episodes storage + finalized-audio triggers enforce the account cap and the publish invariant on this write.
  6. On a denied / errored / zero-row finalize: rolls back the just-uploaded object and returns 409 (cap or concurrent replace), 404 (episode gone), or 500 (other DB error) — the previous audio is left intact
  7. On success: invalidates the published feed (for a published episode — 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 a published/archived episode 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 removeAudio action (SvelteKit form action), which refuses to strip audio from a published/scheduled episode (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 (an archived episode's object is retained for the reconciler sweep). The audio metadata columns are locked to server-side writers — a browser-JWT write is rejected with 42501; and a native self-hosted episode cannot be published/scheduled without finalized audio (PT428). See migrations 20260619120000 and 20260622120000.

Image Endpoints

POST /api/media/presigned-url

Generate upload metadata for image files.

Request Body:

typescript
{
	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:

TypeAccepted FormatsMax SizeMin DimensionsAspect Ratio
coverJPEG, PNG5 MB1400×14001:1 (2% tolerance)
headerJPEG, PNG, WebP2 MB1200×2006.4:1
bannerJPEG, PNG, WebP2 MB600×3151.91:1
episode_coverJPEG, PNG5 MB1400×14001: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:

json
{
	"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 type
    • Authorization: Bearer <token>

Server-Side Dimension Parsing:

The API parses image dimensions directly from binary data without external libraries:

FormatDetection Method
JPEGReads SOF0/SOF1/SOF2 markers (0xFFC0-0xFFC3)
PNGReads IHDR chunk at bytes 16-23
WebPParses VP8/VP8L/VP8X chunk headers

Response:

json
{
	"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:

json
{
	"success": true,
	"deletedCount": 1
}

POST /api/media/import-artwork

Import artwork from an external URL (used during RSS feed import).

Request Body:

typescript
{
  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:

json
{
	"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:

typescript
{
	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:

  1. Validates ownership of target podcast
  2. Fetches source object from R2
  3. Copies to proper location preserving metadata
  4. Deletes source file (cleanup)

Response:

json
{
	"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 imports

Supported File Types

Audio Formats

MIME TypeExtensionNotes
audio/mpeg.mp3Most common, recommended
audio/mp4.m4aAAC audio
audio/x-m4a.m4aAAC audio (alternate)
audio/wav.wavUncompressed
audio/x-wav.wavUncompressed (alternate)

Max Size: 500 MB

Image Formats

MIME TypeExtensionSupported Types
image/jpeg.jpgAll types
image/png.pngAll types
image/webp.webpheader, 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, immutable

To 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

CodeErrorCause
400Invalid content typeUnsupported MIME type for file type
400File too largeExceeds size limit for type
400Image dimensions too smallBelow minimum dimensions
400Invalid aspect ratioOutside tolerance range
401UnauthorizedMissing or invalid Bearer token
403ForbiddenUser lacks required role
404Episode not foundEpisode doesn't exist or wrong podcast
408Request timeoutArtwork import fetch exceeded 30s
500Upload failedR2 operation error

Error Response Format:

json
{
	"error": "Error description",
	"details": "Additional context (optional)",
	"code": "ERROR_CODE (optional)"
}

TypeScript Client Usage

typescript
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

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

typescript
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)
}

Internal documentation - Not for public distribution