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.
This commit is contained in:
Yusuf İpek
2026-02-28 11:01:10 +03:00
parent 9dde7dce44
commit 975ff603e1
14 changed files with 615 additions and 43 deletions
+3
View File
@@ -47,3 +47,6 @@ next-env.d.ts
PROGRESS.md PROGRESS.md
OPTIMIZATIONS.md OPTIMIZATIONS.md
.kilocode .kilocode
# Claude doesn't respect AGENTS.md and I don't want double AGENTS.md files on the repo.
CLAUDE.md
+86 -7
View File
@@ -15,7 +15,74 @@ import {
} from '@/lib/guest-upload-token'; } from '@/lib/guest-upload-token';
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB 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<string, string> = {
'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<string, string> = {
'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('<!doctype') ||
snippet.startsWith('<html') ||
snippet.startsWith('<?xml') ||
snippet.startsWith('<script') ||
snippet.startsWith('<svg')
);
}
// Verify that the first bytes of the file match known audio container signatures.
function hasValidAudioMagicBytes(header: Buffer, mimeType: string): boolean {
if (header.length < 8) return false;
switch (mimeType) {
// WebM / Matroska: EBML header 1a 45 df a3
case 'audio/webm':
return header[0] === 0x1a && header[1] === 0x45 && header[2] === 0xdf && header[3] === 0xa3;
// OGG container (covers ogg vorbis and opus)
case 'audio/ogg':
case 'audio/opus':
return header[0] === 0x4f && header[1] === 0x67 && header[2] === 0x67 && header[3] === 0x53; // "OggS"
// MPEG audio: ID3 tag header or raw MPEG sync frame
case 'audio/mpeg': {
const hasId3 = header[0] === 0x49 && header[1] === 0x44 && header[2] === 0x33; // "ID3"
const hasMpegSync = header[0] === 0xff && (header[1] & 0xe0) === 0xe0;
return hasId3 || hasMpegSync;
}
// MP4 / M4A: ISO base media file; "ftyp" box starts at offset 4
case 'audio/mp4':
return header[4] === 0x66 && header[5] === 0x74 && header[6] === 0x79 && header[7] === 0x70; // "ftyp"
// WAV: RIFF header
case 'audio/wav':
return header[0] === 0x52 && header[1] === 0x49 && header[2] === 0x46 && header[3] === 0x46; // "RIFF"
default:
return false;
}
}
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
@@ -101,14 +168,18 @@ export async function POST(request: NextRequest) {
return apiErrors.badRequest('File too large. Maximum size is 10MB.'); return apiErrors.badRequest('File too large. Maximum size is 10MB.');
} }
// Check content type // Normalize content type: strip codec params, then resolve aliases
const contentType = file.type || 'audio/webm'; const rawContentType = file.type || 'audio/webm';
if (!ALLOWED_TYPES.includes(contentType)) { const strippedType = rawContentType.split(';')[0].trim().toLowerCase();
return apiErrors.badRequest(`Unsupported audio format: ${contentType}`); const contentType = MIME_ALIASES[strippedType] ?? strippedType;
if (!ALLOWED_TYPES.has(contentType)) {
return apiErrors.badRequest(`Unsupported audio format: ${rawContentType}`);
} }
// Generate unique filename // Prefer the original file extension when it's a known safe type (e.g. preserve .opus, .mp3)
const ext = contentType.split('/')[1] || 'webm'; // Fall back to MIME-derived extension for blobs without a real name (e.g. MediaRecorder output)
const origExt = (file.name.split('.').pop() ?? '').toLowerCase();
const ext = SAFE_AUDIO_EXTENSIONS.has(origExt) ? origExt : (MIME_TO_EXT[contentType] ?? 'webm');
const filename = `${randomUUID()}.${ext}`; const filename = `${randomUUID()}.${ext}`;
const key = `voice/${filename}`; const key = `voice/${filename}`;
@@ -116,6 +187,14 @@ export async function POST(request: NextRequest) {
const arrayBuffer = await file.arrayBuffer(); const arrayBuffer = await file.arrayBuffer();
const buffer = Buffer.from(arrayBuffer); const buffer = Buffer.from(arrayBuffer);
// Validate file content against magic bytes — rejects HTML/scripts masquerading as audio
if (isHtmlContent(buffer)) {
return apiErrors.badRequest('File content does not match an audio format');
}
if (!hasValidAudioMagicBytes(buffer.slice(0, 16), contentType)) {
return apiErrors.badRequest('File content does not match the declared audio format');
}
// Upload to R2 // Upload to R2
await r2Client.send( await r2Client.send(
new PutObjectCommand({ new PutObjectCommand({
@@ -7,19 +7,31 @@ import { fetchWithTimeout, resolveBunnyDownloadSource } from '@/lib/bunny-downlo
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { import {
extractImageFileNameFromProxyUrl, extractImageFileNameFromProxyUrl,
extractAudioFileNameFromProxyUrl,
getVideoAssetAccessContext, getVideoAssetAccessContext,
} from '@/lib/video-assets'; } from '@/lib/video-assets';
type RouteParams = { params: Promise<{ videoId: string; assetId: string }> }; type RouteParams = { params: Promise<{ videoId: string; assetId: string }> };
type BunnySourcePreference = 'auto' | 'original' | 'compressed'; type BunnySourcePreference = 'auto' | 'original' | 'compressed';
const CONTENT_TYPE_BY_EXTENSION: Record<string, string> = { const IMAGE_CONTENT_TYPE_BY_EXTENSION: Record<string, string> = {
jpg: 'image/jpeg', jpg: 'image/jpeg',
jpeg: 'image/jpeg', jpeg: 'image/jpeg',
png: 'image/png', png: 'image/png',
webp: 'image/webp', webp: 'image/webp',
gif: 'image/gif', gif: 'image/gif',
}; };
const AUDIO_CONTENT_TYPE_BY_EXTENSION: Record<string, string> = {
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]); const BUNNY_ALLOWED_QUALITIES = new Set([2160, 1440, 1080, 720, 480, 360, 240]);
function sanitizeFileName(value: string): string { function sanitizeFileName(value: string): string {
@@ -47,7 +59,7 @@ function buildContentDisposition(fileNameWithExt: string): string {
function imageContentTypeFromFileName(fileName: string): string { function imageContentTypeFromFileName(fileName: string): string {
const ext = fileName.split('.').pop()?.toLowerCase() || ''; 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 // 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 sourceParam = request.nextUrl.searchParams.get('source');
const rawQuality = request.nextUrl.searchParams.get('quality'); const rawQuality = request.nextUrl.searchParams.get('quality');
const isPrepareOnly = request.nextUrl.searchParams.get('prepare') === '1'; const isPrepareOnly = request.nextUrl.searchParams.get('prepare') === '1';
@@ -42,6 +42,7 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
} }
let shouldDeleteImageObject = false; let shouldDeleteImageObject = false;
let shouldDeleteAudioObject = false;
await db.$transaction(async (tx) => { await db.$transaction(async (tx) => {
await tx.videoAsset.delete({ where: { id: asset.id } }); 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; 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<ReturnType<typeof deleteMediaFilesBestEffort>> | undefined; let r2CleanupResult: Awaited<ReturnType<typeof deleteMediaFilesBestEffort>> | undefined;
if (asset.provider === VideoAssetProvider.R2_IMAGE && shouldDeleteImageObject) { if (asset.provider === VideoAssetProvider.R2_IMAGE && shouldDeleteImageObject) {
r2CleanupResult = await deleteMediaFilesBestEffort([asset.sourceUrl]); r2CleanupResult = await deleteMediaFilesBestEffort([asset.sourceUrl]);
} }
if (asset.provider === VideoAssetProvider.R2_AUDIO && shouldDeleteAudioObject) {
r2CleanupResult = await deleteMediaFilesBestEffort([asset.sourceUrl]);
}
let bunnyCleanupResult: Awaited<ReturnType<typeof cleanupBunnyStreamVideosBestEffort>> | undefined; let bunnyCleanupResult: Awaited<ReturnType<typeof cleanupBunnyStreamVideosBestEffort>> | undefined;
if (asset.provider === VideoAssetProvider.BUNNY && asset.providerVideoId) { if (asset.provider === VideoAssetProvider.BUNNY && asset.providerVideoId) {
+43 -4
View File
@@ -15,9 +15,12 @@ import { resolveServerBunnyCdnHostname } from '@/lib/bunny-cdn';
import { import {
SAFE_BUNNY_VIDEO_ID, SAFE_BUNNY_VIDEO_ID,
SAFE_IMAGE_PROXY_PATH, SAFE_IMAGE_PROXY_PATH,
SAFE_AUDIO_PROXY_PATH,
canDeleteAssetForViewer, canDeleteAssetForViewer,
extractImageFileNameFromProxyUrl, extractImageFileNameFromProxyUrl,
extractImageKeyFromProxyUrl, extractImageKeyFromProxyUrl,
extractAudioKeyFromProxyUrl,
extractAudioFileNameFromProxyUrl,
getVideoAssetAccessContext, getVideoAssetAccessContext,
sanitizeAssetDisplayName, sanitizeAssetDisplayName,
} from '@/lib/video-assets'; } from '@/lib/video-assets';
@@ -32,7 +35,7 @@ const YOUTUBE_TITLE_CACHE_TTL_MS = 5 * 60 * 1000;
type AssetWithViewerFields = { type AssetWithViewerFields = {
id: string; id: string;
videoId: string; videoId: string;
kind: 'IMAGE' | 'VIDEO'; kind: 'IMAGE' | 'VIDEO' | 'AUDIO';
provider: VideoAssetProvider; provider: VideoAssetProvider;
displayName: string; displayName: string;
sourceUrl: string; sourceUrl: string;
@@ -159,6 +162,22 @@ async function isFreshImageAttachment(url: string): Promise<boolean> {
} }
} }
async function isFreshAudioAttachment(url: string): Promise<boolean> {
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 // GET /api/videos/[videoId]/assets
export async function GET(request: NextRequest, { params }: RouteParams) { export async function GET(request: NextRequest, { params }: RouteParams) {
try { try {
@@ -225,7 +244,8 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
const response = successResponse({ const response = successResponse({
assets: pagedAssets.map((asset) => shapeAssetForViewer( assets: pagedAssets.map((asset) => shapeAssetForViewer(
asset, 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 includeDeleteMetadata ? canDeleteAssetForViewer(asset, context) : false
)), )),
pagination: { pagination: {
@@ -259,7 +279,12 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
const body = await request.json().catch(() => null); const body = await request.json().catch(() => null);
const provider = typeof body?.provider === 'string' ? body.provider.trim().toUpperCase() : ''; 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'); return apiErrors.badRequest('Invalid provider');
} }
@@ -271,7 +296,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
let sourceUrl = ''; let sourceUrl = '';
let providerVideoId: string | null = null; let providerVideoId: string | null = null;
let thumbnailUrl: string | null = null; let thumbnailUrl: string | null = null;
let kind: 'IMAGE' | 'VIDEO' = 'IMAGE'; let kind: 'IMAGE' | 'VIDEO' | 'AUDIO' = 'IMAGE';
if (provider === VideoAssetProvider.R2_IMAGE) { if (provider === VideoAssetProvider.R2_IMAGE) {
sourceUrl = typeof body?.sourceUrl === 'string' ? body.sourceUrl.trim() : ''; sourceUrl = typeof body?.sourceUrl === 'string' ? body.sourceUrl.trim() : '';
@@ -288,6 +313,20 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
kind = 'IMAGE'; 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) { if (provider === VideoAssetProvider.YOUTUBE) {
sourceUrl = typeof body?.sourceUrl === 'string' ? body.sourceUrl.trim() : ''; sourceUrl = typeof body?.sourceUrl === 'string' ? body.sourceUrl.trim() : '';
const parsedSource = parseVideoUrl(sourceUrl); const parsedSource = parseVideoUrl(sourceUrl);
+5 -5
View File
@@ -1,7 +1,7 @@
'use client'; 'use client';
import { memo, type ReactNode } from 'react'; 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 { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { import {
@@ -103,15 +103,15 @@ export const AssetListSection = memo(function AssetListSection({
size="icon" size="icon"
variant="outline" variant="outline"
className="h-7 w-7" className="h-7 w-7"
title={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' : 'View image'} aria-label={asset.kind === 'VIDEO' ? 'Play video' : asset.kind === 'AUDIO' ? 'Play recording' : 'View image'}
onClick={() => onViewAsset(asset)} onClick={() => onViewAsset(asset)}
> >
{asset.kind === 'IMAGE' ? <ImageIcon className="h-3 w-3" /> : <Play className="h-3 w-3" />} {asset.kind === 'IMAGE' ? <ImageIcon className="h-3 w-3" /> : asset.kind === 'AUDIO' ? <Volume2 className="h-3 w-3" /> : <Play className="h-3 w-3" />}
</Button> </Button>
{canDownloadAssets && asset.provider !== 'YOUTUBE' && ( {canDownloadAssets && asset.provider !== 'YOUTUBE' && (
asset.provider === 'BUNNY' ? ( asset.provider === 'BUNNY' && asset.kind !== 'AUDIO' ? (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button
+358 -14
View File
@@ -1,9 +1,9 @@
'use client'; 'use client';
import { memo, useEffect, useMemo, useRef, useState } from 'react'; import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import * as tus from 'tus-js-client'; import * as tus from 'tus-js-client';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Download, FileVideo, Image as ImageIcon, Loader2, Play, UploadCloud, X, Youtube } from 'lucide-react'; import { Download, FileVideo, Image as ImageIcon, Loader2, Mic, Pause, Play, Square, UploadCloud, Volume2, X, Youtube } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@@ -20,7 +20,14 @@ import { BunnyPreviewPlayer, type BunnyPreviewPlayerHandle } from '@/components/
import { AssetListSection } from '@/components/video-page/asset-list-section'; import { AssetListSection } from '@/components/video-page/asset-list-section';
import type { VideoAsset } from '@/components/video-page/types'; import type { VideoAsset } from '@/components/video-page/types';
import { extractPastedImageFile, validateImageFile } from '@/components/video-page/image-upload-utils'; import { extractPastedImageFile, validateImageFile } from '@/components/video-page/image-upload-utils';
import { useCommentMedia } from '@/components/video-page/hooks/use-comment-media';
import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn'; import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn';
import { cn } from '@/lib/utils';
function formatTime(seconds: number): string {
const s = Math.floor(seconds);
return `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`;
}
interface AssetsPaneProps { interface AssetsPaneProps {
videoId: string; videoId: string;
@@ -31,9 +38,9 @@ interface AssetsPaneProps {
activeDownloadAssetId: string | null; activeDownloadAssetId: string | null;
canUploadAssets: boolean; canUploadAssets: boolean;
canDownloadAssets: boolean; canDownloadAssets: boolean;
getGuestUploadToken: (intent: 'image') => Promise<string | null>; getGuestUploadToken: (intent: 'image' | 'audio') => Promise<string | null>;
createAsset: (payload: { createAsset: (payload: {
provider: 'R2_IMAGE' | 'YOUTUBE' | 'BUNNY'; provider: 'R2_IMAGE' | 'YOUTUBE' | 'BUNNY' | 'R2_AUDIO';
displayName?: string; displayName?: string;
sourceUrl: string; sourceUrl: string;
providerVideoId?: string; providerVideoId?: string;
@@ -68,7 +75,7 @@ export const AssetsPane = memo(function AssetsPane({
highlightedAssetId, highlightedAssetId,
onHighlightedAssetHandled, onHighlightedAssetHandled,
}: AssetsPaneProps) { }: AssetsPaneProps) {
const [uploadTab, setUploadTab] = useState<'image' | 'youtube' | 'bunny'>('image'); const [uploadTab, setUploadTab] = useState<'image' | 'youtube' | 'bunny' | 'voice'>('image');
const [imageTitle, setImageTitle] = useState(''); const [imageTitle, setImageTitle] = useState('');
const [pendingImageFile, setPendingImageFile] = useState<File | null>(null); const [pendingImageFile, setPendingImageFile] = useState<File | null>(null);
const [youtubeUrl, setYoutubeUrl] = useState(''); const [youtubeUrl, setYoutubeUrl] = useState('');
@@ -91,6 +98,25 @@ export const AssetsPane = memo(function AssetsPane({
const imageInputRef = useRef<HTMLInputElement>(null); const imageInputRef = useRef<HTMLInputElement>(null);
const bunnyInputRef = useRef<HTMLInputElement>(null); const bunnyInputRef = useRef<HTMLInputElement>(null);
// Voice recording state
const [voiceTitle, setVoiceTitle] = useState('');
const [isRecording, setIsRecording] = useState(false);
const [recordingTime, setRecordingTime] = useState(0);
const [audioBlob, setAudioBlob] = useState<Blob | null>(null);
const [audioBlobUrl, setAudioBlobUrl] = useState<string | null>(null);
const [pendingAudioFile, setPendingAudioFile] = useState<File | null>(null);
const [isUploadingVoice, setIsUploadingVoice] = useState(false);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const audioChunksRef = useRef<BlobPart[]>([]);
const recordingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Drag-drop state
const [isDragOver, setIsDragOver] = useState(false);
const dragCounterRef = useRef(0);
// Audio playback for asset preview dialog and recording preview
const { playingVoiceId, voiceProgress, voiceCurrentTime, voicePlaybackRate, playVoice, stopVoice, toggleVoiceSpeed } = useCommentMedia();
const sortedAssets = useMemo(() => { const sortedAssets = useMemo(() => {
return [...assets].sort((a, b) => +new Date(b.createdAt) - +new Date(a.createdAt)); return [...assets].sort((a, b) => +new Date(b.createdAt) - +new Date(a.createdAt));
}, [assets]); }, [assets]);
@@ -227,7 +253,7 @@ export const AssetsPane = memo(function AssetsPane({
setBunnyProcessingByAssetId((prev) => (prev[selectedAsset.id] ? prev : { ...prev, [selectedAsset.id]: true })); setBunnyProcessingByAssetId((prev) => (prev[selectedAsset.id] ? prev : { ...prev, [selectedAsset.id]: true }));
}, [bunnyReadyByAssetId, selectedAsset]); }, [bunnyReadyByAssetId, selectedAsset]);
const handleImageUpload = async (file: File) => { const handleImageUpload = useCallback(async (file: File) => {
if (!file) return; if (!file) return;
const imageError = validateImageFile(file); const imageError = validateImageFile(file);
@@ -266,7 +292,7 @@ export const AssetsPane = memo(function AssetsPane({
console.error('Failed to upload image asset:', error); console.error('Failed to upload image asset:', error);
toast.error('Failed to upload image'); toast.error('Failed to upload image');
} }
}; }, [videoId, getGuestUploadToken, createAsset, imageTitle]);
const handleImageFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => { const handleImageFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]; const file = event.target.files?.[0];
@@ -307,9 +333,7 @@ export const AssetsPane = memo(function AssetsPane({
} }
}; };
const handleBunnyUpload = async (event: React.ChangeEvent<HTMLInputElement>) => { const handleBunnyFileUpload = useCallback(async (file: File) => {
const file = event.target.files?.[0];
if (!file) return;
if (!file.type.startsWith('video/')) { if (!file.type.startsWith('video/')) {
toast.error('Please select a video file'); toast.error('Please select a video file');
return; return;
@@ -403,6 +427,12 @@ export const AssetsPane = memo(function AssetsPane({
setIsUploadingBunny(false); setIsUploadingBunny(false);
setBunnyProgress(0); setBunnyProgress(0);
} }
}, [videoId, bunnyTitle, bunnyCdnHostname, createAsset]);
const handleBunnyUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
await handleBunnyFileUpload(file);
}; };
const handleBunnyThumbnailError = (assetId: string) => { const handleBunnyThumbnailError = (assetId: string) => {
@@ -425,7 +455,161 @@ export const AssetsPane = memo(function AssetsPane({
}); });
}; };
const startRecording = useCallback(async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
? 'audio/webm;codecs=opus'
: 'audio/webm';
const recorder = new MediaRecorder(stream, { mimeType });
audioChunksRef.current = [];
recorder.ondataavailable = (e) => {
if (e.data.size > 0) audioChunksRef.current.push(e.data);
};
recorder.onstop = () => {
const blob = new Blob(audioChunksRef.current, { type: mimeType });
setAudioBlob(blob);
setAudioBlobUrl((prev) => {
if (prev) URL.revokeObjectURL(prev);
return URL.createObjectURL(blob);
});
stream.getTracks().forEach((t) => t.stop());
};
mediaRecorderRef.current = recorder;
recorder.start(100);
setIsRecording(true);
setRecordingTime(0);
recordingTimerRef.current = setInterval(() => setRecordingTime((t) => t + 1), 1000);
} catch {
toast.error('Could not access microphone');
}
}, []);
const stopRecording = useCallback(() => {
mediaRecorderRef.current?.stop();
mediaRecorderRef.current = null;
setIsRecording(false);
if (recordingTimerRef.current) {
clearInterval(recordingTimerRef.current);
recordingTimerRef.current = null;
}
}, []);
const cancelRecording = useCallback(() => {
mediaRecorderRef.current?.stop();
mediaRecorderRef.current?.stream?.getTracks().forEach((t) => t.stop());
mediaRecorderRef.current = null;
setIsRecording(false);
setRecordingTime(0);
setAudioBlob(null);
setAudioBlobUrl((prev) => { if (prev) URL.revokeObjectURL(prev); return null; });
setPendingAudioFile(null);
if (recordingTimerRef.current) {
clearInterval(recordingTimerRef.current);
recordingTimerRef.current = null;
}
}, []);
const handleVoiceUpload = useCallback(async () => {
const uploadSource = pendingAudioFile ?? audioBlob;
if (!uploadSource) return;
setIsUploadingVoice(true);
try {
const formData = new FormData();
if (pendingAudioFile) {
formData.append('audio', pendingAudioFile);
} else {
formData.append('audio', audioBlob!, 'recording.webm');
}
formData.append('videoId', videoId);
const guestUploadToken = await getGuestUploadToken('audio');
if (guestUploadToken) formData.append('uploadToken', guestUploadToken);
const uploadRes = await fetch('/api/upload/audio', {
method: 'POST',
body: formData,
});
const uploadPayload = (await uploadRes.json().catch(() => null)) as { data?: { url?: string }; error?: string } | null;
const uploadedUrl = uploadPayload?.data?.url;
if (!uploadRes.ok || !uploadedUrl) {
toast.error(uploadPayload?.error || 'Failed to upload voice recording');
return;
}
const fallbackName = pendingAudioFile
? pendingAudioFile.name.replace(/\.[^/.]+$/, '')
: 'Voice Recording';
await createAsset({
provider: 'R2_AUDIO',
sourceUrl: uploadedUrl,
displayName: voiceTitle.trim() || fallbackName,
});
setVoiceTitle('');
setAudioBlob(null);
setAudioBlobUrl((prev) => { if (prev) URL.revokeObjectURL(prev); return null; });
setPendingAudioFile(null);
} catch {
toast.error('Failed to upload voice recording');
} finally {
setIsUploadingVoice(false);
}
}, [pendingAudioFile, audioBlob, videoId, getGuestUploadToken, createAsset, voiceTitle]);
const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault();
if (!canUploadAssets) return;
dragCounterRef.current += 1;
if (e.dataTransfer.types.includes('Files')) setIsDragOver(true);
}, [canUploadAssets]);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
dragCounterRef.current -= 1;
if (dragCounterRef.current === 0) setIsDragOver(false);
}, []);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
}, []);
const handleDrop = useCallback(async (e: React.DragEvent) => {
e.preventDefault();
dragCounterRef.current = 0;
setIsDragOver(false);
if (!canUploadAssets) return;
const file = Array.from(e.dataTransfer.files)[0];
if (!file) return;
if (file.type.startsWith('image/')) {
const imageError = validateImageFile(file);
if (imageError) { toast.error(imageError); return; }
// Stage the file so the user can optionally set a name before uploading
setUploadTab('image');
setPendingImageFile(file);
} else if (file.type.startsWith('video/')) {
// Videos upload immediately (large files, no staging)
setUploadTab('bunny');
await handleBunnyFileUpload(file);
} else if (file.type.startsWith('audio/')) {
// Stage the file so the user can optionally set a name before uploading
setUploadTab('voice');
setPendingAudioFile(file);
} else {
toast.error('Unsupported file type. Drop an image, video, or audio file.');
}
}, [canUploadAssets, handleBunnyFileUpload]);
const renderAssetPreview = (asset: VideoAsset) => { const renderAssetPreview = (asset: VideoAsset) => {
if (asset.kind === 'AUDIO') {
return (
<div className="h-24 w-36 rounded border bg-muted flex flex-col items-center justify-center gap-1">
<Volume2 className="h-6 w-6 text-muted-foreground" />
<span className="text-[10px] text-muted-foreground font-medium">Voice Recording</span>
</div>
);
}
if (asset.kind === 'IMAGE') { if (asset.kind === 'IMAGE') {
const imageSrc = asset.thumbnailUrl || asset.sourceUrl; const imageSrc = asset.thumbnailUrl || asset.sourceUrl;
return ( return (
@@ -501,6 +685,10 @@ export const AssetsPane = memo(function AssetsPane({
setPreviewImageTitle(asset.displayName); setPreviewImageTitle(asset.displayName);
return; return;
} }
if (asset.kind === 'AUDIO') {
setSelectedAsset(asset);
return;
}
if (asset.provider === 'BUNNY' && !bunnyReadyByAssetId[asset.id]) { if (asset.provider === 'BUNNY' && !bunnyReadyByAssetId[asset.id]) {
setBunnyProcessingByAssetId((prev) => (prev[asset.id] ? prev : { ...prev, [asset.id]: true })); setBunnyProcessingByAssetId((prev) => (prev[asset.id] ? prev : { ...prev, [asset.id]: true }));
} }
@@ -513,7 +701,14 @@ export const AssetsPane = memo(function AssetsPane({
: false; : false;
return ( return (
<div className="space-y-4" onPaste={handleImagePaste}> <div
className="space-y-4"
onPaste={handleImagePaste}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="font-medium">Assets</span> <span className="font-medium">Assets</span>
@@ -522,12 +717,19 @@ export const AssetsPane = memo(function AssetsPane({
</div> </div>
{canUploadAssets ? ( {canUploadAssets ? (
<div className="rounded-lg border p-3 space-y-3"> <div className={cn('rounded-lg border p-3 space-y-3 relative transition-colors', isDragOver && 'border-primary bg-primary/5')}>
<Tabs value={uploadTab} onValueChange={(value) => setUploadTab(value as 'image' | 'youtube' | 'bunny')}> {isDragOver && (
<TabsList className="grid w-full grid-cols-3"> <div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-2 rounded-lg bg-primary/10 border-2 border-dashed border-primary pointer-events-none">
<UploadCloud className="h-8 w-8 text-primary" />
<span className="text-sm font-medium text-primary">Drop to upload</span>
</div>
)}
<Tabs value={uploadTab} onValueChange={(value) => setUploadTab(value as 'image' | 'youtube' | 'bunny' | 'voice')}>
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="image">Image</TabsTrigger> <TabsTrigger value="image">Image</TabsTrigger>
<TabsTrigger value="youtube">YouTube</TabsTrigger> <TabsTrigger value="youtube">YouTube</TabsTrigger>
<TabsTrigger value="bunny">Video</TabsTrigger> <TabsTrigger value="bunny">Video</TabsTrigger>
<TabsTrigger value="voice">Voice</TabsTrigger>
</TabsList> </TabsList>
</Tabs> </Tabs>
@@ -636,6 +838,111 @@ export const AssetsPane = memo(function AssetsPane({
)} )}
</div> </div>
)} )}
{uploadTab === 'voice' && (
<div className="space-y-2">
<Input
placeholder="Optional name for this recording"
value={voiceTitle}
onChange={(e) => setVoiceTitle(e.target.value)}
disabled={isRecording || isUploadingVoice}
/>
{pendingAudioFile ? (
<div className="space-y-2">
<div className="rounded-md border px-2 py-1.5 text-xs flex items-center justify-between gap-2">
<span className="truncate">Attached: {pendingAudioFile.name}</span>
<Button
type="button"
size="sm"
variant="ghost"
className="h-6 px-2"
onClick={() => setPendingAudioFile(null)}
>
Clear
</Button>
</div>
<Button
className="w-full"
disabled={isUploadingVoice || isCreatingAsset}
onClick={handleVoiceUpload}
>
{isUploadingVoice ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <UploadCloud className="h-4 w-4 mr-2" />}
{isUploadingVoice ? 'Uploading...' : 'Upload File'}
</Button>
</div>
) : isRecording ? (
<div className="flex items-center gap-2">
<div className="flex-1 flex items-center gap-2 rounded-md border px-3 py-2 text-sm">
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-red-500" />
</span>
<span className="text-red-500 font-medium">Recording</span>
<span className="ml-auto tabular-nums text-muted-foreground">
{String(Math.floor(recordingTime / 60)).padStart(2, '0')}:{String(recordingTime % 60).padStart(2, '0')}
</span>
</div>
<Button size="icon" variant="outline" className="h-9 w-9 shrink-0" title="Stop recording" onClick={stopRecording}>
<Square className="h-3.5 w-3.5 fill-current" />
</Button>
<Button size="icon" variant="ghost" className="h-9 w-9 shrink-0" title="Cancel recording" onClick={cancelRecording}>
<X className="h-4 w-4" />
</Button>
</div>
) : audioBlob ? (
<div className="space-y-2">
<div className="flex items-center gap-2 p-2 bg-muted rounded">
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
onClick={() => audioBlobUrl && playVoice('recording-preview', audioBlobUrl, recordingTime)}
>
{playingVoiceId === 'recording-preview' ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
</Button>
<div className="flex-1 h-2 bg-primary/20 rounded-full overflow-hidden">
<div
className="h-full bg-primary rounded-full"
style={{ width: playingVoiceId === 'recording-preview' ? `${voiceProgress}%` : '0%' }}
/>
</div>
<span className="text-xs text-muted-foreground tabular-nums shrink-0">
{playingVoiceId === 'recording-preview'
? `${formatTime(voiceCurrentTime)} / ${formatTime(recordingTime)}`
: formatTime(recordingTime)}
</span>
{playingVoiceId === 'recording-preview' && (
<button
onClick={toggleVoiceSpeed}
className="text-[10px] font-bold px-1 py-0.5 rounded bg-muted hover:bg-muted-foreground/20 tabular-nums shrink-0"
>
{voicePlaybackRate}x
</button>
)}
</div>
<div className="flex gap-2">
<Button
className="flex-1"
disabled={isUploadingVoice || isCreatingAsset}
onClick={handleVoiceUpload}
>
{isUploadingVoice ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <UploadCloud className="h-4 w-4 mr-2" />}
{isUploadingVoice ? 'Uploading...' : 'Upload Recording'}
</Button>
<Button variant="outline" size="icon" className="h-9 w-9 shrink-0" title="Discard and re-record" onClick={cancelRecording}>
<X className="h-4 w-4" />
</Button>
</div>
</div>
) : (
<Button variant="outline" className="w-full" onClick={startRecording} disabled={isUploadingVoice || isCreatingAsset}>
<Mic className="h-4 w-4 mr-2" />
Start Recording
</Button>
)}
<p className="text-xs text-muted-foreground">Or drag an audio file anywhere onto this panel.</p>
</div>
)}
</div> </div>
) : ( ) : (
<div className="rounded-lg border p-3 text-xs text-muted-foreground"> <div className="rounded-lg border p-3 text-xs text-muted-foreground">
@@ -672,6 +979,43 @@ export const AssetsPane = memo(function AssetsPane({
}} }}
/> />
<Dialog open={selectedAsset?.kind === 'AUDIO'} onOpenChange={(open) => { if (!open) { stopVoice(); setSelectedAsset(null); } }}>
<DialogContent className="max-w-sm">
<DialogTitle>{selectedAsset?.displayName || 'Voice Recording'}</DialogTitle>
{selectedAsset?.sourceUrl ? (
<div className="flex items-center gap-2 p-2 bg-muted rounded">
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
onClick={() => selectedAsset.sourceUrl && playVoice(selectedAsset.id, selectedAsset.sourceUrl)}
>
{playingVoiceId === selectedAsset?.id ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
</Button>
<div className="flex-1 h-2 bg-primary/20 rounded-full overflow-hidden">
<div
className="h-full bg-primary rounded-full"
style={{ width: playingVoiceId === selectedAsset?.id ? `${voiceProgress}%` : '0%' }}
/>
</div>
<span className="text-xs text-muted-foreground tabular-nums shrink-0">
{playingVoiceId === selectedAsset?.id ? formatTime(voiceCurrentTime) : '00:00'}
</span>
{playingVoiceId === selectedAsset?.id && (
<button
onClick={toggleVoiceSpeed}
className="text-[10px] font-bold px-1 py-0.5 rounded bg-muted hover:bg-muted-foreground/20 tabular-nums shrink-0"
>
{voicePlaybackRate}x
</button>
)}
</div>
) : (
<p className="text-sm text-muted-foreground">Audio preview unavailable.</p>
)}
</DialogContent>
</Dialog>
<Dialog open={selectedAsset?.kind === 'VIDEO'} onOpenChange={(open) => !open && setSelectedAsset(null)}> <Dialog open={selectedAsset?.kind === 'VIDEO'} onOpenChange={(open) => !open && setSelectedAsset(null)}>
<DialogContent <DialogContent
showCloseButton={false} showCloseButton={false}
+6 -4
View File
@@ -1,7 +1,7 @@
'use client'; 'use client';
import React from 'react'; import React from 'react';
import { Image as ImageIcon, Video } from 'lucide-react'; import { Image as ImageIcon, Video, Volume2 } from 'lucide-react';
import type { VideoAsset } from '@/components/video-page/types'; import type { VideoAsset } from '@/components/video-page/types';
const URL_REGEX = /(https?:\/\/[^\s]+)/g; const URL_REGEX = /(https?:\/\/[^\s]+)/g;
@@ -50,7 +50,7 @@ export function CommentRichText({ text, onAssetMentionClick, assets = [] }: Comm
const assetId = match[2] || ''; const assetId = match[2] || '';
const matchedAsset = assets.find((asset) => asset.id === assetId); const matchedAsset = assets.find((asset) => asset.id === assetId);
const label = matchedAsset?.displayName || fallbackLabel; const label = matchedAsset?.displayName || fallbackLabel;
const isVideoAsset = matchedAsset?.kind === 'VIDEO'; const assetKind = matchedAsset?.kind;
nodes.push( nodes.push(
<button <button
@@ -65,12 +65,14 @@ export function CommentRichText({ text, onAssetMentionClick, assets = [] }: Comm
> >
<span <span
className={ className={
isVideoAsset assetKind === 'VIDEO'
? 'inline-flex h-4 w-4 shrink-0 items-center justify-center rounded bg-violet-500/25 text-violet-200' ? 'inline-flex h-4 w-4 shrink-0 items-center justify-center rounded bg-violet-500/25 text-violet-200'
: assetKind === 'AUDIO'
? 'inline-flex h-4 w-4 shrink-0 items-center justify-center rounded bg-blue-500/25 text-blue-200'
: 'inline-flex h-4 w-4 shrink-0 items-center justify-center rounded bg-emerald-500/25 text-emerald-200' : 'inline-flex h-4 w-4 shrink-0 items-center justify-center rounded bg-emerald-500/25 text-emerald-200'
} }
> >
{isVideoAsset ? <Video className="h-2.5 w-2.5" /> : <ImageIcon className="h-2.5 w-2.5" />} {assetKind === 'VIDEO' ? <Video className="h-2.5 w-2.5" /> : assetKind === 'AUDIO' ? <Volume2 className="h-2.5 w-2.5" /> : <ImageIcon className="h-2.5 w-2.5" />}
</span> </span>
<span className="truncate max-w-[190px] sm:max-w-[240px]"> <span className="truncate max-w-[190px] sm:max-w-[240px]">
@{label} @{label}
@@ -86,6 +86,17 @@ export function useCommentMedia() {
void audio.play(); void audio.play();
}, [playingVoiceId, voicePlaybackRate, startVoiceTracking, stopVoiceTracking]); }, [playingVoiceId, voicePlaybackRate, startVoiceTracking, stopVoiceTracking]);
const stopVoice = useCallback(() => {
if (audioPlayerRef.current) {
audioPlayerRef.current.pause();
audioPlayerRef.current = null;
}
stopVoiceTracking();
setPlayingVoiceId(null);
setVoiceProgress(0);
setVoiceCurrentTime(0);
}, [stopVoiceTracking]);
const toggleVoiceSpeed = useCallback(() => { const toggleVoiceSpeed = useCallback(() => {
setVoicePlaybackRate((prev) => { setVoicePlaybackRate((prev) => {
const next = prev === 1 ? 2 : 1; const next = prev === 1 ? 2 : 1;
@@ -112,6 +123,7 @@ export function useCommentMedia() {
voiceCurrentTime, voiceCurrentTime,
voicePlaybackRate, voicePlaybackRate,
playVoice, playVoice,
stopVoice,
toggleVoiceSpeed, toggleVoiceSpeed,
}; };
} }
@@ -7,7 +7,7 @@ import type { VideoAsset } from '@/components/video-page/types';
type BunnyDownloadPreference = 'original' | 'compressed'; type BunnyDownloadPreference = 'original' | 'compressed';
type CreateAssetPayload = { type CreateAssetPayload = {
provider: 'R2_IMAGE' | 'YOUTUBE' | 'BUNNY'; provider: 'R2_IMAGE' | 'YOUTUBE' | 'BUNNY' | 'R2_AUDIO';
displayName?: string; displayName?: string;
sourceUrl: string; sourceUrl: string;
providerVideoId?: string; providerVideoId?: string;
@@ -240,7 +240,7 @@ export function useVideoAssets({
} }
}, [canDownloadAssets, videoId]); }, [canDownloadAssets, videoId]);
const getGuestUploadToken = useCallback(async (intent: 'image') => { const getGuestUploadToken = useCallback(async (intent: 'image' | 'audio') => {
if (isAuthenticated) return null; if (isAuthenticated) return null;
const response = await fetch(`/api/watch/${videoId}/upload-token`, { const response = await fetch(`/api/watch/${videoId}/upload-token`, {
method: 'POST', method: 'POST',
+2 -2
View File
@@ -21,8 +21,8 @@ export interface CommentTag {
export interface VideoAsset { export interface VideoAsset {
id: string; id: string;
videoId: string; videoId: string;
kind: 'IMAGE' | 'VIDEO'; kind: 'IMAGE' | 'VIDEO' | 'AUDIO';
provider: 'R2_IMAGE' | 'YOUTUBE' | 'BUNNY'; provider: 'R2_IMAGE' | 'YOUTUBE' | 'BUNNY' | 'R2_AUDIO';
displayName: string; displayName: string;
sourceUrl: string | null; sourceUrl: string | null;
providerVideoId: string | null; providerVideoId: string | null;
+29 -2
View File
@@ -257,7 +257,7 @@ export async function getCachedUserMediaStorage(): Promise<Record<string, { tota
const snapshot = await getR2StorageSnapshot(); const snapshot = await getR2StorageSnapshot();
const seenKeys = new Set<string>(); const seenKeys = new Set<string>();
const [mediaComments, mediaAssets] = await Promise.all([ const [mediaComments, imageAssets, audioAssets] = await Promise.all([
db.comment.findMany({ db.comment.findMany({
where: { OR: [{ voiceUrl: { not: null } }, { imageUrl: { not: null } }] }, where: { OR: [{ voiceUrl: { not: null } }, { imageUrl: { not: null } }] },
select: { select: {
@@ -287,6 +287,13 @@ export async function getCachedUserMediaStorage(): Promise<Record<string, { tota
billedUserId: true, billedUserId: true,
}, },
}), }),
db.videoAsset.findMany({
where: { provider: 'R2_AUDIO' },
select: {
sourceUrl: true,
billedUserId: true,
},
}),
]); ]);
for (const comment of mediaComments) { for (const comment of mediaComments) {
@@ -324,7 +331,7 @@ export async function getCachedUserMediaStorage(): Promise<Record<string, { tota
} }
} }
for (const asset of mediaAssets) { for (const asset of imageAssets) {
const billedUserId = asset.billedUserId; const billedUserId = asset.billedUserId;
if (!billedUserId) continue; if (!billedUserId) continue;
if (!userStorage[billedUserId]) { if (!userStorage[billedUserId]) {
@@ -343,6 +350,26 @@ export async function getCachedUserMediaStorage(): Promise<Record<string, { tota
userStorage[billedUserId].image += size; userStorage[billedUserId].image += size;
userStorage[billedUserId].total += size; userStorage[billedUserId].total += size;
} }
for (const asset of audioAssets) {
const billedUserId = asset.billedUserId;
if (!billedUserId) continue;
if (!userStorage[billedUserId]) {
userStorage[billedUserId] = { total: 0, voice: 0, image: 0 };
}
const keyParts = asset.sourceUrl.split('/');
const filename = keyParts[keyParts.length - 1];
if (!filename) continue;
const r2Key = `voice/${filename}`;
const dedupeKey = `${billedUserId}:${r2Key}`;
if (seenKeys.has(dedupeKey)) continue;
seenKeys.add(dedupeKey);
const size = snapshot.fileSizes.get(r2Key) || 0;
userStorage[billedUserId].voice += size;
userStorage[billedUserId].total += size;
}
} catch (err) { } catch (err) {
console.error('Failed to parse user storage:', err); console.error('Failed to parse user storage:', err);
} }
+14
View File
@@ -10,6 +10,7 @@ const IMAGE_PROXY_PREFIX = '/api/upload/image/';
const AUDIO_PROXY_PREFIX = '/api/upload/audio/'; const AUDIO_PROXY_PREFIX = '/api/upload/audio/';
export const SAFE_IMAGE_PROXY_PATH = /^\/api\/upload\/image\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.[a-z0-9]+$/i; export const SAFE_IMAGE_PROXY_PATH = /^\/api\/upload\/image\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.[a-z0-9]+$/i;
export const SAFE_AUDIO_PROXY_PATH = /^\/api\/upload\/audio\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.[a-z0-9]+$/i;
export const SAFE_BUNNY_VIDEO_ID = /^[A-Za-z0-9_-]{8,128}$/; export const SAFE_BUNNY_VIDEO_ID = /^[A-Za-z0-9_-]{8,128}$/;
export type VideoAssetAccessContext = { export type VideoAssetAccessContext = {
@@ -61,6 +62,19 @@ export function extractImageFileNameFromProxyUrl(url: string): string | null {
return filename || null; return filename || null;
} }
export function extractAudioKeyFromProxyUrl(url: string): string | null {
if (!SAFE_AUDIO_PROXY_PATH.test(url)) return null;
const filename = url.slice(AUDIO_PROXY_PREFIX.length);
if (!filename) return null;
return `voice/${filename}`;
}
export function extractAudioFileNameFromProxyUrl(url: string): string | null {
if (!SAFE_AUDIO_PROXY_PATH.test(url)) return null;
const filename = url.slice(AUDIO_PROXY_PREFIX.length);
return filename || null;
}
export function mediaUrlToR2Key(url: string): string | null { export function mediaUrlToR2Key(url: string): string | null {
if (url.includes(IMAGE_PROXY_PREFIX)) { if (url.includes(IMAGE_PROXY_PREFIX)) {
const filename = url.slice(url.indexOf(IMAGE_PROXY_PREFIX) + IMAGE_PROXY_PREFIX.length); const filename = url.slice(url.indexOf(IMAGE_PROXY_PREFIX) + IMAGE_PROXY_PREFIX.length);
@@ -0,0 +1,5 @@
-- AlterEnum
ALTER TYPE "VideoAssetKind" ADD VALUE 'AUDIO';
-- AlterEnum
ALTER TYPE "VideoAssetProvider" ADD VALUE 'R2_AUDIO';