'use server'; import { PrismaClient } from '@prisma/client'; const prisma = new PrismaClient(); const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY; const OPENROUTER_MODEL = 'anthropic/claude-3.5-haiku'; const BATCH_SIZE = 20; // Process 20 songs at a time to avoid timeouts interface CategorizeResult { songId: number; title: string; artist: string; assignedGenres: string[]; } export async function POST(request: Request) { try { if (!OPENROUTER_API_KEY) { return Response.json( { error: 'OPENROUTER_API_KEY not configured' }, { status: 500 } ); } // Get offset from request body (for batch processing) const body = await request.json().catch(() => ({})); const offset = body.offset || 0; // Fetch all songs without genres const totalUncategorized = await prisma.song.count({ where: { genres: { none: {} } } }); if (totalUncategorized === 0) { return Response.json({ message: 'No uncategorized songs found', results: [], hasMore: false, totalUncategorized: 0, processed: 0 }); } // Fetch batch of songs const uncategorizedSongs = await prisma.song.findMany({ where: { genres: { none: {} } }, include: { genres: true }, take: BATCH_SIZE, skip: offset }); // Fetch all available genres const allGenres = await prisma.genre.findMany({ orderBy: { name: 'asc' } }); if (allGenres.length === 0) { return Response.json( { error: 'No genres available. Please create genres first.' }, { status: 400 } ); } const results: CategorizeResult[] = []; // Process each song in this batch for (const song of uncategorizedSongs) { try { const genreNames = allGenres.map(g => g.name); const prompt = `You are a music genre categorization assistant. Given a song title and artist, categorize it into 0-3 of the available genres. Song: "${song.title}" by ${song.artist} Available genres: ${genreNames.join(', ')} Rules: - Select 0-3 genres that best match this song - Only use genres from the available list - Respond with ONLY a JSON array of genre names, nothing else - If no genres match well, return an empty array [] Example response: ["Rock", "Alternative"] Your response:`; const response = await fetch('https://openrouter.ai/api/v1/chat/completions', { method: 'POST', headers: { 'Authorization': `Bearer ${OPENROUTER_API_KEY}`, 'Content-Type': 'application/json', 'HTTP-Referer': 'https://hoerdle.elpatron.me', 'X-Title': 'Hördle Genre Categorization' }, body: JSON.stringify({ model: OPENROUTER_MODEL, messages: [ { role: 'user', content: prompt } ], temperature: 0.3, max_tokens: 100 }) }); if (!response.ok) { console.error(`OpenRouter API error for song ${song.id}:`, await response.text()); continue; } const data = await response.json(); const aiResponse = data.choices?.[0]?.message?.content?.trim() || '[]'; // Parse AI response let suggestedGenreNames: string[] = []; try { suggestedGenreNames = JSON.parse(aiResponse); } catch (e) { console.error(`Failed to parse AI response for song ${song.id}:`, aiResponse); continue; } // Filter to only valid genres and get their IDs const genreIds = allGenres .filter(g => suggestedGenreNames.includes(g.name)) .map(g => g.id) .slice(0, 3); // Max 3 genres if (genreIds.length > 0) { // Update song with genres await prisma.song.update({ where: { id: song.id }, data: { genres: { connect: genreIds.map(id => ({ id })) } } }); results.push({ songId: song.id, title: song.title, artist: song.artist, assignedGenres: suggestedGenreNames.filter(name => allGenres.some(g => g.name === name) ) }); } } catch (error) { console.error(`Error processing song ${song.id}:`, error); continue; } } const newOffset = offset + BATCH_SIZE; const hasMore = newOffset < totalUncategorized; return Response.json({ message: `Processed ${uncategorizedSongs.length} songs in this batch, categorized ${results.length}`, totalUncategorized, processed: Math.min(newOffset, totalUncategorized), hasMore, nextOffset: hasMore ? newOffset : null, results }); } catch (error) { console.error('Categorization error:', error); return Response.json( { error: 'Failed to categorize songs' }, { status: 500 } ); } }