Feat: AI-powered genre categorization with OpenRouter
This commit is contained in:
164
app/api/categorize/route.ts
Normal file
164
app/api/categorize/route.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
'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';
|
||||
|
||||
interface CategorizeResult {
|
||||
songId: number;
|
||||
title: string;
|
||||
artist: string;
|
||||
assignedGenres: string[];
|
||||
}
|
||||
|
||||
export async function POST() {
|
||||
try {
|
||||
if (!OPENROUTER_API_KEY) {
|
||||
return Response.json(
|
||||
{ error: 'OPENROUTER_API_KEY not configured' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch all songs without genres
|
||||
const uncategorizedSongs = await prisma.song.findMany({
|
||||
where: {
|
||||
genres: {
|
||||
none: {}
|
||||
}
|
||||
},
|
||||
include: {
|
||||
genres: true
|
||||
}
|
||||
});
|
||||
|
||||
if (uncategorizedSongs.length === 0) {
|
||||
return Response.json({
|
||||
message: 'No uncategorized songs found',
|
||||
results: []
|
||||
});
|
||||
}
|
||||
|
||||
// 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
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
return Response.json({
|
||||
message: `Processed ${uncategorizedSongs.length} songs, categorized ${results.length}`,
|
||||
totalProcessed: uncategorizedSongs.length,
|
||||
totalCategorized: results.length,
|
||||
results
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Categorization error:', error);
|
||||
return Response.json(
|
||||
{ error: 'Failed to categorize songs' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user