Podcasts hosted on ATProto

Implement chunked blob upload for large podcast episodes

Problem:
- AT Protocol PDS servers have blob size limits (~5-10 MB)
- Users were getting PayloadTooLarge (413) errors when uploading large episodes

Solution:
- Implement automatic file chunking for files >5MB
- Upload each chunk as a separate blob to AT Protocol
- Store chunk manifest in episode record with audioChunks array
- Reconstruct full audio file when streaming by concatenating chunks

Changes:
- Upload: Split large files into 5MB chunks and upload sequentially
- Episode schema: Add audioChunks[], totalSize, and isChunked fields
- Streaming: Detect chunked episodes and reassemble chunks on-the-fly
- Frontend: Update file size limits (500 MB max) with chunking indicator
- Error handling: Better messages for file size issues

Benefits:
- Supports episode files up to 500 MB (from ~5-10 MB)
- Transparent to users - chunking happens automatically
- Backwards compatible with existing single-blob episodes
- Maintains audio quality (no compression/transcoding)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

Changed files
+240 -27
client
src
components
src
+16 -1
client/src/components/UploadModal.jsx
··· 10 10 const [error, setError] = useState(''); 11 11 const [success, setSuccess] = useState(''); 12 12 13 + const formatFileSize = (bytes) => { 14 + if (bytes === 0) return '0 Bytes'; 15 + const k = 1024; 16 + const sizes = ['Bytes', 'KB', 'MB', 'GB']; 17 + const i = Math.floor(Math.log(bytes) / Math.log(k)); 18 + return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i]; 19 + }; 20 + 13 21 const handleSubmit = async (e) => { 14 22 e.preventDefault(); 15 23 ··· 57 65 onChange={(e) => setFile(e.target.files[0])} 58 66 required 59 67 /> 60 - <small>Supported formats: MP3, M4A, WAV, etc.</small> 68 + {file && ( 69 + <small style={{ display: 'block', marginTop: '0.5rem', color: file.size > 500 * 1024 * 1024 ? '#ef4444' : 'inherit' }}> 70 + Selected: {file.name} ({formatFileSize(file.size)}) 71 + {file.size > 500 * 1024 * 1024 && ' - File exceeds 500 MB limit'} 72 + {file.size > 5 * 1024 * 1024 && file.size <= 500 * 1024 * 1024 && ' - Will be uploaded in chunks'} 73 + </small> 74 + )} 75 + <small>Supported formats: MP3, M4A, WAV, etc. Large files will be automatically chunked (max 500 MB)</small> 61 76 </div> 62 77 63 78 <div className="form-group">
+3 -2
src/index.js
··· 41 41 exposedHeaders: ['Set-Cookie'] 42 42 })); 43 43 44 - // Parse JSON bodies 45 - app.use(express.json()); 44 + // Parse JSON bodies with increased limit for large payloads 45 + app.use(express.json({ limit: '50mb' })); 46 + app.use(express.urlencoded({ limit: '50mb', extended: true })); 46 47 47 48 // Session middleware 48 49 app.use(session({
+68 -4
src/routes/media.js
··· 9 9 /** 10 10 * Stream media from AT Protocol blob storage 11 11 * GET /api/media/stream/:did/:cid 12 + * Supports both single blob and chunked blob episodes 12 13 */ 13 14 router.get('/stream/:did/:cid', async (req, res) => { 14 15 try { 15 16 const { did, cid } = req.params; 16 - 17 + 17 18 console.log('Stream request:', { did, cid }); 18 - 19 - // Fetch blob from AT Protocol 19 + 20 + // Check if this is a chunked episode by trying to get the episode record 21 + // The CID in the URL will be from the first chunk for chunked episodes 22 + try { 23 + // Try to find the episode record that matches this CID 24 + const rpc = new Client({ 25 + handler: (pathname, init) => { 26 + return fetch(pathname, init); 27 + } 28 + }); 29 + 30 + const records = await ok( 31 + rpc.get('com.atproto.repo.listRecords', { 32 + params: { 33 + repo: did, 34 + collection: 'app.podcast.episode' 35 + } 36 + }) 37 + ); 38 + 39 + // Find the episode with this CID in its audio field 40 + const episode = records.records?.find(record => { 41 + const audioCid = record.value.audio?.ref?.$link || record.value.audio?.ref?.toString(); 42 + return audioCid === cid; 43 + }); 44 + 45 + // If we found a chunked episode, stream all chunks 46 + if (episode?.value?.isChunked && episode.value.audioChunks) { 47 + console.log(`Streaming chunked episode with ${episode.value.audioChunks.length} chunks`); 48 + 49 + const chunks = []; 50 + for (let i = 0; i < episode.value.audioChunks.length; i++) { 51 + const chunk = episode.value.audioChunks[i]; 52 + const chunkCid = chunk.ref.$link || chunk.ref.toString(); 53 + 54 + console.log(`Fetching chunk ${i + 1}/${episode.value.audioChunks.length}: ${chunkCid}`); 55 + 56 + const blobResponse = await atprotoClient.getBlob(did, chunkCid); 57 + 58 + if (!blobResponse.ok) { 59 + console.error('Chunk not found:', { did, chunkCid, chunkIndex: i }); 60 + throw new Error(`Chunk ${i + 1} not found`); 61 + } 62 + 63 + const buffer = await blobResponse.arrayBuffer(); 64 + chunks.push(Buffer.from(buffer)); 65 + } 66 + 67 + // Concatenate all chunks 68 + const fullAudio = Buffer.concat(chunks); 69 + const contentType = episode.value.audioChunks[0].mimeType || 'audio/mpeg'; 70 + 71 + res.setHeader('Content-Type', contentType); 72 + res.setHeader('Content-Length', fullAudio.length); 73 + res.setHeader('Accept-Ranges', 'bytes'); 74 + 75 + console.log(`Streaming complete file: ${fullAudio.length} bytes`); 76 + return res.send(fullAudio); 77 + } 78 + } catch (recordError) { 79 + // If we can't find the record, fall back to single blob streaming 80 + console.log('Could not check for chunked episode, falling back to single blob:', recordError.message); 81 + } 82 + 83 + // Fall back to single blob streaming 20 84 const blobResponse = await atprotoClient.getBlob(did, cid); 21 - 85 + 22 86 if (!blobResponse.ok) { 23 87 console.error('Blob not found:', { did, cid, status: blobResponse.status }); 24 88 return res.status(404).json({ error: 'Media not found' });
+153 -20
src/routes/upload.js
··· 6 6 import { storePodcastMetadata } from '../storage/metadata.js'; 7 7 8 8 const router = express.Router(); 9 - const upload = multer({ dest: 'public/uploads/' }); 9 + 10 + // AT Protocol PDS typically has a 10MB limit for blobs 11 + // We'll chunk files larger than this into smaller pieces 12 + const PDS_BLOB_LIMIT = 5 * 1024 * 1024; // 5MB per chunk (conservative estimate) 13 + const MAX_FILE_SIZE = 500 * 1024 * 1024; // 500MB total file limit 14 + 15 + const upload = multer({ 16 + dest: 'public/uploads/', 17 + limits: { 18 + fileSize: MAX_FILE_SIZE 19 + } 20 + }); 21 + 22 + /** 23 + * Split buffer into chunks for multi-blob upload 24 + */ 25 + function splitIntoChunks(buffer, chunkSize) { 26 + const chunks = []; 27 + for (let i = 0; i < buffer.length; i += chunkSize) { 28 + chunks.push(buffer.slice(i, i + chunkSize)); 29 + } 30 + return chunks; 31 + } 10 32 11 33 /** 12 34 * Upload a podcast episode ··· 15 37 */ 16 38 router.post('/episode', requireAuth, upload.single('audio'), async (req, res) => { 17 39 try { 40 + console.log('Upload request received:', { 41 + hasFile: !!req.file, 42 + filename: req.file?.originalname, 43 + size: req.file?.size, 44 + mimetype: req.file?.mimetype, 45 + title: req.body?.title, 46 + userDid: req.oauthSession?.did 47 + }); 48 + 18 49 if (!req.file) { 50 + console.error('Upload failed: No audio file provided'); 19 51 return res.status(400).json({ error: 'No audio file provided' }); 20 52 } 21 53 22 54 const { title, description, pubDate } = req.body; 23 - 55 + 24 56 if (!title) { 57 + console.error('Upload failed: No title provided'); 25 58 return res.status(400).json({ error: 'Episode title is required' }); 26 59 } 27 60 61 + console.log('Reading uploaded file from:', req.file.path); 28 62 // Read the uploaded file 29 63 const audioData = await fs.readFile(req.file.path); 64 + console.log('File read successfully, size:', audioData.length, 'bytes'); 30 65 31 66 // Create atcute Client with OAuth session's authenticated fetch handler 32 67 const rpc = new Client({ ··· 36 71 } 37 72 }); 38 73 39 - // Upload to AT Protocol as a blob 40 - const uploadResponse = await ok( 41 - rpc.post('com.atproto.repo.uploadBlob', { 42 - input: audioData, 43 - headers: { 44 - 'Content-Type': req.file.mimetype 74 + // Determine if we need to chunk the file 75 + const needsChunking = audioData.length > PDS_BLOB_LIMIT; 76 + let audioBlobs = []; 77 + 78 + if (needsChunking) { 79 + console.log(`File is large (${(audioData.length / (1024 * 1024)).toFixed(2)} MB), splitting into chunks...`); 80 + 81 + // Split file into chunks 82 + const chunks = splitIntoChunks(audioData, PDS_BLOB_LIMIT); 83 + console.log(`Split into ${chunks.length} chunks`); 84 + 85 + // Upload each chunk 86 + for (let i = 0; i < chunks.length; i++) { 87 + console.log(`Uploading chunk ${i + 1}/${chunks.length} (${(chunks[i].length / (1024 * 1024)).toFixed(2)} MB)...`); 88 + 89 + try { 90 + const chunkResponse = await ok( 91 + rpc.post('com.atproto.repo.uploadBlob', { 92 + input: chunks[i], 93 + headers: { 94 + 'Content-Type': req.file.mimetype 95 + } 96 + }) 97 + ); 98 + 99 + audioBlobs.push({ 100 + ...chunkResponse.blob, 101 + chunkIndex: i, 102 + chunkSize: chunks[i].length 103 + }); 104 + 105 + console.log(`Chunk ${i + 1}/${chunks.length} uploaded successfully`); 106 + } catch (uploadError) { 107 + // Clean up temporary file before throwing 108 + await fs.unlink(req.file.path).catch(() => {}); 109 + 110 + if (uploadError.status === 413 || uploadError.error === 'PayloadTooLarge') { 111 + throw new Error(`Chunk ${i + 1} is too large for your PDS server. The PDS_BLOB_LIMIT may need to be reduced.`); 112 + } 113 + throw uploadError; 114 + } 115 + } 116 + 117 + console.log('All chunks uploaded successfully'); 118 + } else { 119 + // Upload as single blob 120 + console.log('Uploading as single blob to AT Protocol...'); 121 + try { 122 + const uploadResponse = await ok( 123 + rpc.post('com.atproto.repo.uploadBlob', { 124 + input: audioData, 125 + headers: { 126 + 'Content-Type': req.file.mimetype 127 + } 128 + }) 129 + ); 130 + 131 + audioBlobs = [uploadResponse.blob]; 132 + } catch (uploadError) { 133 + // Clean up temporary file before throwing 134 + await fs.unlink(req.file.path).catch(() => {}); 135 + 136 + if (uploadError.status === 413 || uploadError.error === 'PayloadTooLarge') { 137 + const sizeMB = (audioData.length / (1024 * 1024)).toFixed(2); 138 + throw new Error(`File is too large (${sizeMB} MB). Your PDS server limits blob uploads. Try a smaller file or compress your audio.`); 45 139 } 46 - }) 47 - ); 140 + throw uploadError; 141 + } 142 + } 48 143 49 - // The response contains a blob object 50 - const blobData = uploadResponse.blob; 144 + // Use the first blob for backwards compatibility in metadata 145 + const blobData = audioBlobs[0]; 51 146 52 147 // Extract CID from blob reference 53 148 const cid = blobData.ref.$link || blobData.ref.toString(); ··· 73 168 74 169 // Use a simple app.podcast.episode collection (custom namespace) 75 170 const rkey = `${Date.now()}`; 171 + 172 + // Build the episode record 173 + const episodeRecord = { 174 + $type: 'app.podcast.episode', 175 + title: title, 176 + description: description || '', 177 + publishedAt: episode.pubDate, 178 + createdAt: episode.pubDate 179 + }; 180 + 181 + // If chunked, store all chunks; otherwise store single blob 182 + if (audioBlobs.length > 1) { 183 + console.log(`Storing chunked episode with ${audioBlobs.length} chunks`); 184 + episodeRecord.audioChunks = audioBlobs; 185 + episodeRecord.audio = blobData; // Keep first chunk as primary for backwards compatibility 186 + episodeRecord.totalSize = audioData.length; 187 + episodeRecord.isChunked = true; 188 + } else { 189 + episodeRecord.audio = blobData; 190 + } 191 + 76 192 const record = await ok( 77 193 rpc.post('com.atproto.repo.createRecord', { 78 194 input: { 79 195 repo: req.oauthSession.did, 80 196 collection: 'app.podcast.episode', 81 197 rkey: rkey, 82 - record: { 83 - $type: 'app.podcast.episode', 84 - title: title, 85 - description: description || '', 86 - audio: blobData, // Reference the blob in the record 87 - publishedAt: episode.pubDate, 88 - createdAt: episode.pubDate 89 - } 198 + record: episodeRecord 90 199 } 91 200 }) 92 201 ); ··· 109 218 }); 110 219 } catch (error) { 111 220 console.error('Upload error:', error); 221 + 222 + // Clean up temporary file if it exists 223 + if (req.file?.path) { 224 + await fs.unlink(req.file.path).catch(() => {}); 225 + } 226 + 112 227 res.status(500).json({ error: 'Failed to upload episode', details: error.message }); 113 228 } 229 + }); 230 + 231 + // Error handler for multer errors (file too large, etc.) 232 + router.use((error, req, res, next) => { 233 + if (error instanceof multer.MulterError) { 234 + if (error.code === 'LIMIT_FILE_SIZE') { 235 + const maxSizeMB = (MAX_FILE_SIZE / (1024 * 1024)).toFixed(0); 236 + return res.status(413).json({ 237 + error: 'File too large', 238 + details: `Maximum file size is ${maxSizeMB} MB. Please compress your audio file or split it into multiple episodes.` 239 + }); 240 + } 241 + return res.status(400).json({ 242 + error: 'Upload error', 243 + details: error.message 244 + }); 245 + } 246 + next(error); 114 247 }); 115 248 116 249 export { router as uploadRouter };