diff --git a/README.md b/README.md index 93736b2..be5b68a 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/admin/page.tsx b/app/admin/page.tsx index 563a9a9..9135916 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -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(null); const [message, setMessage] = useState(''); const [songs, setSongs] = useState([]); + const [genres, setGenres] = useState([]); + const [newGenreName, setNewGenreName] = useState(''); // Edit state const [editingId, setEditingId] = useState(null); const [editTitle, setEditTitle] = useState(''); const [editArtist, setEditArtist] = useState(''); + const [editGenreIds, setEditGenreIds] = useState([]); + + // Post-upload state + const [uploadedSong, setUploadedSong] = useState(null); + const [uploadGenreIds, setUploadGenreIds] = useState([]); + + // AI Categorization state + const [isCategorizing, setIsCategorizing] = useState(false); + const [categorizationResults, setCategorizationResults] = useState(null); // Sort state const [sortField, setSortField] = useState('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() {

Admin Dashboard

+ {/* Genre Management */} +
+

Manage Genres

+
+ setNewGenreName(e.target.value)} + placeholder="New Genre Name" + className="form-input" + style={{ maxWidth: '200px' }} + /> + +
+
+ {genres.map(genre => ( +
+ {genre.name} ({genre._count?.songs || 0}) + +
+ ))} +
+ + {/* AI Categorization */} +
+ + {genres.length === 0 && ( +

+ Please create at least one genre first. +

+ )} +
+ + {/* Categorization Results */} + {categorizationResults && ( +
+

+ ✅ Categorization Complete +

+

+ {categorizationResults.message} +

+ {categorizationResults.results && categorizationResults.results.length > 0 && ( +
+

Updated Songs:

+
+ {categorizationResults.results.map((result: any) => ( +
+ {result.title} by {result.artist} +
+ {result.assignedGenres.map((genre: string) => ( + + {genre} + + ))} +
+
+ ))} +
+
+ )} + +
+ )} +
+

Upload New Song

@@ -286,6 +522,41 @@ export default function AdminPage() {
)} + + {/* Post-upload Genre Selection */} + {uploadedSong && ( +
+

Assign Genres to "{uploadedSong.title}"

+
+ {genres.map(genre => ( + + ))} +
+ +
+ )}
@@ -326,6 +597,7 @@ export default function AdminPage() { > Artist {sortField === 'artist' && (sortDirection === 'asc' ? '↑' : '↓')} + Genres handleSort('createdAt')} @@ -361,6 +633,26 @@ export default function AdminPage() { style={{ padding: '0.25rem' }} /> + +
+ {genres.map(genre => ( + + ))} +
+ {new Date(song.createdAt).toLocaleDateString('de-DE')} @@ -388,6 +680,20 @@ export default function AdminPage() { <> {song.title} {song.artist} + +
+ {song.genres?.map(g => ( + + {g.name} + + ))} +
+ {new Date(song.createdAt).toLocaleDateString('de-DE')} @@ -423,7 +729,7 @@ export default function AdminPage() { ))} {paginatedSongs.length === 0 && ( - + {searchQuery ? 'No songs found matching your search.' : 'No songs uploaded yet.'} diff --git a/app/api/categorize/route.ts b/app/api/categorize/route.ts new file mode 100644 index 0000000..f9c2bd7 --- /dev/null +++ b/app/api/categorize/route.ts @@ -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 } + ); + } +} diff --git a/docker-compose.example.yml b/docker-compose.example.yml index b85cf08..10cccad 100644 --- a/docker-compose.example.yml +++ b/docker-compose.example.yml @@ -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