From 975ff603e180ae4f0f004b64eb6f86d42c346ecd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20=C4=B0pek?= Date: Sat, 28 Feb 2026 11:01:10 +0300 Subject: [PATCH] feat: add audio asset support with recording and upload functionality - Implemented audio asset handling in the API, including download and deletion routes. - Added audio content type mappings and extraction functions for audio file names and keys. - Enhanced the asset management UI to support audio uploads, including recording capabilities. - Updated the database schema to accommodate audio assets with new enum values. - Integrated audio playback features in the asset list and comment sections. - Improved user experience with drag-and-drop support for audio files. --- .gitignore | 5 +- app/api/upload/audio/route.ts | 93 ++++- .../assets/[assetId]/download/route.ts | 39 +- .../[videoId]/assets/[assetId]/route.ts | 12 + app/api/videos/[videoId]/assets/route.ts | 47 ++- components/video-page/asset-list-section.tsx | 10 +- components/video-page/assets-pane.tsx | 372 +++++++++++++++++- components/video-page/comment-rich-text.tsx | 10 +- .../video-page/hooks/use-comment-media.ts | 12 + .../video-page/hooks/use-video-assets.ts | 4 +- components/video-page/types.ts | 4 +- lib/admin-stats.ts | 31 +- lib/video-assets.ts | 14 + .../migration.sql | 5 + 14 files changed, 615 insertions(+), 43 deletions(-) create mode 100644 prisma/migrations/20260227000000_add_audio_asset_kind_and_provider/migration.sql diff --git a/.gitignore b/.gitignore index 42b5474..28a8dfa 100644 --- a/.gitignore +++ b/.gitignore @@ -46,4 +46,7 @@ next-env.d.ts # Progress (Internal Tracking) PROGRESS.md OPTIMIZATIONS.md -.kilocode \ No newline at end of file +.kilocode + +# Claude doesn't respect AGENTS.md and I don't want double AGENTS.md files on the repo. +CLAUDE.md \ No newline at end of file diff --git a/app/api/upload/audio/route.ts b/app/api/upload/audio/route.ts index a8975dc..747cfb8 100644 --- a/app/api/upload/audio/route.ts +++ b/app/api/upload/audio/route.ts @@ -15,7 +15,74 @@ import { } from '@/lib/guest-upload-token'; const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB -const ALLOWED_TYPES = ['audio/webm', 'audio/ogg', 'audio/mp4', 'audio/mpeg', 'audio/wav']; + +// Canonical MIME types accepted +const ALLOWED_TYPES = new Set(['audio/webm', 'audio/ogg', 'audio/opus', 'audio/mp4', 'audio/mpeg', 'audio/wav']); + +// Normalize known MIME aliases to canonical values +const MIME_ALIASES: Record = { + 'audio/wave': 'audio/wav', + 'audio/vnd.wave': 'audio/wav', + 'audio/x-wav': 'audio/wav', + 'audio/x-pn-wav': 'audio/wav', + 'audio/mp3': 'audio/mpeg', + 'audio/x-mpeg': 'audio/mpeg', +}; + +// Map canonical MIME to fallback file extension +const MIME_TO_EXT: Record = { + 'audio/webm': 'webm', + 'audio/ogg': 'ogg', + 'audio/opus': 'opus', + 'audio/mp4': 'm4a', + 'audio/mpeg': 'mp3', + 'audio/wav': 'wav', +}; + +// Safe extensions to preserve from original filename (prevents path traversal, allows known types) +// Intentionally excludes flac/aac: they have no corresponding MIME in ALLOWED_TYPES and are +// never produced by MediaRecorder, so accepting them would create extension/MIME mismatches. +const SAFE_AUDIO_EXTENSIONS = new Set(['webm', 'ogg', 'opus', 'mp3', 'm4a', 'mp4', 'wav']); + +// Reject content that looks like HTML/XML/script regardless of the declared MIME type. +function isHtmlContent(bytes: Buffer): boolean { + const snippet = bytes.toString('latin1', 0, Math.min(bytes.length, 512)).trimStart().slice(0, 50).toLowerCase(); + return ( + snippet.startsWith(' }; type BunnySourcePreference = 'auto' | 'original' | 'compressed'; -const CONTENT_TYPE_BY_EXTENSION: Record = { +const IMAGE_CONTENT_TYPE_BY_EXTENSION: Record = { jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', webp: 'image/webp', gif: 'image/gif', }; + +const AUDIO_CONTENT_TYPE_BY_EXTENSION: Record = { + webm: 'audio/webm', + ogg: 'audio/ogg', + opus: 'audio/ogg', + mp4: 'audio/mp4', + m4a: 'audio/mp4', + mpeg: 'audio/mpeg', + mp3: 'audio/mpeg', + wav: 'audio/wav', +}; const BUNNY_ALLOWED_QUALITIES = new Set([2160, 1440, 1080, 720, 480, 360, 240]); function sanitizeFileName(value: string): string { @@ -47,7 +59,7 @@ function buildContentDisposition(fileNameWithExt: string): string { function imageContentTypeFromFileName(fileName: string): string { const ext = fileName.split('.').pop()?.toLowerCase() || ''; - return CONTENT_TYPE_BY_EXTENSION[ext] || 'application/octet-stream'; + return IMAGE_CONTENT_TYPE_BY_EXTENSION[ext] || 'application/octet-stream'; } // GET /api/videos/[videoId]/assets/[assetId]/download @@ -101,6 +113,29 @@ export async function GET(request: NextRequest, { params }: RouteParams) { }); } + if (asset.provider === VideoAssetProvider.R2_AUDIO) { + const fileName = extractAudioFileNameFromProxyUrl(asset.sourceUrl); + if (!fileName) return apiErrors.badRequest('Invalid audio asset URL'); + const key = `voice/${fileName}`; + const ext = fileName.includes('.') ? fileName.slice(fileName.lastIndexOf('.')) : '.webm'; + const downloadName = `${sanitizeFileName(asset.displayName)}${ext}`; + const contentDisposition = buildContentDisposition(downloadName); + const extKey = ext.replace('.', ''); + const contentType = AUDIO_CONTENT_TYPE_BY_EXTENSION[extKey] || 'audio/webm'; + + return proxyR2MediaObject({ + request, + key, + fallbackContentType: contentType, + cacheControl: 'private, no-store', + extraHeaders: { + 'Content-Disposition': contentDisposition, + 'X-Content-Type-Options': 'nosniff', + }, + internalErrorMessage: 'Failed to retrieve audio', + }); + } + const sourceParam = request.nextUrl.searchParams.get('source'); const rawQuality = request.nextUrl.searchParams.get('quality'); const isPrepareOnly = request.nextUrl.searchParams.get('prepare') === '1'; diff --git a/app/api/videos/[videoId]/assets/[assetId]/route.ts b/app/api/videos/[videoId]/assets/[assetId]/route.ts index 7271acf..277927c 100644 --- a/app/api/videos/[videoId]/assets/[assetId]/route.ts +++ b/app/api/videos/[videoId]/assets/[assetId]/route.ts @@ -42,6 +42,7 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) { } let shouldDeleteImageObject = false; + let shouldDeleteAudioObject = false; await db.$transaction(async (tx) => { await tx.videoAsset.delete({ where: { id: asset.id } }); @@ -52,12 +53,23 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) { ]); shouldDeleteImageObject = assetReferenceCount === 0 && commentReferenceCount === 0; } + + if (asset.provider === VideoAssetProvider.R2_AUDIO) { + const [assetReferenceCount, commentReferenceCount] = await Promise.all([ + tx.videoAsset.count({ where: { sourceUrl: asset.sourceUrl } }), + tx.comment.count({ where: { voiceUrl: asset.sourceUrl } }), + ]); + shouldDeleteAudioObject = assetReferenceCount === 0 && commentReferenceCount === 0; + } }); let r2CleanupResult: Awaited> | undefined; if (asset.provider === VideoAssetProvider.R2_IMAGE && shouldDeleteImageObject) { r2CleanupResult = await deleteMediaFilesBestEffort([asset.sourceUrl]); } + if (asset.provider === VideoAssetProvider.R2_AUDIO && shouldDeleteAudioObject) { + r2CleanupResult = await deleteMediaFilesBestEffort([asset.sourceUrl]); + } let bunnyCleanupResult: Awaited> | undefined; if (asset.provider === VideoAssetProvider.BUNNY && asset.providerVideoId) { diff --git a/app/api/videos/[videoId]/assets/route.ts b/app/api/videos/[videoId]/assets/route.ts index ac509c9..f463e04 100644 --- a/app/api/videos/[videoId]/assets/route.ts +++ b/app/api/videos/[videoId]/assets/route.ts @@ -15,9 +15,12 @@ import { resolveServerBunnyCdnHostname } from '@/lib/bunny-cdn'; import { SAFE_BUNNY_VIDEO_ID, SAFE_IMAGE_PROXY_PATH, + SAFE_AUDIO_PROXY_PATH, canDeleteAssetForViewer, extractImageFileNameFromProxyUrl, extractImageKeyFromProxyUrl, + extractAudioKeyFromProxyUrl, + extractAudioFileNameFromProxyUrl, getVideoAssetAccessContext, sanitizeAssetDisplayName, } from '@/lib/video-assets'; @@ -32,7 +35,7 @@ const YOUTUBE_TITLE_CACHE_TTL_MS = 5 * 60 * 1000; type AssetWithViewerFields = { id: string; videoId: string; - kind: 'IMAGE' | 'VIDEO'; + kind: 'IMAGE' | 'VIDEO' | 'AUDIO'; provider: VideoAssetProvider; displayName: string; sourceUrl: string; @@ -159,6 +162,22 @@ async function isFreshImageAttachment(url: string): Promise { } } +async function isFreshAudioAttachment(url: string): Promise { + const key = extractAudioKeyFromProxyUrl(url); + if (!key) return false; + + try { + const head = await r2Client.send(new HeadObjectCommand({ + Bucket: R2_BUCKET_NAME, + Key: key, + })); + if (!head.LastModified) return false; + return Date.now() - head.LastModified.getTime() <= UNATTACHED_UPLOAD_TTL_MS; + } catch { + return false; + } +} + // GET /api/videos/[videoId]/assets export async function GET(request: NextRequest, { params }: RouteParams) { try { @@ -225,7 +244,8 @@ export async function GET(request: NextRequest, { params }: RouteParams) { const response = successResponse({ assets: pagedAssets.map((asset) => shapeAssetForViewer( asset, - context.canDownloadAssets, + // R2_AUDIO proxy URLs have no auth gate — expose them to any viewer so guests can preview audio + context.canDownloadAssets || (asset.provider === VideoAssetProvider.R2_AUDIO && context.hasViewAccess), includeDeleteMetadata ? canDeleteAssetForViewer(asset, context) : false )), pagination: { @@ -259,7 +279,12 @@ export async function POST(request: NextRequest, { params }: RouteParams) { const body = await request.json().catch(() => null); const provider = typeof body?.provider === 'string' ? body.provider.trim().toUpperCase() : ''; - if (provider !== VideoAssetProvider.R2_IMAGE && provider !== VideoAssetProvider.YOUTUBE && provider !== VideoAssetProvider.BUNNY) { + if ( + provider !== VideoAssetProvider.R2_IMAGE && + provider !== VideoAssetProvider.YOUTUBE && + provider !== VideoAssetProvider.BUNNY && + provider !== VideoAssetProvider.R2_AUDIO + ) { return apiErrors.badRequest('Invalid provider'); } @@ -271,7 +296,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) { let sourceUrl = ''; let providerVideoId: string | null = null; let thumbnailUrl: string | null = null; - let kind: 'IMAGE' | 'VIDEO' = 'IMAGE'; + let kind: 'IMAGE' | 'VIDEO' | 'AUDIO' = 'IMAGE'; if (provider === VideoAssetProvider.R2_IMAGE) { sourceUrl = typeof body?.sourceUrl === 'string' ? body.sourceUrl.trim() : ''; @@ -288,6 +313,20 @@ export async function POST(request: NextRequest, { params }: RouteParams) { kind = 'IMAGE'; } + if (provider === VideoAssetProvider.R2_AUDIO) { + sourceUrl = typeof body?.sourceUrl === 'string' ? body.sourceUrl.trim() : ''; + if (!SAFE_AUDIO_PROXY_PATH.test(sourceUrl)) { + return apiErrors.badRequest('Audio URL must reference an uploaded audio file'); + } + if (!(await isFreshAudioAttachment(sourceUrl))) { + return apiErrors.badRequest('Audio upload expired. Please upload again.'); + } + + const fileName = extractAudioFileNameFromProxyUrl(sourceUrl); + displayName = sanitizeAssetDisplayName(requestedDisplayName, fileName || 'Voice Recording'); + kind = 'AUDIO'; + } + if (provider === VideoAssetProvider.YOUTUBE) { sourceUrl = typeof body?.sourceUrl === 'string' ? body.sourceUrl.trim() : ''; const parsedSource = parseVideoUrl(sourceUrl); diff --git a/components/video-page/asset-list-section.tsx b/components/video-page/asset-list-section.tsx index fb01bf9..4b676fe 100644 --- a/components/video-page/asset-list-section.tsx +++ b/components/video-page/asset-list-section.tsx @@ -1,7 +1,7 @@ 'use client'; import { memo, type ReactNode } from 'react'; -import { Download, Image as ImageIcon, Loader2, Play, Trash2 } from 'lucide-react'; +import { Download, Image as ImageIcon, Loader2, Play, Trash2, Volume2 } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { @@ -103,15 +103,15 @@ export const AssetListSection = memo(function AssetListSection({ size="icon" variant="outline" className="h-7 w-7" - title={asset.kind === 'VIDEO' ? 'Play video' : 'View image'} - aria-label={asset.kind === 'VIDEO' ? 'Play video' : 'View image'} + title={asset.kind === 'VIDEO' ? 'Play video' : asset.kind === 'AUDIO' ? 'Play recording' : 'View image'} + aria-label={asset.kind === 'VIDEO' ? 'Play video' : asset.kind === 'AUDIO' ? 'Play recording' : 'View image'} onClick={() => onViewAsset(asset)} > - {asset.kind === 'IMAGE' ? : } + {asset.kind === 'IMAGE' ? : asset.kind === 'AUDIO' ? : } {canDownloadAssets && asset.provider !== 'YOUTUBE' && ( - asset.provider === 'BUNNY' ? ( + asset.provider === 'BUNNY' && asset.kind !== 'AUDIO' ? ( + + + + ) : isRecording ? ( +
+
+ + + + + Recording + + {String(Math.floor(recordingTime / 60)).padStart(2, '0')}:{String(recordingTime % 60).padStart(2, '0')} + +
+ + +
+ ) : audioBlob ? ( +
+
+ +
+
+
+ + {playingVoiceId === 'recording-preview' + ? `${formatTime(voiceCurrentTime)} / ${formatTime(recordingTime)}` + : formatTime(recordingTime)} + + {playingVoiceId === 'recording-preview' && ( + + )} +
+
+ + +
+
+ ) : ( + + )} +

Or drag an audio file anywhere onto this panel.

+
+ )} ) : (
@@ -672,6 +979,43 @@ export const AssetsPane = memo(function AssetsPane({ }} /> + { if (!open) { stopVoice(); setSelectedAsset(null); } }}> + + {selectedAsset?.displayName || 'Voice Recording'} + {selectedAsset?.sourceUrl ? ( +
+ +
+
+
+ + {playingVoiceId === selectedAsset?.id ? formatTime(voiceCurrentTime) : '00:00'} + + {playingVoiceId === selectedAsset?.id && ( + + )} +
+ ) : ( +

Audio preview unavailable.

+ )} + +
+ !open && setSelectedAsset(null)}> asset.id === assetId); const label = matchedAsset?.displayName || fallbackLabel; - const isVideoAsset = matchedAsset?.kind === 'VIDEO'; + const assetKind = matchedAsset?.kind; nodes.push(