Skip to content

Commit 6eee20a

Browse files
committed
Implement signed URLs for audio to reduce Vercel egress
- Add /api/audio/signed-url/[audioId] endpoint to generate signed URLs - Update audio library to generate signed URLs for all audio files - Frontend now uses signed URLs (playUrl) instead of Vercel proxy - Remove full chapter content from library response (only send preview) - Audio files now stream directly from Supabase, bypassing Vercel - Reduces Vercel cached egress from ~20MB per play to ~1KB per URL generation
1 parent a5d5db0 commit 6eee20a

5 files changed

Lines changed: 118 additions & 9 deletions

File tree

writegeist-web/app/api/audio/download/[audioId]/route.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,13 +60,14 @@ export async function GET(
6060
console.log('File downloaded successfully, size:', fileData.size)
6161

6262
// Return the file as a response
63+
// Note: Consider redirecting to audio_url (Supabase public URL) instead to avoid Vercel egress
6364
return new NextResponse(fileData.stream(), {
6465
status: 200,
6566
headers: {
6667
'Content-Type': 'audio/mpeg',
6768
'Content-Length': fileData.size.toString(),
6869
'Content-Disposition': `attachment; filename="chapter_${audio.chapter_id}_audio.mp3"`,
69-
'Cache-Control': 'private, max-age=3600'
70+
'Cache-Control': 'no-cache, no-store, must-revalidate' // Prevent Vercel caching to reduce egress
7071
}
7172
})
7273

writegeist-web/app/api/audio/library/route.ts

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export async function GET(request: NextRequest) {
1717
console.log('Loading audio library for user:', user.id)
1818

1919
// Get all chapters for the user with their audio status
20+
// Note: We only select content_length to calculate preview, not full content to reduce egress
2021
const { data: chapters, error: chaptersError } = await supabase
2122
.from('chapters')
2223
.select(`
@@ -64,9 +65,31 @@ export async function GET(request: NextRequest) {
6465

6566
console.log(`Found ${audioData.length} audio records`)
6667

68+
// Generate signed URLs for all completed audio files
69+
const audioWithSignedUrls = await Promise.all(
70+
audioData
71+
.filter(a => a.status === 'completed' && a.file_path)
72+
.map(async (audio) => {
73+
try {
74+
const { data: signedUrlData } = await supabase.storage
75+
.from('audio-files')
76+
.createSignedUrl(audio.file_path, 3600) // 1 hour expiration
77+
78+
return {
79+
...audio,
80+
signedUrl: signedUrlData?.signedUrl || null
81+
}
82+
} catch (error) {
83+
console.error('Error generating signed URL for audio:', audio.id, error)
84+
return { ...audio, signedUrl: null }
85+
}
86+
})
87+
)
88+
6789
// Combine chapters with their audio status and outdated detection
6890
const chaptersWithAudio = chapters?.map(chapter => {
69-
const audio = audioData.find(a => a.chapter_id === chapter.id)
91+
const audio = audioWithSignedUrls.find(a => a.chapter_id === chapter.id) ||
92+
audioData.find(a => a.chapter_id === chapter.id)
7093

7194
// Check if audio is outdated (compare content hashes)
7295
let isOutdated = false
@@ -81,13 +104,21 @@ export async function GET(request: NextRequest) {
81104
}
82105
}
83106

107+
// Don't send full content - only preview to reduce egress
108+
const { content, ...chapterWithoutContent } = chapter
109+
84110
return {
85-
...chapter,
86-
audio: audio ? { ...audio, isOutdated } : null,
111+
...chapterWithoutContent,
112+
audio: audio ? {
113+
...audio,
114+
isOutdated,
115+
// Include signedUrl if available, otherwise fallback to audio_url or null
116+
playUrl: audio.signedUrl || audio.audio_url || null
117+
} : null,
87118
project: chapter.projects,
88-
// Calculate content preview
89-
content_preview: chapter.content
90-
? chapter.content.substring(0, 200) + (chapter.content.length > 200 ? '...' : '')
119+
// Calculate content preview (don't send full content to reduce egress)
120+
content_preview: content
121+
? content.substring(0, 200) + (content.length > 200 ? '...' : '')
91122
: 'No content'
92123
}
93124
}) || []
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { NextRequest, NextResponse } from 'next/server'
2+
import { createClient } from '@/lib/supabase/server'
3+
4+
/**
5+
* GET /api/audio/signed-url/[audioId]
6+
* Generate a signed URL for an audio file (expires in 1 hour)
7+
* This avoids Vercel egress charges by serving files directly from Supabase
8+
*/
9+
export async function GET(
10+
request: NextRequest,
11+
{ params }: { params: Promise<{ audioId: string }> }
12+
) {
13+
try {
14+
const { audioId } = await params
15+
const supabase = await createClient()
16+
17+
// Get the current user
18+
const { data: { user }, error: userError } = await supabase.auth.getUser()
19+
if (userError || !user) {
20+
return NextResponse.json(
21+
{ error: 'User not authenticated' },
22+
{ status: 401 }
23+
)
24+
}
25+
26+
// Get audio record to verify ownership and get file path
27+
const { data: audio, error: audioError } = await supabase
28+
.from('chapter_audio')
29+
.select('*')
30+
.eq('id', audioId)
31+
.eq('user_id', user.id)
32+
.single()
33+
34+
if (audioError || !audio) {
35+
return NextResponse.json(
36+
{ error: 'Audio file not found' },
37+
{ status: 404 }
38+
)
39+
}
40+
41+
if (audio.status !== 'completed' || !audio.file_path) {
42+
return NextResponse.json(
43+
{ error: 'Audio file not ready' },
44+
{ status: 400 }
45+
)
46+
}
47+
48+
// Generate signed URL (expires in 1 hour = 3600 seconds)
49+
const { data: signedUrlData, error: signedUrlError } = await supabase.storage
50+
.from('audio-files')
51+
.createSignedUrl(audio.file_path, 3600)
52+
53+
if (signedUrlError || !signedUrlData) {
54+
console.error('Error generating signed URL:', signedUrlError)
55+
return NextResponse.json(
56+
{ error: 'Failed to generate signed URL' },
57+
{ status: 500 }
58+
)
59+
}
60+
61+
return NextResponse.json({
62+
success: true,
63+
signedUrl: signedUrlData.signedUrl,
64+
expiresIn: 3600
65+
})
66+
67+
} catch (error) {
68+
console.error('Signed URL generation error:', error)
69+
return NextResponse.json(
70+
{ error: 'Internal server error' },
71+
{ status: 500 }
72+
)
73+
}
74+
}
75+

writegeist-web/app/api/audio/stream/[audioId]/route.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,13 +54,15 @@ export async function GET(
5454
}
5555

5656
// Return the file for streaming
57+
// Note: This route should only be used as fallback. Prefer using audio_url (Supabase public URL) directly
58+
// to avoid Vercel Cached Egress charges
5759
return new NextResponse(fileData.stream(), {
5860
status: 200,
5961
headers: {
6062
'Content-Type': 'audio/mpeg',
6163
'Content-Length': fileData.size.toString(),
6264
'Accept-Ranges': 'bytes',
63-
'Cache-Control': 'private, max-age=3600'
65+
'Cache-Control': 'no-cache, no-store, must-revalidate' // Prevent Vercel caching to reduce egress
6466
}
6567
})
6668

writegeist-web/app/audio/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -575,7 +575,7 @@ export default function AudioPage() {
575575
className="w-full"
576576
preload="metadata"
577577
>
578-
<source src={`/api/audio/stream/${chapter.audio.id}`} type="audio/mpeg" />
578+
<source src={chapter.audio.playUrl || chapter.audio.audio_url || `/api/audio/stream/${chapter.audio.id}`} type="audio/mpeg" />
579579
Your browser does not support the audio element.
580580
</audio>
581581
</div>

0 commit comments

Comments
 (0)