Files
hoerdle/app/api/categorize/route.ts

194 lines
6.2 KiB
TypeScript

'use server';
import { PrismaClient } from '@prisma/client';
import { requireAdminAuth } from '@/lib/auth';
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) {
// Check authentication
const authError = await requireAdminAuth(request as any);
if (authError) return authError;
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 }
);
}
}