Feat: AI-powered genre categorization with OpenRouter
This commit is contained in:
@@ -20,6 +20,11 @@ Eine Web-App inspiriert von Heardle, bei der Nutzer täglich einen Song anhand k
|
||||
- **PWA Support:** Installierbar als App auf Desktop und Mobilgeräten (Manifest & Icons).
|
||||
- **Persistenz:** Spielstatus wird lokal im Browser gespeichert.
|
||||
- **Benachrichtigungen:** Integration mit Gotify für Push-Nachrichten bei Spielabschluss.
|
||||
- **Genre-Management:**
|
||||
- Erstellen und Verwalten von Musik-Genres.
|
||||
- Manuelle Zuweisung von Genres zu Songs.
|
||||
- KI-gestützte automatische Kategorisierung mit OpenRouter (Claude 3.5 Haiku).
|
||||
- Genre-spezifische tägliche Rätsel.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
@@ -68,6 +73,7 @@ Das Projekt ist für den Betrieb mit Docker optimiert.
|
||||
- `TZ`: Zeitzone für täglichen Puzzle-Wechsel (Standard: `Europe/Berlin`)
|
||||
- `GOTIFY_URL`: URL deines Gotify Servers (z.B. `https://gotify.example.com`)
|
||||
- `GOTIFY_APP_TOKEN`: App Token für Gotify (z.B. `A...`)
|
||||
- `OPENROUTER_API_KEY`: API-Key für OpenRouter (für KI-Kategorisierung, optional)
|
||||
|
||||
2. **Starten:**
|
||||
```bash
|
||||
|
||||
@@ -2,6 +2,14 @@
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
interface Genre {
|
||||
id: number;
|
||||
name: string;
|
||||
_count?: {
|
||||
songs: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface Song {
|
||||
id: number;
|
||||
title: string;
|
||||
@@ -9,6 +17,7 @@ interface Song {
|
||||
filename: string;
|
||||
createdAt: string;
|
||||
activations: number;
|
||||
genres: Genre[];
|
||||
}
|
||||
|
||||
type SortField = 'id' | 'title' | 'artist' | 'createdAt';
|
||||
@@ -20,11 +29,22 @@ export default function AdminPage() {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [message, setMessage] = useState('');
|
||||
const [songs, setSongs] = useState<Song[]>([]);
|
||||
const [genres, setGenres] = useState<Genre[]>([]);
|
||||
const [newGenreName, setNewGenreName] = useState('');
|
||||
|
||||
// Edit state
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [editTitle, setEditTitle] = useState('');
|
||||
const [editArtist, setEditArtist] = useState('');
|
||||
const [editGenreIds, setEditGenreIds] = useState<number[]>([]);
|
||||
|
||||
// Post-upload state
|
||||
const [uploadedSong, setUploadedSong] = useState<Song | null>(null);
|
||||
const [uploadGenreIds, setUploadGenreIds] = useState<number[]>([]);
|
||||
|
||||
// AI Categorization state
|
||||
const [isCategorizing, setIsCategorizing] = useState(false);
|
||||
const [categorizationResults, setCategorizationResults] = useState<any>(null);
|
||||
|
||||
// Sort state
|
||||
const [sortField, setSortField] = useState<SortField>('artist');
|
||||
@@ -45,6 +65,7 @@ export default function AdminPage() {
|
||||
if (authToken === 'authenticated') {
|
||||
setIsAuthenticated(true);
|
||||
fetchSongs();
|
||||
fetchGenres();
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -57,6 +78,7 @@ export default function AdminPage() {
|
||||
localStorage.setItem('hoerdle_admin_auth', 'authenticated');
|
||||
setIsAuthenticated(true);
|
||||
fetchSongs();
|
||||
fetchGenres();
|
||||
} else {
|
||||
alert('Wrong password');
|
||||
}
|
||||
@@ -70,6 +92,69 @@ export default function AdminPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const fetchGenres = async () => {
|
||||
const res = await fetch('/api/genres');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setGenres(data);
|
||||
}
|
||||
};
|
||||
|
||||
const createGenre = async () => {
|
||||
if (!newGenreName.trim()) return;
|
||||
const res = await fetch('/api/genres', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name: newGenreName }),
|
||||
});
|
||||
if (res.ok) {
|
||||
setNewGenreName('');
|
||||
fetchGenres();
|
||||
} else {
|
||||
alert('Failed to create genre');
|
||||
}
|
||||
};
|
||||
|
||||
const deleteGenre = async (id: number) => {
|
||||
if (!confirm('Delete this genre?')) return;
|
||||
const res = await fetch('/api/genres', {
|
||||
method: 'DELETE',
|
||||
body: JSON.stringify({ id }),
|
||||
});
|
||||
if (res.ok) {
|
||||
fetchGenres();
|
||||
} else {
|
||||
alert('Failed to delete genre');
|
||||
}
|
||||
};
|
||||
|
||||
const handleAICategorization = async () => {
|
||||
if (!confirm('This will categorize all songs without genres using AI. Continue?')) return;
|
||||
|
||||
setIsCategorizing(true);
|
||||
setCategorizationResults(null);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/categorize', {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setCategorizationResults(data);
|
||||
fetchSongs(); // Refresh song list
|
||||
fetchGenres(); // Refresh genre counts
|
||||
} else {
|
||||
const error = await res.json();
|
||||
alert(`Categorization failed: ${error.error || 'Unknown error'}`);
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Failed to categorize songs');
|
||||
console.error(error);
|
||||
} finally {
|
||||
setIsCategorizing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!file) return;
|
||||
@@ -104,29 +189,62 @@ export default function AdminPage() {
|
||||
|
||||
setMessage(statusMessage);
|
||||
setFile(null);
|
||||
setUploadedSong(data.song);
|
||||
setUploadGenreIds([]); // Reset selection
|
||||
fetchSongs();
|
||||
} else {
|
||||
setMessage('Upload failed.');
|
||||
}
|
||||
};
|
||||
|
||||
const saveUploadedSongGenres = async () => {
|
||||
if (!uploadedSong) return;
|
||||
|
||||
const res = await fetch('/api/songs', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
id: uploadedSong.id,
|
||||
title: uploadedSong.title,
|
||||
artist: uploadedSong.artist,
|
||||
genreIds: uploadGenreIds
|
||||
}),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setUploadedSong(null);
|
||||
setUploadGenreIds([]);
|
||||
fetchSongs();
|
||||
setMessage(prev => prev + '\n✅ Genres assigned successfully!');
|
||||
} else {
|
||||
alert('Failed to assign genres');
|
||||
}
|
||||
};
|
||||
|
||||
const startEditing = (song: Song) => {
|
||||
setEditingId(song.id);
|
||||
setEditTitle(song.title);
|
||||
setEditArtist(song.artist);
|
||||
setEditGenreIds(song.genres.map(g => g.id));
|
||||
};
|
||||
|
||||
const cancelEditing = () => {
|
||||
setEditingId(null);
|
||||
setEditTitle('');
|
||||
setEditArtist('');
|
||||
setEditGenreIds([]);
|
||||
};
|
||||
|
||||
const saveEditing = async (id: number) => {
|
||||
const res = await fetch('/api/songs', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id, title: editTitle, artist: editArtist }),
|
||||
body: JSON.stringify({
|
||||
id,
|
||||
title: editTitle,
|
||||
artist: editArtist,
|
||||
genreIds: editGenreIds
|
||||
}),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
@@ -255,6 +373,124 @@ export default function AdminPage() {
|
||||
<div className="admin-container">
|
||||
<h1 className="title" style={{ marginBottom: '2rem' }}>Admin Dashboard</h1>
|
||||
|
||||
{/* Genre Management */}
|
||||
<div className="admin-card" style={{ marginBottom: '2rem' }}>
|
||||
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem' }}>Manage Genres</h2>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1rem' }}>
|
||||
<input
|
||||
type="text"
|
||||
value={newGenreName}
|
||||
onChange={e => setNewGenreName(e.target.value)}
|
||||
placeholder="New Genre Name"
|
||||
className="form-input"
|
||||
style={{ maxWidth: '200px' }}
|
||||
/>
|
||||
<button onClick={createGenre} className="btn-primary">Add Genre</button>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
|
||||
{genres.map(genre => (
|
||||
<div key={genre.id} style={{
|
||||
background: '#f3f4f6',
|
||||
padding: '0.25rem 0.75rem',
|
||||
borderRadius: '999px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
fontSize: '0.875rem'
|
||||
}}>
|
||||
<span>{genre.name} ({genre._count?.songs || 0})</span>
|
||||
<button
|
||||
onClick={() => deleteGenre(genre.id)}
|
||||
style={{ border: 'none', background: 'none', cursor: 'pointer', color: '#ef4444', fontWeight: 'bold' }}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* AI Categorization */}
|
||||
<div style={{ marginTop: '1.5rem', paddingTop: '1rem', borderTop: '1px solid #e5e7eb' }}>
|
||||
<button
|
||||
onClick={handleAICategorization}
|
||||
disabled={isCategorizing || genres.length === 0}
|
||||
className="btn-primary"
|
||||
style={{
|
||||
opacity: isCategorizing || genres.length === 0 ? 0.5 : 1,
|
||||
cursor: isCategorizing || genres.length === 0 ? 'not-allowed' : 'pointer'
|
||||
}}
|
||||
>
|
||||
{isCategorizing ? '🤖 Categorizing...' : '🤖 Auto-Categorize Songs with AI'}
|
||||
</button>
|
||||
{genres.length === 0 && (
|
||||
<p style={{ fontSize: '0.875rem', color: '#666', marginTop: '0.5rem' }}>
|
||||
Please create at least one genre first.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Categorization Results */}
|
||||
{categorizationResults && (
|
||||
<div style={{
|
||||
marginTop: '1rem',
|
||||
padding: '1rem',
|
||||
background: '#f0fdf4',
|
||||
border: '1px solid #86efac',
|
||||
borderRadius: '0.5rem'
|
||||
}}>
|
||||
<h3 style={{ fontWeight: 'bold', marginBottom: '0.5rem', color: '#166534' }}>
|
||||
✅ Categorization Complete
|
||||
</h3>
|
||||
<p style={{ fontSize: '0.875rem', marginBottom: '0.5rem' }}>
|
||||
{categorizationResults.message}
|
||||
</p>
|
||||
{categorizationResults.results && categorizationResults.results.length > 0 && (
|
||||
<div style={{ marginTop: '1rem' }}>
|
||||
<p style={{ fontWeight: 'bold', fontSize: '0.875rem', marginBottom: '0.5rem' }}>Updated Songs:</p>
|
||||
<div style={{ maxHeight: '300px', overflowY: 'auto' }}>
|
||||
{categorizationResults.results.map((result: any) => (
|
||||
<div key={result.songId} style={{
|
||||
padding: '0.5rem',
|
||||
background: 'white',
|
||||
borderRadius: '0.25rem',
|
||||
marginBottom: '0.5rem',
|
||||
fontSize: '0.875rem'
|
||||
}}>
|
||||
<strong>{result.title}</strong> by {result.artist}
|
||||
<div style={{ display: 'flex', gap: '0.25rem', marginTop: '0.25rem', flexWrap: 'wrap' }}>
|
||||
{result.assignedGenres.map((genre: string) => (
|
||||
<span key={genre} style={{
|
||||
background: '#dbeafe',
|
||||
padding: '0.1rem 0.4rem',
|
||||
borderRadius: '0.25rem',
|
||||
fontSize: '0.75rem'
|
||||
}}>
|
||||
{genre}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setCategorizationResults(null)}
|
||||
style={{
|
||||
marginTop: '1rem',
|
||||
padding: '0.5rem 1rem',
|
||||
background: 'white',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '0.25rem',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="admin-card" style={{ marginBottom: '2rem' }}>
|
||||
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem' }}>Upload New Song</h2>
|
||||
<form onSubmit={handleUpload}>
|
||||
@@ -286,6 +522,41 @@ export default function AdminPage() {
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
|
||||
{/* Post-upload Genre Selection */}
|
||||
{uploadedSong && (
|
||||
<div style={{ marginTop: '1.5rem', padding: '1rem', background: '#eef2ff', borderRadius: '0.5rem', border: '1px solid #c7d2fe' }}>
|
||||
<h3 style={{ fontWeight: 'bold', marginBottom: '0.5rem' }}>Assign Genres to "{uploadedSong.title}"</h3>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem', marginBottom: '1rem' }}>
|
||||
{genres.map(genre => (
|
||||
<label key={genre.id} style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.25rem',
|
||||
background: 'white',
|
||||
padding: '0.25rem 0.5rem',
|
||||
borderRadius: '0.25rem',
|
||||
cursor: 'pointer',
|
||||
border: uploadGenreIds.includes(genre.id) ? '1px solid #4f46e5' : '1px solid #e5e7eb'
|
||||
}}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={uploadGenreIds.includes(genre.id)}
|
||||
onChange={e => {
|
||||
if (e.target.checked) {
|
||||
setUploadGenreIds([...uploadGenreIds, genre.id]);
|
||||
} else {
|
||||
setUploadGenreIds(uploadGenreIds.filter(id => id !== genre.id));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{genre.name}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<button onClick={saveUploadedSongGenres} className="btn-primary">Save Genres</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="admin-card">
|
||||
@@ -326,6 +597,7 @@ export default function AdminPage() {
|
||||
>
|
||||
Artist {sortField === 'artist' && (sortDirection === 'asc' ? '↑' : '↓')}
|
||||
</th>
|
||||
<th style={{ padding: '0.75rem' }}>Genres</th>
|
||||
<th
|
||||
style={{ padding: '0.75rem', cursor: 'pointer', userSelect: 'none' }}
|
||||
onClick={() => handleSort('createdAt')}
|
||||
@@ -361,6 +633,26 @@ export default function AdminPage() {
|
||||
style={{ padding: '0.25rem' }}
|
||||
/>
|
||||
</td>
|
||||
<td style={{ padding: '0.75rem' }}>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
|
||||
{genres.map(genre => (
|
||||
<label key={genre.id} style={{ display: 'flex', alignItems: 'center', gap: '0.25rem', fontSize: '0.75rem' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editGenreIds.includes(genre.id)}
|
||||
onChange={e => {
|
||||
if (e.target.checked) {
|
||||
setEditGenreIds([...editGenreIds, genre.id]);
|
||||
} else {
|
||||
setEditGenreIds(editGenreIds.filter(id => id !== genre.id));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{genre.name}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: '0.75rem', color: '#666', fontSize: '0.75rem' }}>
|
||||
{new Date(song.createdAt).toLocaleDateString('de-DE')}
|
||||
</td>
|
||||
@@ -388,6 +680,20 @@ export default function AdminPage() {
|
||||
<>
|
||||
<td style={{ padding: '0.75rem', fontWeight: 'bold' }}>{song.title}</td>
|
||||
<td style={{ padding: '0.75rem' }}>{song.artist}</td>
|
||||
<td style={{ padding: '0.75rem' }}>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
|
||||
{song.genres?.map(g => (
|
||||
<span key={g.id} style={{
|
||||
background: '#e5e7eb',
|
||||
padding: '0.1rem 0.4rem',
|
||||
borderRadius: '0.25rem',
|
||||
fontSize: '0.7rem'
|
||||
}}>
|
||||
{g.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: '0.75rem', color: '#666', fontSize: '0.75rem' }}>
|
||||
{new Date(song.createdAt).toLocaleDateString('de-DE')}
|
||||
</td>
|
||||
@@ -423,7 +729,7 @@ export default function AdminPage() {
|
||||
))}
|
||||
{paginatedSongs.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} style={{ padding: '1rem', textAlign: 'center', color: '#666' }}>
|
||||
<td colSpan={7} style={{ padding: '1rem', textAlign: 'center', color: '#666' }}>
|
||||
{searchQuery ? 'No songs found matching your search.' : 'No songs uploaded yet.'}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ services:
|
||||
- TZ=Europe/Berlin # Timezone for daily puzzle rotation
|
||||
- GOTIFY_URL=https://gotify.example.com
|
||||
- GOTIFY_APP_TOKEN=your_gotify_token
|
||||
- OPENROUTER_API_KEY=your_openrouter_api_key
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./public/uploads:/app/public/uploads
|
||||
|
||||
Reference in New Issue
Block a user