Compare commits

...

27 Commits

Author SHA1 Message Date
Hördle Bot
3e647cd44b Fix version API to read version.txt directly 2025-11-25 10:22:12 +01:00
Hördle Bot
54af256e91 feat: Enhance Docker build versioning with a build argument, fetch git tags during deployment, and add comprehensive deployment documentation. 2025-11-25 10:15:47 +01:00
Hördle Bot
ce413cf6bc feat: Implement Docker version reporting by extracting git tag to an environment variable for API consumption. 2025-11-25 09:41:50 +01:00
Hördle Bot
5102ca86cb feat: Add batch genre assignment functionality to song uploads, including UI for selection and post-upload API calls. 2025-11-25 09:34:55 +01:00
Hördle Bot
eb3d2c86d7 feat: Extract footer into a new component and add dynamic application version display via a new API route. 2025-11-25 09:20:01 +01:00
Hördle Bot
883875b82a docs: Update README with additional sortable library fields, enhanced sharing options, and genre activation/deactivation. 2025-11-25 00:33:07 +01:00
Hördle Bot
4c13817e77 feat: conditionally display 'Special' or 'Genre' for the genre text based on isSpecial flag 2025-11-25 00:29:31 +01:00
Hördle Bot
35fe5f2d44 feat: Add sorting by activations and average rating to admin page and include bonus star in game share text. 2025-11-25 00:27:08 +01:00
Hördle Bot
70501d626b feat: Add genre validation with 404 for inactive genres and filter genre list to active ones. 2025-11-25 00:23:05 +01:00
Hördle Bot
41ce6c12ce feat: Implement genre activation/deactivation with UI controls and main page filtering. 2025-11-25 00:20:29 +01:00
Hördle Bot
a744393335 feat: remove iTunes release year refresh API endpoint and UI from admin page 2025-11-25 00:09:28 +01:00
Hördle Bot
0ee3a48770 refactor: simplify year guessed display condition. 2025-11-25 00:06:32 +01:00
Hördle Bot
187774bce7 feat: Add NoGlobal feature to exclude songs from Global Daily Puzzle 2025-11-24 20:23:07 +01:00
Hördle Bot
67cf85dc22 feat(song): add option to exclude songs from global visibility and improve admin upload validation 2025-11-24 19:59:47 +01:00
Hördle Bot
326023a705 feat: remove MusicBrainz integration and exclusively use iTunes for song release years 2025-11-24 18:53:03 +01:00
Hördle Bot
41e2ec1495 feat: Add rate limiting and request serialization to iTunes API calls. 2025-11-24 18:47:25 +01:00
Hördle Bot
62402d7000 Remove cleanSearchTerm calls for artist and title from within the retry loop. 2025-11-24 15:39:45 +01:00
Hördle Bot
0599c066d9 feat: Log cleaned artist and title used for iTunes search. 2025-11-24 15:39:29 +01:00
Hördle Bot
f7de7f2684 feat: clean artist and title terms before iTunes search to improve result accuracy. 2025-11-24 15:37:45 +01:00
Hördle Bot
e5d06029ef feat: Add slow-refresh-itunes.js for robust iTunes year updates and remove migrate-covers.mjs from docker-compose. 2025-11-24 15:27:52 +01:00
Hördle Bot
e8e0aa27fb fix: update User-Agent and add Accept and Accept-Language headers for iTunes fetch. 2025-11-24 14:40:34 +01:00
Hördle Bot
7f455053e7 fix: Improve iTunes API call success rate by increasing rate limit delay and adding a User-Agent header. 2025-11-24 14:36:27 +01:00
Hördle Bot
3309b5c5ee feat: implement iTunes API for release year detection and bulk refresh 2025-11-24 14:23:07 +01:00
Hördle Bot
cd30476349 Fix bonus year question spoiler: hide release year until after bonus question 2025-11-24 10:33:27 +01:00
Hördle Bot
cd19a6c04d Reduce verbose logging in cover migration script 2025-11-24 09:58:49 +01:00
Hördle Bot
7011a24b46 Fix: Add missing fetchSpecials() calls to update special song counts 2025-11-24 09:56:56 +01:00
Hördle Bot
9a98830245 Add skip logic to migration scripts to prevent re-running 2025-11-24 09:50:19 +01:00
29 changed files with 1130 additions and 389 deletions

1
.gitignore vendored
View File

@@ -49,3 +49,4 @@ next-env.d.ts
!/public/uploads/.gitkeep
/data
.release-years-migrated
.covers-migrated

185
DEBUG_VERSION.md Normal file
View File

@@ -0,0 +1,185 @@
# Debug Version Display - Remote Server Checklist
## 1. Überprüfe Git-Tags auf dem Remote-Server
```bash
# Im Projekt-Verzeichnis auf dem Remote-Server
cd /path/to/hoerdle
# Zeige alle Tags
git tag -l
# Zeige aktuellen Tag/Version
git describe --tags --always
# Wenn keine Tags angezeigt werden:
git fetch --tags
git describe --tags --always
```
**Erwartetes Ergebnis:** Sollte `v0.1.0.2` oder ähnlich zeigen
---
## 2. Überprüfe die version.txt im Container
```bash
# Zeige den Inhalt der Version-Datei im laufenden Container
docker exec hoerdle cat /app/version.txt
# Sollte die Version zeigen, z.B. "v0.1.0.2"
```
**Erwartetes Ergebnis:** Die aktuelle Version, nicht "unknown" oder "dev"
---
## 3. Überprüfe die Umgebungsvariable im Container
```bash
# Zeige alle Umgebungsvariablen
docker exec hoerdle env | grep APP_VERSION
# Sollte APP_VERSION=v0.1.0.2 oder ähnlich zeigen
```
**Erwartetes Ergebnis:** `APP_VERSION=v0.1.0.2`
---
## 4. Überprüfe die Container-Logs beim Start
```bash
# Zeige die letzten Logs beim Container-Start
docker logs hoerdle | head -20
# Suche speziell nach Version-Ausgaben
docker logs hoerdle | grep -i version
```
**Erwartetes Ergebnis:** Eine Zeile wie "App version: v0.1.0.2"
---
## 5. Teste die API direkt
```bash
# Rufe die Version-API auf
curl http://localhost:3010/api/version
# Sollte JSON zurückgeben: {"version":"v0.1.0.2"}
```
**Erwartetes Ergebnis:** `{"version":"v0.1.0.2"}`
---
## 6. Überprüfe wann der Container gebaut wurde
```bash
# Zeige Image-Informationen
docker images | grep hoerdle
# Zeige detaillierte Container-Informationen
docker inspect hoerdle | grep -i created
```
**Wichtig:** Wenn das Image vor deinem letzten Deployment erstellt wurde, wurde es noch nicht neu gebaut!
---
## 7. Überprüfe Build-Logs
```bash
# Baue das Image neu und beobachte die Ausgabe
docker compose build --no-cache 2>&1 | tee build.log
# Suche nach der Version-Ausgabe im Build
grep -i "Building version" build.log
```
**Erwartetes Ergebnis:** Eine Zeile wie "Building version: v0.1.0.2"
---
## Häufige Probleme und Lösungen
### Problem 1: Tags nicht auf dem Server
```bash
git fetch --tags
git describe --tags --always
```
### Problem 2: Container wurde nicht neu gebaut
```bash
docker compose build --no-cache
docker compose up -d
```
### Problem 3: Alte version.txt im Container
```bash
# Stoppe Container, lösche Image, baue neu
docker compose down
docker rmi $(docker images | grep hoerdle | awk '{print $3}')
docker compose build --no-cache
docker compose up -d
```
### Problem 4: .git Verzeichnis nicht im Build-Context
```bash
# Überprüfe ob .git existiert
ls -la .git
# Überprüfe .dockerignore (sollte .git NICHT ausschließen)
cat .dockerignore 2>/dev/null || echo "Keine .dockerignore Datei"
```
---
## Vollständiger Neustart (wenn nichts anderes hilft)
```bash
# 1. Stoppe alles
docker compose down
# 2. Lösche alte Images
docker rmi $(docker images | grep hoerdle | awk '{print $3}')
# 3. Hole neueste Änderungen und Tags
git pull
git fetch --tags
# 4. Überprüfe Version lokal
git describe --tags --always
# 5. Baue komplett neu
docker compose build --no-cache
# 6. Starte Container
docker compose up -d
# 7. Überprüfe Logs
docker logs hoerdle | grep -i version
# 8. Teste API
curl http://localhost:3010/api/version
```
---
## Debugging-Befehl für alle Checks auf einmal
```bash
echo "=== Git Tags ===" && \
git describe --tags --always && \
echo -e "\n=== version.txt im Container ===" && \
docker exec hoerdle cat /app/version.txt 2>/dev/null || echo "Container läuft nicht oder Datei fehlt" && \
echo -e "\n=== APP_VERSION Env ===" && \
docker exec hoerdle env | grep APP_VERSION || echo "Variable nicht gesetzt" && \
echo -e "\n=== API Response ===" && \
curl -s http://localhost:3010/api/version && \
echo -e "\n\n=== Container Created ===" && \
docker inspect hoerdle | grep -i created | head -1
```
Kopiere diesen Befehl und führe ihn auf dem Remote-Server aus. Schicke mir die Ausgabe!

84
DEPLOYMENT.md Normal file
View File

@@ -0,0 +1,84 @@
# Deployment Guide
## Automated Deployment
Use the deployment script for zero-downtime deployments:
```bash
./scripts/deploy.sh
```
This script will:
1. Create a database backup
2. Pull latest changes from git
3. Fetch all git tags (for version display)
4. Build the new Docker image
5. Restart the container with minimal downtime
6. Clean up old images
## Manual Deployment
If you need to deploy manually:
```bash
# Pull latest changes
git pull
# Fetch tags (important for version display!)
git fetch --tags
# Build and restart
docker compose build
docker compose up -d
```
## Version Display
The app displays the current version in the footer. The version is determined as follows:
1. **During Docker build**: The version is extracted from git tags using `git describe --tags --always`
2. **At runtime**: The version is read from `/app/version.txt` and exposed via the `/api/version` endpoint
3. **Local development**: The version is extracted directly from git on each request
### Building with a specific version
You can override the version during build:
```bash
docker compose build --build-arg APP_VERSION=v1.2.3
```
### Troubleshooting
If the version shows as "dev" or "unknown":
1. Make sure git tags are pushed to the remote repository:
```bash
git push --tags
```
2. On the deployment server, fetch the tags:
```bash
git fetch --tags
```
3. Verify tags are available:
```bash
git describe --tags --always
```
4. Rebuild the Docker image:
```bash
docker compose build --no-cache
docker compose up -d
```
## Health Check
The container includes a health check that monitors the `/api/daily` endpoint. Check the health status:
```bash
docker ps
```
Look for the "healthy" status in the STATUS column.

View File

@@ -13,9 +13,24 @@ RUN npm ci
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
# Accept version as build argument (optional)
ARG APP_VERSION=""
# Install git to extract version information
RUN apk add --no-cache git
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Extract version: use build arg if provided, otherwise get from git
RUN if [ -n "$APP_VERSION" ]; then \
echo "$APP_VERSION" > /tmp/version.txt; \
else \
git describe --tags --always 2>/dev/null > /tmp/version.txt || echo "unknown" > /tmp/version.txt; \
fi && \
echo "Building version: $(cat /tmp/version.txt)"
# Next.js collects completely anonymous telemetry data about general usage.
# Learn more here: https://nextjs.org/telemetry
# Uncomment the following line in case you want to disable telemetry during the build.
@@ -53,6 +68,9 @@ COPY --from=builder --chown=nextjs:nodejs /app/node_modules ./node_modules
# Create uploads directory and set permissions
RUN mkdir -p public/uploads/covers && chown -R nextjs:nodejs public/uploads
# Copy version file from builder
COPY --from=builder /tmp/version.txt /app/version.txt
USER nextjs
EXPOSE 3000

View File

@@ -12,18 +12,22 @@ Eine Web-App inspiriert von Heardle, bei der Nutzer täglich einen Song anhand k
- Automatische Extraktion von ID3-Tags (Titel, Interpret).
- Intelligente Artist-Erkennung (unterstützt Multi-Artist-Tags).
- Bearbeitung von Metadaten.
- Sortierbare Song-Bibliothek (Titel, Interpret, Hinzugefügt am).
- Sortierbare Song-Bibliothek (Titel, Interpret, Hinzugefügt am, Erscheinungsjahr, Aktivierungen, Rating).
- Play/Pause-Funktion zum Vorhören in der Bibliothek.
- **Cover Art:**
- Automatische Extraktion von Cover-Bildern aus MP3-Dateien.
- Anzeige des Covers nach Spielende (Sieg/Niederlage).
- Automatische Migration bestehender Songs.
- **Teilen-Funktion:** Ergebnisse können als Emoji-Grid geteilt werden.
- **Teilen-Funktion:**
- Ergebnisse können als Emoji-Grid geteilt werden.
- Stern-Symbol (⭐) bei korrekt beantworteter Bonusfrage.
- Automatische Anpassung für Genre- und Special-Rätsel.
- **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.
- **Aktivierung/Deaktivierung:** Genres können aktiviert oder deaktiviert werden (deaktivierte Genres sind nicht auf der Startseite sichtbar und ihre Routen sind nicht erreichbar).
- Manuelle Zuweisung von Genres zu Songs.
- KI-gestützte automatische Kategorisierung mit OpenRouter (Claude 3.5 Haiku).
- Genre-spezifische tägliche Rätsel.

View File

@@ -2,6 +2,7 @@ import Game from '@/components/Game';
import { getOrCreateDailyPuzzle } from '@/lib/dailyPuzzle';
import Link from 'next/link';
import { PrismaClient } from '@prisma/client';
import { notFound } from 'next/navigation';
export const dynamic = 'force-dynamic';
@@ -14,8 +15,21 @@ interface PageProps {
export default async function GenrePage({ params }: PageProps) {
const { genre } = await params;
const decodedGenre = decodeURIComponent(genre);
// Check if genre exists and is active
const currentGenre = await prisma.genre.findUnique({
where: { name: decodedGenre }
});
if (!currentGenre || !currentGenre.active) {
notFound();
}
const dailyPuzzle = await getOrCreateDailyPuzzle(decodedGenre);
const genres = await prisma.genre.findMany({ orderBy: { name: 'asc' } });
const genres = await prisma.genre.findMany({
where: { active: true },
orderBy: { name: 'asc' }
});
const specials = await prisma.special.findMany({ orderBy: { name: 'asc' } });
const now = new Date();

View File

@@ -1,6 +1,6 @@
'use client';
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
interface Special {
@@ -21,6 +21,7 @@ interface Genre {
id: number;
name: string;
subtitle?: string;
active: boolean;
_count?: {
songs: number;
};
@@ -47,9 +48,10 @@ interface Song {
specials: Special[];
averageRating: number;
ratingCount: number;
excludeFromGlobal: boolean;
}
type SortField = 'id' | 'title' | 'artist' | 'createdAt' | 'releaseYear';
type SortField = 'id' | 'title' | 'artist' | 'createdAt' | 'releaseYear' | 'activations' | 'averageRating';
type SortDirection = 'asc' | 'desc';
export default function AdminPage() {
@@ -65,9 +67,11 @@ export default function AdminPage() {
const [genres, setGenres] = useState<Genre[]>([]);
const [newGenreName, setNewGenreName] = useState('');
const [newGenreSubtitle, setNewGenreSubtitle] = useState('');
const [newGenreActive, setNewGenreActive] = useState(true);
const [editingGenreId, setEditingGenreId] = useState<number | null>(null);
const [editGenreName, setEditGenreName] = useState('');
const [editGenreSubtitle, setEditGenreSubtitle] = useState('');
const [editGenreActive, setEditGenreActive] = useState(true);
// Specials state
const [specials, setSpecials] = useState<Special[]>([]);
@@ -95,10 +99,15 @@ export default function AdminPage() {
const [editReleaseYear, setEditReleaseYear] = useState<number | ''>('');
const [editGenreIds, setEditGenreIds] = useState<number[]>([]);
const [editSpecialIds, setEditSpecialIds] = useState<number[]>([]);
const [editExcludeFromGlobal, setEditExcludeFromGlobal] = useState(false);
// Post-upload state
const [uploadedSong, setUploadedSong] = useState<Song | null>(null);
const [uploadGenreIds, setUploadGenreIds] = useState<number[]>([]);
const [uploadExcludeFromGlobal, setUploadExcludeFromGlobal] = useState(false);
// Batch upload genre selection
const [batchUploadGenreIds, setBatchUploadGenreIds] = useState<number[]>([]);
// AI Categorization state
const [isCategorizing, setIsCategorizing] = useState(false);
@@ -123,6 +132,7 @@ export default function AdminPage() {
const [dailyPuzzles, setDailyPuzzles] = useState<any[]>([]);
const [playingPuzzleId, setPlayingPuzzleId] = useState<number | null>(null);
const [showDailyPuzzles, setShowDailyPuzzles] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// Check for existing auth on mount
useEffect(() => {
@@ -196,11 +206,16 @@ export default function AdminPage() {
const res = await fetch('/api/genres', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ name: newGenreName, subtitle: newGenreSubtitle }),
body: JSON.stringify({
name: newGenreName,
subtitle: newGenreSubtitle,
active: newGenreActive
}),
});
if (res.ok) {
setNewGenreName('');
setNewGenreSubtitle('');
setNewGenreActive(true);
fetchGenres();
} else {
alert('Failed to create genre');
@@ -211,6 +226,7 @@ export default function AdminPage() {
setEditingGenreId(genre.id);
setEditGenreName(genre.name);
setEditGenreSubtitle(genre.subtitle || '');
setEditGenreActive(genre.active !== undefined ? genre.active : true);
};
const saveEditedGenre = async () => {
@@ -221,7 +237,8 @@ export default function AdminPage() {
body: JSON.stringify({
id: editingGenreId,
name: editGenreName,
subtitle: editGenreSubtitle
subtitle: editGenreSubtitle,
active: editGenreActive
}),
});
if (res.ok) {
@@ -478,8 +495,11 @@ export default function AdminPage() {
setUploadProgress({ current: i + 1, total: files.length });
try {
console.log(`Uploading file ${i + 1}/${files.length}: ${file.name} (${(file.size / 1024 / 1024).toFixed(2)}MB)`);
const formData = new FormData();
formData.append('file', file);
formData.append('excludeFromGlobal', String(uploadExcludeFromGlobal));
const res = await fetch('/api/songs', {
method: 'POST',
@@ -487,8 +507,11 @@ export default function AdminPage() {
body: formData,
});
console.log(`Response status for ${file.name}: ${res.status}`);
if (res.ok) {
const data = await res.json();
console.log(`Upload successful for ${file.name}:`, data);
results.push({
filename: file.name,
success: true,
@@ -498,6 +521,7 @@ export default function AdminPage() {
} else if (res.status === 409) {
// Duplicate detected
const data = await res.json();
console.log(`Duplicate detected for ${file.name}:`, data);
results.push({
filename: file.name,
success: false,
@@ -506,17 +530,20 @@ export default function AdminPage() {
error: `Duplicate: Already exists as "${data.duplicate.title}" by "${data.duplicate.artist}"`
});
} else {
const errorText = await res.text();
console.error(`Upload failed for ${file.name} (${res.status}):`, errorText);
results.push({
filename: file.name,
success: false,
error: 'Upload failed'
error: `Upload failed (${res.status}): ${errorText.substring(0, 100)}`
});
}
} catch (error) {
console.error(`Network error for ${file.name}:`, error);
results.push({
filename: file.name,
success: false,
error: 'Network error'
error: `Network error: ${error instanceof Error ? error.message : 'Unknown error'}`
});
}
}
@@ -524,8 +551,31 @@ export default function AdminPage() {
setUploadResults(results);
setFiles([]);
setIsUploading(false);
// Assign genres to successfully uploaded songs
if (batchUploadGenreIds.length > 0) {
const successfulUploads = results.filter(r => r.success && r.song);
for (const result of successfulUploads) {
try {
await fetch('/api/songs', {
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify({
id: result.song.id,
title: result.song.title,
artist: result.song.artist,
genreIds: batchUploadGenreIds
}),
});
} catch (error) {
console.error(`Failed to assign genres to ${result.song.title}:`, error);
}
}
}
fetchSongs();
fetchGenres();
fetchSpecials(); // Update special counts
// Auto-trigger categorization after uploads
const successCount = results.filter(r => r.success).length;
@@ -539,6 +589,13 @@ export default function AdminPage() {
if (failedCount > 0) {
msg += `\n❌ ${failedCount} failed`;
}
if (batchUploadGenreIds.length > 0) {
const selectedGenreNames = genres
.filter(g => batchUploadGenreIds.includes(g.id))
.map(g => g.name)
.join(', ');
msg += `\n🏷 Assigned genres: ${selectedGenreNames}`;
}
msg += '\n\n🤖 Starting auto-categorization...';
setMessage(msg);
// Small delay to let user see the message
@@ -552,32 +609,81 @@ export default function AdminPage() {
}
};
const handleDragEnter = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(true);
e.stopPropagation();
e.dataTransfer.dropEffect = 'copy';
if (!isDragging) setIsDragging(true);
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
// Prevent flickering when dragging over children
if (e.currentTarget.contains(e.relatedTarget as Node)) {
return;
}
setIsDragging(false);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
const droppedFiles = Array.from(e.dataTransfer.files).filter(
file => file.type === 'audio/mpeg' || file.name.endsWith('.mp3')
);
const droppedFiles = Array.from(e.dataTransfer.files);
if (droppedFiles.length > 0) {
setFiles(droppedFiles);
// Validate file types
const validFiles: File[] = [];
const invalidFiles: string[] = [];
droppedFiles.forEach(file => {
if (file.type === 'audio/mpeg' || file.name.toLowerCase().endsWith('.mp3')) {
validFiles.push(file);
} else {
invalidFiles.push(`${file.name} (${file.type || 'unknown type'})`);
}
});
if (invalidFiles.length > 0) {
alert(`⚠️ The following files are not supported:\n\n${invalidFiles.join('\n')}\n\nOnly MP3 files are allowed.`);
}
if (validFiles.length > 0) {
setFiles(validFiles);
}
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
setFiles(Array.from(e.target.files));
const selectedFiles = Array.from(e.target.files);
// Validate file types
const validFiles: File[] = [];
const invalidFiles: string[] = [];
selectedFiles.forEach(file => {
if (file.type === 'audio/mpeg' || file.name.toLowerCase().endsWith('.mp3')) {
validFiles.push(file);
} else {
invalidFiles.push(`${file.name} (${file.type || 'unknown type'})`);
}
});
if (invalidFiles.length > 0) {
alert(`⚠️ The following files are not supported:\n\n${invalidFiles.join('\n')}\n\nOnly MP3 files are allowed.`);
}
if (validFiles.length > 0) {
setFiles(validFiles);
}
}
};
@@ -600,6 +706,7 @@ export default function AdminPage() {
setUploadGenreIds([]);
fetchSongs();
fetchGenres();
fetchSpecials(); // Update special counts if song was assigned to specials
setMessage(prev => prev + '\n✅ Genres assigned successfully!');
} else {
alert('Failed to assign genres');
@@ -613,6 +720,7 @@ export default function AdminPage() {
setEditReleaseYear(song.releaseYear || '');
setEditGenreIds(song.genres.map(g => g.id));
setEditSpecialIds(song.specials ? song.specials.map(s => s.id) : []);
setEditExcludeFromGlobal(song.excludeFromGlobal || false);
};
const cancelEditing = () => {
@@ -622,6 +730,7 @@ export default function AdminPage() {
setEditReleaseYear('');
setEditGenreIds([]);
setEditSpecialIds([]);
setEditExcludeFromGlobal(false);
};
const saveEditing = async (id: number) => {
@@ -634,7 +743,8 @@ export default function AdminPage() {
artist: editArtist,
releaseYear: editReleaseYear === '' ? null : Number(editReleaseYear),
genreIds: editGenreIds,
specialIds: editSpecialIds
specialIds: editSpecialIds,
excludeFromGlobal: editExcludeFromGlobal
}),
});
@@ -642,6 +752,7 @@ export default function AdminPage() {
setEditingId(null);
fetchSongs();
fetchGenres();
fetchSpecials(); // Update special counts
} else {
alert('Failed to update song');
}
@@ -661,6 +772,7 @@ export default function AdminPage() {
if (res.ok) {
fetchSongs();
fetchGenres();
fetchSpecials(); // Update special counts
} else {
alert('Failed to delete song');
}
@@ -735,6 +847,8 @@ export default function AdminPage() {
} else if (selectedGenreFilter === 'daily') {
const today = new Date().toISOString().split('T')[0];
matchesFilter = song.puzzles?.some(p => p.date === today) || false;
} else if (selectedGenreFilter === 'no-global') {
matchesFilter = song.excludeFromGlobal === true;
}
}
@@ -742,7 +856,7 @@ export default function AdminPage() {
});
const sortedSongs = [...filteredSongs].sort((a, b) => {
// Handle numeric sorting for ID and Release Year
// Handle numeric sorting for ID, Release Year, Activations, and Rating
if (sortField === 'id') {
return sortDirection === 'asc' ? a.id - b.id : b.id - a.id;
}
@@ -751,6 +865,12 @@ export default function AdminPage() {
const yearB = b.releaseYear || 0;
return sortDirection === 'asc' ? yearA - yearB : yearB - yearA;
}
if (sortField === 'activations') {
return sortDirection === 'asc' ? a.activations - b.activations : b.activations - a.activations;
}
if (sortField === 'averageRating') {
return sortDirection === 'asc' ? a.averageRating - b.averageRating : b.averageRating - a.averageRating;
}
// String sorting for other fields
const valA = String(a[sortField]).toLowerCase();
@@ -906,7 +1026,7 @@ export default function AdminPage() {
{/* 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' }}>
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1rem', alignItems: 'center' }}>
<input
type="text"
value={newGenreName}
@@ -923,12 +1043,21 @@ export default function AdminPage() {
className="form-input"
style={{ maxWidth: '300px' }}
/>
<label style={{ display: 'flex', alignItems: 'center', gap: '0.25rem', fontSize: '0.875rem', cursor: 'pointer' }}>
<input
type="checkbox"
checked={newGenreActive}
onChange={e => setNewGenreActive(e.target.checked)}
/>
Active
</label>
<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',
background: genre.active ? '#f3f4f6' : '#fee2e2',
opacity: genre.active ? 1 : 0.8,
padding: '0.25rem 0.75rem',
borderRadius: '999px',
display: 'flex',
@@ -955,6 +1084,16 @@ export default function AdminPage() {
<label style={{ fontSize: '0.75rem', color: '#666' }}>Subtitle</label>
<input type="text" value={editGenreSubtitle} onChange={e => setEditGenreSubtitle(e.target.value)} className="form-input" style={{ width: '300px' }} />
</div>
<div style={{ display: 'flex', flexDirection: 'column', justifyContent: 'flex-end', paddingBottom: '0.5rem' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '0.25rem', fontSize: '0.875rem', cursor: 'pointer' }}>
<input
type="checkbox"
checked={editGenreActive}
onChange={e => setEditGenreActive(e.target.checked)}
/>
Active
</label>
</div>
<button onClick={saveEditedGenre} className="btn-primary">Save</button>
<button onClick={() => setEditingGenreId(null)} className="btn-secondary">Cancel</button>
</div>
@@ -1048,6 +1187,7 @@ export default function AdminPage() {
<form onSubmit={handleBatchUpload}>
{/* Drag & Drop Zone */}
<div
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
@@ -1061,7 +1201,7 @@ export default function AdminPage() {
cursor: 'pointer',
transition: 'all 0.2s'
}}
onClick={() => document.getElementById('file-input')?.click()}
onClick={() => fileInputRef.current?.click()}
>
<div style={{ fontSize: '3rem', marginBottom: '0.5rem' }}>📁</div>
<p style={{ fontWeight: 'bold', marginBottom: '0.25rem' }}>
@@ -1071,7 +1211,7 @@ export default function AdminPage() {
or click to browse
</p>
<input
id="file-input"
ref={fileInputRef}
type="file"
accept="audio/mpeg"
multiple
@@ -1111,6 +1251,63 @@ export default function AdminPage() {
</div>
)}
<div style={{ marginBottom: '1rem' }}>
<label style={{ fontWeight: '500', display: 'block', marginBottom: '0.5rem' }}>
Assign Genres (optional)
</label>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
{genres.map(genre => (
<label
key={genre.id}
style={{
display: 'flex',
alignItems: 'center',
gap: '0.25rem',
padding: '0.25rem 0.5rem',
background: batchUploadGenreIds.includes(genre.id) ? '#dbeafe' : '#f3f4f6',
border: batchUploadGenreIds.includes(genre.id) ? '2px solid #3b82f6' : '2px solid transparent',
borderRadius: '0.25rem',
cursor: 'pointer',
fontSize: '0.875rem',
transition: 'all 0.2s'
}}
>
<input
type="checkbox"
checked={batchUploadGenreIds.includes(genre.id)}
onChange={e => {
if (e.target.checked) {
setBatchUploadGenreIds([...batchUploadGenreIds, genre.id]);
} else {
setBatchUploadGenreIds(batchUploadGenreIds.filter(id => id !== genre.id));
}
}}
style={{ margin: 0 }}
/>
{genre.name}
</label>
))}
</div>
<p style={{ fontSize: '0.875rem', color: '#666', marginTop: '0.25rem' }}>
Selected genres will be assigned to all uploaded songs.
</p>
</div>
<div style={{ marginBottom: '1rem' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer' }}>
<input
type="checkbox"
checked={uploadExcludeFromGlobal}
onChange={e => setUploadExcludeFromGlobal(e.target.checked)}
style={{ width: '1.25rem', height: '1.25rem' }}
/>
<span style={{ fontWeight: '500' }}>Exclude from Global Daily Puzzle</span>
</label>
<p style={{ fontSize: '0.875rem', color: '#666', marginLeft: '1.75rem', marginTop: '0.25rem' }}>
If checked, these songs will only appear in Genre or Special puzzles.
</p>
</div>
<button
type="submit"
className="btn-primary"
@@ -1231,6 +1428,7 @@ export default function AdminPage() {
>
<option value="">All Content</option>
<option value="daily">📅 Song of the Day</option>
<option value="no-global">🚫 No Global</option>
<optgroup label="Genres">
<option value="genre:-1">No Genre</option>
{genres.map(genre => (
@@ -1296,8 +1494,18 @@ export default function AdminPage() {
>
Added {sortField === 'createdAt' && (sortDirection === 'asc' ? '' : '')}
</th>
<th style={{ padding: '0.75rem' }}>Activations</th>
<th style={{ padding: '0.75rem' }}>Rating</th>
<th
style={{ padding: '0.75rem', cursor: 'pointer', userSelect: 'none' }}
onClick={() => handleSort('activations')}
>
Activations {sortField === 'activations' && (sortDirection === 'asc' ? '' : '')}
</th>
<th
style={{ padding: '0.75rem', cursor: 'pointer', userSelect: 'none' }}
onClick={() => handleSort('averageRating')}
>
Rating {sortField === 'averageRating' && (sortDirection === 'asc' ? '' : '')}
</th>
<th style={{ padding: '0.75rem' }}>Actions</th>
</tr>
</thead>
@@ -1373,6 +1581,16 @@ export default function AdminPage() {
</label>
))}
</div>
<div style={{ marginTop: '0.5rem', borderTop: '1px dashed #eee', paddingTop: '0.5rem' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '0.75rem', cursor: 'pointer', color: '#b91c1c' }}>
<input
type="checkbox"
checked={editExcludeFromGlobal}
onChange={e => setEditExcludeFromGlobal(e.target.checked)}
/>
Exclude from Global
</label>
</div>
</td>
<td style={{ padding: '0.75rem', color: '#666', fontSize: '0.75rem' }}>
{new Date(song.createdAt).toLocaleDateString('de-DE')}
@@ -1412,6 +1630,24 @@ export default function AdminPage() {
<div style={{ fontWeight: 'bold', color: '#111827' }}>{song.title}</div>
<div style={{ fontSize: '0.875rem', color: '#6b7280' }}>{song.artist}</div>
{song.excludeFromGlobal && (
<div style={{ marginTop: '0.25rem' }}>
<span style={{
background: '#fee2e2',
color: '#991b1b',
padding: '0.1rem 0.4rem',
borderRadius: '0.25rem',
fontSize: '0.7rem',
border: '1px solid #fecaca',
display: 'inline-flex',
alignItems: 'center',
gap: '0.25rem'
}}>
🚫 No Global
</span>
</div>
)}
{/* Daily Puzzle Badges */}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem', marginTop: '0.25rem' }}>
{song.puzzles?.filter(p => p.date === new Date().toISOString().split('T')[0]).map(p => {
@@ -1600,6 +1836,8 @@ export default function AdminPage() {
>
Rebuild Database
</button>
</div>
</div>
);

View File

@@ -27,7 +27,7 @@ export async function POST(request: Request) {
if (authError) return authError;
try {
const { name, subtitle } = await request.json();
const { name, subtitle, active } = await request.json();
if (!name || typeof name !== 'string') {
return NextResponse.json({ error: 'Invalid name' }, { status: 400 });
@@ -36,7 +36,8 @@ export async function POST(request: Request) {
const genre = await prisma.genre.create({
data: {
name: name.trim(),
subtitle: subtitle ? subtitle.trim() : null
subtitle: subtitle ? subtitle.trim() : null,
active: active !== undefined ? active : true
},
});
@@ -76,7 +77,7 @@ export async function PUT(request: Request) {
if (authError) return authError;
try {
const { id, name, subtitle } = await request.json();
const { id, name, subtitle, active } = await request.json();
if (!id) {
return NextResponse.json({ error: 'Missing id' }, { status: 400 });
@@ -86,7 +87,8 @@ export async function PUT(request: Request) {
where: { id: Number(id) },
data: {
...(name && { name: name.trim() }),
subtitle: subtitle ? subtitle.trim() : null // Allow clearing subtitle if empty string passed? Or just update if provided? Let's assume null/empty string clears it.
subtitle: subtitle ? subtitle.trim() : null,
...(active !== undefined && { active })
},
});

View File

@@ -8,6 +8,10 @@ import { requireAdminAuth } from '@/lib/auth';
const prisma = new PrismaClient();
// Configure route to handle large file uploads
export const runtime = 'nodejs';
export const maxDuration = 60; // 60 seconds timeout for uploads
export async function GET() {
const songs = await prisma.song.findMany({
orderBy: { createdAt: 'desc' },
@@ -37,23 +41,35 @@ export async function GET() {
specials: song.specials.map(ss => ss.special),
averageRating: song.averageRating,
ratingCount: song.ratingCount,
excludeFromGlobal: song.excludeFromGlobal,
}));
return NextResponse.json(songsWithActivations);
}
export async function POST(request: Request) {
console.log('[UPLOAD] Starting song upload request');
// Check authentication
const authError = await requireAdminAuth(request as any);
if (authError) return authError;
if (authError) {
console.log('[UPLOAD] Authentication failed');
return authError;
}
try {
console.log('[UPLOAD] Parsing form data...');
const formData = await request.formData();
const file = formData.get('file') as File;
let title = '';
let artist = '';
const excludeFromGlobal = formData.get('excludeFromGlobal') === 'true';
console.log('[UPLOAD] Received file:', file?.name, 'Size:', file?.size, 'Type:', file?.type);
console.log('[UPLOAD] excludeFromGlobal:', excludeFromGlobal);
if (!file) {
console.error('[UPLOAD] No file provided');
return NextResponse.json({ error: 'No file provided' }, { status: 400 });
}
@@ -81,6 +97,7 @@ export async function POST(request: Request) {
}
const buffer = Buffer.from(await file.arrayBuffer());
console.log('[UPLOAD] Buffer created, size:', buffer.length, 'bytes');
// Validate and extract metadata from file
let metadata;
@@ -208,16 +225,17 @@ export async function POST(request: Request) {
console.error('Failed to extract cover image:', e);
}
// Fetch release year from MusicBrainz
// Fetch release year from iTunes
let releaseYear = null;
try {
const { getReleaseYear } = await import('@/lib/musicbrainz');
releaseYear = await getReleaseYear(artist, title);
const { getReleaseYearFromItunes } = await import('@/lib/itunes');
releaseYear = await getReleaseYearFromItunes(artist, title);
if (releaseYear) {
console.log(`Fetched release year ${releaseYear} for "${title}" by "${artist}"`);
console.log(`Fetched release year ${releaseYear} from iTunes for "${title}" by "${artist}"`);
}
} catch (e) {
console.error('Failed to fetch release year from MusicBrainz:', e);
console.error('Failed to fetch release year:', e);
}
const song = await prisma.song.create({
@@ -227,6 +245,7 @@ export async function POST(request: Request) {
filename,
coverImage,
releaseYear,
excludeFromGlobal,
},
include: { genres: true, specials: true }
});
@@ -247,7 +266,7 @@ export async function PUT(request: Request) {
if (authError) return authError;
try {
const { id, title, artist, releaseYear, genreIds, specialIds } = await request.json();
const { id, title, artist, releaseYear, genreIds, specialIds, excludeFromGlobal } = await request.json();
if (!id || !title || !artist) {
return NextResponse.json({ error: 'Missing fields' }, { status: 400 });
@@ -260,6 +279,10 @@ export async function PUT(request: Request) {
data.releaseYear = releaseYear;
}
if (excludeFromGlobal !== undefined) {
data.excludeFromGlobal = excludeFromGlobal;
}
if (genreIds) {
data.genres = {
set: genreIds.map((gId: number) => ({ id: gId }))

65
app/api/version/route.ts Normal file
View File

@@ -0,0 +1,65 @@
import { NextResponse } from 'next/server';
import { execSync } from 'child_process';
import { readFileSync, existsSync } from 'fs';
import { join } from 'path';
export async function GET() {
try {
// First check if version file exists (Docker deployment)
// Try both /app/version.txt (Docker) and ./version.txt (local)
const versionPaths = [
'/app/version.txt',
join(process.cwd(), 'version.txt')
];
for (const versionFilePath of versionPaths) {
if (existsSync(versionFilePath)) {
const version = readFileSync(versionFilePath, 'utf-8').trim();
if (version && version !== 'unknown') {
return NextResponse.json({ version });
}
}
}
// Fallback: check environment variable
if (process.env.APP_VERSION) {
return NextResponse.json({ version: process.env.APP_VERSION });
}
// Fallback: try to get from git (local development)
let version = 'dev';
try {
// First try to get the exact tag if we're on a tagged commit
version = execSync('git describe --tags --exact-match 2>/dev/null', {
encoding: 'utf-8',
cwd: process.cwd()
}).trim();
} catch {
try {
// If not on a tag, get the latest tag with commit info
version = execSync('git describe --tags --always 2>/dev/null', {
encoding: 'utf-8',
cwd: process.cwd()
}).trim();
} catch {
// If git is not available or no tags exist, try to get commit hash
try {
const hash = execSync('git rev-parse --short HEAD 2>/dev/null', {
encoding: 'utf-8',
cwd: process.cwd()
}).trim();
version = `dev-${hash}`;
} catch {
// Fallback to just 'dev' if git is not available
version = 'dev';
}
}
}
return NextResponse.json({ version });
} catch (error) {
console.error('Error getting version:', error);
return NextResponse.json({ version: 'unknown' });
}
}

View File

@@ -25,6 +25,7 @@ export const viewport: Viewport = {
};
import InstallPrompt from "@/components/InstallPrompt";
import AppFooter from "@/components/AppFooter";
export default function RootLayout({
children,
@@ -36,15 +37,7 @@ export default function RootLayout({
<body className={`${geistSans.variable} ${geistMono.variable}`}>
{children}
<InstallPrompt />
<footer className="app-footer">
<p>
Vibe coded with and 🍺 by{' '}
<a href="https://digitalcourage.social/@elpatron" target="_blank" rel="noopener noreferrer">
@elpatron@digitalcourage.social
</a>
{' '}- for personal use among friends only!
</p>
</footer>
<AppFooter />
</body>
</html>
);

View File

@@ -9,7 +9,10 @@ const prisma = new PrismaClient();
export default async function Home() {
const dailyPuzzle = await getOrCreateDailyPuzzle(null); // Global puzzle
const genres = await prisma.genre.findMany({ orderBy: { name: 'asc' } });
const genres = await prisma.genre.findMany({
where: { active: true },
orderBy: { name: 'asc' }
});
const specials = await prisma.special.findMany({ orderBy: { name: 'asc' } });
const now = new Date();

34
components/AppFooter.tsx Normal file
View File

@@ -0,0 +1,34 @@
'use client';
import { useEffect, useState } from 'react';
export default function AppFooter() {
const [version, setVersion] = useState<string>('');
useEffect(() => {
fetch('/api/version')
.then(res => res.json())
.then(data => setVersion(data.version))
.catch(() => setVersion(''));
}, []);
return (
<footer className="app-footer">
<p>
Vibe coded with and 🍺 by{' '}
<a href="https://digitalcourage.social/@elpatron" target="_blank" rel="noopener noreferrer">
@elpatron@digitalcourage.social
</a>
{' '}- for personal use among friends only!
{version && (
<>
{' '}·{' '}
<span style={{ fontSize: '0.85em', opacity: 0.7 }}>
{version}
</span>
</>
)}
</p>
</footer>
);
}

View File

@@ -173,7 +173,8 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
}
const speaker = hasWon ? '🔉' : '🔇';
const genreText = genre ? `Genre: ${genre}\n` : '';
const bonusStar = (hasWon && gameState.yearGuessed && dailyPuzzle.releaseYear && gameState.scoreBreakdown.some(item => item.reason === 'Bonus: Correct Year')) ? '⭐' : '';
const genreText = genre ? `${isSpecial ? 'Special' : 'Genre'}: ${genre}\n` : '';
let shareUrl = 'https://hoerdle.elpatron.me';
if (genre) {
@@ -184,7 +185,7 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
}
}
const text = `Hördle #${dailyPuzzle.puzzleNumber}\n${genreText}\n${speaker}${emojiGrid}\nScore: ${gameState.score}\n\n#Hördle #Music\n\n${shareUrl}`;
const text = `Hördle #${dailyPuzzle.puzzleNumber}\n${genreText}\n${speaker}${emojiGrid}${bonusStar}\nScore: ${gameState.score}\n\n#Hördle #Music\n\n${shareUrl}`;
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
@@ -332,7 +333,7 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
/>
<h3 style={{ fontSize: '1.125rem', fontWeight: 'bold', margin: '0 0 0.5rem 0' }}>{dailyPuzzle.title}</h3>
<p style={{ fontSize: '0.875rem', color: '#666', margin: '0 0 0.5rem 0' }}>{dailyPuzzle.artist}</p>
{dailyPuzzle.releaseYear && (
{dailyPuzzle.releaseYear && gameState.yearGuessed && (
<p style={{ fontSize: '0.875rem', color: '#666', margin: '0 0 1rem 0' }}>Released: {dailyPuzzle.releaseYear}</p>
)}
<audio controls style={{ width: '100%' }}>

View File

@@ -26,4 +26,4 @@ services:
start_period: 40s
# Run migrations and start server (auto-baseline on first run if needed)
command: >
sh -c "npx prisma migrate deploy || (echo 'Baselining existing database...' && sh scripts/baseline-migrations.sh && npx prisma migrate deploy) && node scripts/migrate-release-years.mjs && node scripts/migrate-covers.mjs && node server.js"
sh -c "npx prisma migrate deploy || (echo 'Baselining existing database...' && sh scripts/baseline-migrations.sh && npx prisma migrate deploy) && node server.js"

View File

@@ -33,7 +33,7 @@ export async function getOrCreateDailyPuzzle(genreName: string | null = null) {
// Get songs available for this genre
const whereClause = genreId
? { genres: { some: { id: genreId } } }
: {}; // Global puzzle picks from ALL songs
: { excludeFromGlobal: false }; // Global puzzle picks from ALL songs (except excluded)
const allSongs = await prisma.song.findMany({
where: whereClause,

125
lib/itunes.ts Normal file
View File

@@ -0,0 +1,125 @@
/**
* iTunes Search API integration for fetching release years
* API Documentation: https://performance-partners.apple.com/search-api
*/
interface ItunesResult {
wrapperType: string;
kind: string;
artistName: string;
collectionName: string;
trackName: string;
releaseDate: string;
primaryGenreName: string;
}
interface ItunesResponse {
resultCount: number;
results: ItunesResult[];
}
// Rate limiting state
let lastRequestTime = 0;
let blockedUntil = 0;
const MIN_INTERVAL = 2000; // 2 seconds = 30 requests per minute
const BLOCK_DURATION = 60000; // 60 seconds pause after 403
// Mutex for serializing requests
let requestQueue = Promise.resolve<any>(null);
/**
* Get the earliest release year for a song from iTunes
* @param artist Artist name
* @param title Song title
* @returns Release year or null if not found
*/
export async function getReleaseYearFromItunes(artist: string, title: string): Promise<number | null> {
// Queue the request to ensure sequential execution and rate limiting
const result = requestQueue.then(() => executeRequest(artist, title));
// Update queue to wait for this request
requestQueue = result.catch(() => null);
return result;
}
async function executeRequest(artist: string, title: string): Promise<number | null> {
try {
// Check if blocked
const now = Date.now();
if (now < blockedUntil) {
const waitTime = blockedUntil - now;
console.log(`iTunes API blocked (403/429). Waiting ${Math.ceil(waitTime / 1000)}s before next request...`);
await new Promise(resolve => setTimeout(resolve, waitTime));
}
// Enforce rate limit (min interval)
const timeSinceLast = Date.now() - lastRequestTime;
if (timeSinceLast < MIN_INTERVAL) {
const delay = MIN_INTERVAL - timeSinceLast;
await new Promise(resolve => setTimeout(resolve, delay));
}
// Construct search URL
const term = encodeURIComponent(`${artist} ${title}`);
const url = `https://itunes.apple.com/search?term=${term}&entity=song&limit=10`;
const response = await fetch(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.9'
}
});
lastRequestTime = Date.now();
if (response.status === 403 || response.status === 429) {
console.warn(`iTunes API rate limit hit (${response.status}). Pausing for 60s.`);
blockedUntil = Date.now() + BLOCK_DURATION;
return null;
}
if (!response.ok) {
console.error(`iTunes API error: ${response.status} ${response.statusText}`);
return null;
}
const data: ItunesResponse = await response.json();
if (data.resultCount === 0) {
return null;
}
// Filter for exact(ish) matches to avoid wrong songs
// and find the earliest release date
let earliestYear: number | null = null;
const normalizedTitle = title.toLowerCase().replace(/[^\w\s]/g, '');
const normalizedArtist = artist.toLowerCase().replace(/[^\w\s]/g, '');
for (const result of data.results) {
// Basic validation that it's the right song
const resTitle = result.trackName.toLowerCase().replace(/[^\w\s]/g, '');
const resArtist = result.artistName.toLowerCase().replace(/[^\w\s]/g, '');
// Check if title and artist are contained in the result (fuzzy match)
if (resTitle.includes(normalizedTitle) && resArtist.includes(normalizedArtist)) {
if (result.releaseDate) {
const year = new Date(result.releaseDate).getFullYear();
if (!isNaN(year)) {
if (earliestYear === null || year < earliestYear) {
earliestYear = year;
}
}
}
}
}
return earliestYear;
} catch (error) {
console.error(`Error fetching release year from iTunes for "${title}" by "${artist}":`, error);
return null;
}
}

View File

@@ -1,121 +0,0 @@
/**
* MusicBrainz API integration for fetching release years
* API Documentation: https://musicbrainz.org/doc/MusicBrainz_API
* Rate Limiting: 50 requests per second for meaningful User-Agent strings
*/
const MUSICBRAINZ_API_BASE = 'https://musicbrainz.org/ws/2';
const USER_AGENT = 'hoerdle/0.1.0 ( elpatron@mailbox.org )';
const RATE_LIMIT_DELAY = 25; // 25ms between requests = ~40 req/s (safe margin)
/**
* Sleep utility for rate limiting
*/
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* Fetch with retry logic for HTTP 503 (rate limit exceeded)
*/
async function fetchWithRetry(url: string, maxRetries = 5): Promise<Response> {
let lastError: Error | null = null;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const response = await fetch(url, {
headers: {
'User-Agent': USER_AGENT,
'Accept': 'application/json'
}
});
// If rate limited (503), wait with exponential backoff
if (response.status === 503) {
const waitTime = Math.pow(2, attempt) * 1000; // 1s, 2s, 4s, 8s, 16s
console.log(`Rate limited (503), waiting ${waitTime}ms before retry ${attempt + 1}/${maxRetries}...`);
await sleep(waitTime);
continue;
}
return response;
} catch (error) {
lastError = error as Error;
if (attempt < maxRetries - 1) {
const waitTime = Math.pow(2, attempt) * 1000;
console.log(`Network error, waiting ${waitTime}ms before retry ${attempt + 1}/${maxRetries}...`);
await sleep(waitTime);
}
}
}
throw lastError || new Error('Max retries exceeded');
}
/**
* Get the earliest release year for a song from MusicBrainz
* @param artist Artist name
* @param title Song title
* @returns Release year or null if not found
*/
export async function getReleaseYear(artist: string, title: string): Promise<number | null> {
try {
// Build search query using Lucene syntax
const query = `artist:"${artist}" AND recording:"${title}"`;
const url = `${MUSICBRAINZ_API_BASE}/recording?query=${encodeURIComponent(query)}&fmt=json&limit=10`;
// Add rate limiting delay
await sleep(RATE_LIMIT_DELAY);
const response = await fetchWithRetry(url);
if (!response.ok) {
console.error(`MusicBrainz API error: ${response.status} ${response.statusText}`);
return null;
}
const data = await response.json();
if (!data.recordings || data.recordings.length === 0) {
console.log(`No recordings found for "${title}" by "${artist}"`);
return null;
}
// Find the earliest release year from all recordings
let earliestYear: number | null = null;
for (const recording of data.recordings) {
// Check if recording has releases
if (recording.releases && recording.releases.length > 0) {
for (const release of recording.releases) {
if (release.date) {
// Extract year from date (format: YYYY-MM-DD or YYYY)
const year = parseInt(release.date.split('-')[0]);
if (!isNaN(year) && (earliestYear === null || year < earliestYear)) {
earliestYear = year;
}
}
}
}
// Also check first-release-date on the recording itself
if (recording['first-release-date']) {
const year = parseInt(recording['first-release-date'].split('-')[0]);
if (!isNaN(year) && (earliestYear === null || year < earliestYear)) {
earliestYear = year;
}
}
}
if (earliestYear) {
console.log(`Found release year ${earliestYear} for "${title}" by "${artist}"`);
} else {
console.log(`No release year found for "${title}" by "${artist}"`);
}
return earliestYear;
} catch (error) {
console.error(`Error fetching release year for "${title}" by "${artist}":`, error);
return null;
}
}

View File

@@ -29,7 +29,7 @@ export function middleware(request: NextRequest) {
"style-src 'self' 'unsafe-inline'", // Allow inline styles
"img-src 'self' data: blob:",
"font-src 'self' data:",
"connect-src 'self' https://openrouter.ai https://gotify.example.com https://musicbrainz.org",
"connect-src 'self' https://openrouter.ai https://gotify.example.com",
"media-src 'self' blob:",
"frame-ancestors 'self'",
].join('; ');

View File

@@ -8,6 +8,7 @@ const nextConfig: NextConfig = {
serverActions: {
bodySizeLimit: '50mb',
},
middlewareClientMaxBodySize: '50mb',
},
env: {
TZ: process.env.TZ || 'Europe/Berlin',

BIN
prisma/dev.db.bak Normal file

Binary file not shown.

View File

@@ -0,0 +1,20 @@
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Song" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"title" TEXT NOT NULL,
"artist" TEXT NOT NULL,
"filename" TEXT NOT NULL,
"coverImage" TEXT,
"releaseYear" INTEGER,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"averageRating" REAL NOT NULL DEFAULT 0,
"ratingCount" INTEGER NOT NULL DEFAULT 0,
"excludeFromGlobal" BOOLEAN NOT NULL DEFAULT false
);
INSERT INTO "new_Song" ("artist", "averageRating", "coverImage", "createdAt", "filename", "id", "ratingCount", "releaseYear", "title") SELECT "artist", "averageRating", "coverImage", "createdAt", "filename", "id", "ratingCount", "releaseYear", "title" FROM "Song";
DROP TABLE "Song";
ALTER TABLE "new_Song" RENAME TO "Song";
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View File

@@ -0,0 +1,15 @@
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Genre" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"name" TEXT NOT NULL,
"subtitle" TEXT,
"active" BOOLEAN NOT NULL DEFAULT true
);
INSERT INTO "new_Genre" ("id", "name", "subtitle") SELECT "id", "name", "subtitle" FROM "Genre";
DROP TABLE "Genre";
ALTER TABLE "new_Genre" RENAME TO "Genre";
CREATE UNIQUE INDEX "Genre_name_key" ON "Genre"("name");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View File

@@ -16,19 +16,21 @@ model Song {
artist String
filename String // Filename in public/uploads
coverImage String? // Filename in public/uploads/covers
releaseYear Int? // Release year from MusicBrainz
releaseYear Int? // Release year from iTunes
createdAt DateTime @default(now())
puzzles DailyPuzzle[]
genres Genre[]
specials SpecialSong[]
averageRating Float @default(0)
ratingCount Int @default(0)
excludeFromGlobal Boolean @default(false)
}
model Genre {
id Int @id @default(autoincrement())
name String @unique
subtitle String?
active Boolean @default(true)
songs Song[]
dailyPuzzles DailyPuzzle[]
}

View File

@@ -50,6 +50,10 @@ fi
echo "📥 Pulling latest changes from git..."
git pull
# Fetch all tags
echo "🏷️ Fetching git tags..."
git fetch --tags
# Build new image in background (doesn't stop running container)
echo "🔨 Building new Docker image (this runs while app is still online)..."
docker compose build

View File

@@ -1,16 +1,19 @@
#!/bin/sh
set -e
# Export version if available
if [ -f /app/version.txt ]; then
export APP_VERSION=$(cat /app/version.txt)
echo "App version: $APP_VERSION"
fi
echo "Starting deployment..."
# Run migrations
echo "Running database migrations..."
npx prisma migrate deploy
# Run release year migration (only if not already done)
# Run release year migration (idempotent, skips if all done)
echo "Running release year migration check..."
node scripts/migrate-release-years.mjs
# Start the application
echo "Starting application..."

View File

@@ -1,6 +1,6 @@
import { PrismaClient } from '@prisma/client';
import { parseBuffer } from 'music-metadata';
import { readFile, writeFile, mkdir } from 'fs/promises';
import { readFile, writeFile, mkdir, access } from 'fs/promises';
import path from 'path';
import { fileURLToPath } from 'url';
@@ -11,6 +11,16 @@ const __dirname = path.dirname(__filename);
const prisma = new PrismaClient();
async function migrate() {
// Check if migration already ran
const flagPath = path.join(process.cwd(), '.covers-migrated');
try {
await access(flagPath);
console.log('✅ Cover migration already completed (flag file exists). Skipping...');
return;
} catch {
// Flag file doesn't exist, proceed with migration
}
console.log('Starting cover art migration...');
try {
@@ -23,10 +33,18 @@ async function migrate() {
console.log(`Found ${songs.length} songs without cover image.`);
if (songs.length === 0) {
console.log('✅ All songs already have cover images!');
await writeFile(flagPath, new Date().toISOString());
return;
}
let processed = 0;
let successful = 0;
for (const song of songs) {
try {
const filePath = path.join(process.cwd(), 'public/uploads', song.filename);
console.log(`Processing ${song.title} (${song.filename})...`);
const buffer = await readFile(filePath);
const metadata = await parseBuffer(buffer);
@@ -47,14 +65,20 @@ async function migrate() {
data: { coverImage: coverFilename }
});
console.log(`✅ Extracted cover for ${song.title}`);
successful++;
}
processed++;
} catch (e) {
console.error(`❌ Failed to process ${song.title}:`, e.message);
processed++;
}
}
console.log('Migration completed.');
console.log(`✅ Cover migration completed: ${successful}/${processed} songs processed successfully.`);
// Create flag file to prevent re-running
await writeFile(flagPath, new Date().toISOString());
console.log(`🏁 Created flag file: ${flagPath}`);
} catch (e) {
console.error('Migration failed:', e);
} finally {

View File

@@ -1,208 +0,0 @@
import { PrismaClient } from '@prisma/client';
import { writeFile } from 'fs/promises';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const prisma = new PrismaClient();
// --- MusicBrainz Logic (Embedded to avoid TS import issues in Docker) ---
const MUSICBRAINZ_API_BASE = 'https://musicbrainz.org/ws/2';
const USER_AGENT = 'hoerdle/0.1.0 ( elpatron@mailbox.org )';
const RATE_LIMIT_DELAY = 250; // 250ms between requests (very conservative)
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function fetchWithRetry(url, maxRetries = 10) {
let lastError = null;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const response = await fetch(url, {
headers: {
'User-Agent': USER_AGENT,
'Accept': 'application/json'
}
});
if (response.status === 503) {
const waitTime = Math.pow(2, attempt) * 1000;
console.log(`Rate limited (503), waiting ${waitTime}ms before retry ${attempt + 1}/${maxRetries}...`);
await sleep(waitTime);
continue;
}
return response;
} catch (error) {
lastError = error;
if (attempt < maxRetries - 1) {
const waitTime = Math.pow(2, attempt) * 1000;
console.log(`Network error, waiting ${waitTime}ms before retry ${attempt + 1}/${maxRetries}...`);
await sleep(waitTime);
}
}
}
throw lastError || new Error('Max retries exceeded');
}
async function getReleaseYear(artist, title) {
const search = async (query, type) => {
const url = `${MUSICBRAINZ_API_BASE}/recording?query=${encodeURIComponent(query)}&fmt=json&limit=5`;
await sleep(RATE_LIMIT_DELAY);
const response = await fetchWithRetry(url);
if (!response.ok) throw new Error(`API Error ${response.status}: ${response.statusText}`);
return response.json();
};
try {
// 1. Strict Search
let data = await search(`artist:"${artist}" AND recording:"${title}"`, 'strict');
// 2. Fallback: Fuzzy Search if no recordings found
if (!data.recordings || data.recordings.length === 0) {
// Remove special chars and quotes for fuzzy search
const cleanArtist = artist.replace(/[^\w\s]/g, ' ').replace(/\s+/g, ' ').trim();
const cleanTitle = title.replace(/[^\w\s]/g, ' ').replace(/\s+/g, ' ').trim();
// Only try fuzzy if the cleaned strings are valid
if (cleanArtist && cleanTitle) {
console.log(` Trying fuzzy search for: ${cleanTitle} by ${cleanArtist}`);
data = await search(`artist:${cleanArtist} AND recording:${cleanTitle}`, 'fuzzy');
}
}
if (!data.recordings || data.recordings.length === 0) {
console.log(` ❌ No recordings found for "${title}" by "${artist}"`);
return null;
}
let earliestYear = null;
for (const recording of data.recordings) {
// Check releases linked to recording
if (recording.releases && recording.releases.length > 0) {
for (const release of recording.releases) {
if (release.date) {
const year = parseInt(release.date.split('-')[0]);
if (!isNaN(year) && (earliestYear === null || year < earliestYear)) {
earliestYear = year;
}
}
}
}
// Check first-release-date on recording itself
if (recording['first-release-date']) {
const year = parseInt(recording['first-release-date'].split('-')[0]);
if (!isNaN(year) && (earliestYear === null || year < earliestYear)) {
earliestYear = year;
}
}
}
if (earliestYear) {
// console.log(` ✅ Found year: ${earliestYear}`);
} else {
console.log(` ⚠️ Recordings found but NO YEAR for "${title}" by "${artist}"`);
}
return earliestYear;
} catch (error) {
console.error(` ❌ Error fetching release year for "${title}" by "${artist}":`, error.message);
return null;
}
}
// --- Migration Logic ---
async function migrate() {
console.log('🎵 Starting release year migration...');
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
try {
// Find songs without release year
const songs = await prisma.song.findMany({
where: {
releaseYear: null
},
orderBy: {
id: 'asc'
}
});
console.log(`📊 Found ${songs.length} songs without release year.\n`);
if (songs.length === 0) {
console.log('✅ All songs already have release years!');
await createFlagFile();
return;
}
let processed = 0;
let successful = 0;
let failed = 0;
const startTime = Date.now();
for (const song of songs) {
processed++;
// const progress = `[${processed}/${songs.length}]`;
try {
// console.log(`${progress} Processing: "${song.title}" by "${song.artist}"`);
const releaseYear = await getReleaseYear(song.artist, song.title);
if (releaseYear) {
await prisma.song.update({
where: { id: song.id },
data: { releaseYear }
});
successful++;
// console.log(` ✅ Updated with year: ${releaseYear}`);
} else {
failed++;
console.log(` ⚠️ No release year found for "${song.title}" by "${song.artist}"`);
}
} catch (error) {
failed++;
console.error(` ❌ Error processing song:`, error instanceof Error ? error.message : error);
}
// Progress update every 10 songs (less verbose)
if (processed % 10 === 0 || processed === songs.length) {
const elapsed = Math.round((Date.now() - startTime) / 1000);
const rate = processed / (elapsed || 1);
const remaining = songs.length - processed;
const eta = Math.round(remaining / rate);
process.stdout.write(`\r📈 Progress: ${processed}/${songs.length} | Success: ${successful} | Failed: ${failed} | ETA: ${eta}s`);
}
}
const totalTime = Math.round((Date.now() - startTime) / 1000);
console.log('\n\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
console.log('✅ Migration completed!');
console.log(`📊 Total: ${processed} | Success: ${successful} | Failed: ${failed}`);
console.log(`⏱️ Time: ${totalTime}s (${(processed / (totalTime || 1)).toFixed(2)} songs/s)`);
await createFlagFile();
} catch (error) {
console.error('❌ Migration failed:', error);
throw error;
} finally {
await prisma.$disconnect();
}
}
async function createFlagFile() {
const flagPath = path.join(process.cwd(), '.release-years-migrated');
await writeFile(flagPath, new Date().toISOString());
console.log(`\n🏁 Created flag file: ${flagPath}`);
}
migrate();

View File

@@ -0,0 +1,211 @@
/**
* Robust iTunes Refresh Script
*
* Usage:
* ADMIN_PASSWORD='your_password' node scripts/slow-refresh-itunes.js
*
* Options:
* --force Overwrite existing release years
*/
const API_URL = process.env.API_URL || 'http://localhost:3010';
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD;
if (!ADMIN_PASSWORD) {
console.error('❌ Error: ADMIN_PASSWORD environment variable is required.');
process.exit(1);
}
const FORCE_UPDATE = process.argv.includes('--force');
const USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36';
// Helper for delays
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
// Helper to clean search terms
function cleanSearchTerm(text) {
return text
.replace(/_Unplugged/gi, '')
.replace(/_Remastered/gi, '')
.replace(/_Live/gi, '')
.replace(/_Acoustic/gi, '')
.replace(/_Radio Edit/gi, '')
.replace(/_Extended/gi, '')
.replace(/_/g, ' ')
.trim();
}
async function main() {
console.log(`🎵 Starting iTunes Refresh Script`);
console.log(` Target: ${API_URL}`);
console.log(` Force Update: ${FORCE_UPDATE}`);
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
try {
// 1. Authenticate
console.log('🔑 Authenticating...');
const loginRes = await fetch(`${API_URL}/api/admin/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: ADMIN_PASSWORD })
});
if (!loginRes.ok) {
throw new Error(`Login failed: ${loginRes.status} ${loginRes.statusText}`);
}
// We need to manually manage the cookie/header if the API uses cookies,
// but the Admin UI uses a custom header 'x-admin-auth'.
// Let's verify if the login endpoint returns a token or if we just use the password/flag.
// Looking at the code, the client sets 'x-admin-auth' to 'authenticated' in localStorage.
// The API middleware likely checks a cookie or just this header?
// Let's check lib/auth.ts... actually, let's just assume we need to send the header.
// Wait, the frontend sets 'x-admin-auth' to 'authenticated' after successful login.
// The middleware likely checks the session cookie set by the login route.
// Let's get the cookie from the login response
const cookie = loginRes.headers.get('set-cookie');
const headers = {
'Content-Type': 'application/json',
'Cookie': cookie || '',
'x-admin-auth': 'authenticated' // Just in case
};
// 2. Fetch Songs
console.log('📥 Fetching song list...');
const songsRes = await fetch(`${API_URL}/api/songs`, { headers });
if (!songsRes.ok) throw new Error(`Failed to fetch songs: ${songsRes.status}`);
const songs = await songsRes.json();
console.log(`📊 Found ${songs.length} songs.`);
let processed = 0;
let updated = 0;
let skipped = 0;
let failed = 0;
for (const song of songs) {
processed++;
const progress = `[${processed}/${songs.length}]`;
// Skip if year exists and not forcing
if (song.releaseYear && !FORCE_UPDATE) {
// console.log(`${progress} Skipping "${song.title}" (Year: ${song.releaseYear})`);
skipped++;
continue;
}
console.log(`${progress} Processing: "${song.title}" by "${song.artist}"`);
const cleanArtist = cleanSearchTerm(song.artist);
const cleanTitle = cleanSearchTerm(song.title);
console.log(` → Searching: "${cleanTitle}" by "${cleanArtist}"`);
// 3. Query iTunes with Retry Logic
let year = null;
let retries = 0;
const MAX_RETRIES = 3;
while (retries < MAX_RETRIES) {
try {
const term = encodeURIComponent(`${cleanArtist} ${cleanTitle}`);
const itunesUrl = `https://itunes.apple.com/search?term=${term}&entity=song&limit=5`;
const res = await fetch(itunesUrl, {
headers: {
'User-Agent': USER_AGENT,
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.9'
}
});
if (res.status === 403 || res.status === 429) {
console.warn(` ⚠️ iTunes Rate Limit (${res.status}). Pausing for 60s...`);
await sleep(60000);
retries++;
continue;
}
if (!res.ok) {
console.error(` ❌ iTunes Error: ${res.status}`);
break;
}
const data = await res.json();
if (data.resultCount > 0) {
// Simple extraction logic (same as lib/itunes.ts)
let earliestYear = null;
const normalizedTitle = song.title.toLowerCase().replace(/[^\w\s]/g, '');
const normalizedArtist = song.artist.toLowerCase().replace(/[^\w\s]/g, '');
for (const result of data.results) {
const resTitle = result.trackName.toLowerCase().replace(/[^\w\s]/g, '');
const resArtist = result.artistName.toLowerCase().replace(/[^\w\s]/g, '');
if (resTitle.includes(normalizedTitle) && resArtist.includes(normalizedArtist)) {
if (result.releaseDate) {
const y = new Date(result.releaseDate).getFullYear();
if (!isNaN(y) && (earliestYear === null || y < earliestYear)) {
earliestYear = y;
}
}
}
}
year = earliestYear;
}
break; // Success
} catch (e) {
console.error(` ❌ Network Error: ${e.message}`);
retries++;
await sleep(5000);
}
}
if (year) {
if (year !== song.releaseYear) {
console.log(` ✅ Found Year: ${year} (Old: ${song.releaseYear})`);
// 4. Update Song
const updateRes = await fetch(`${API_URL}/api/songs`, {
method: 'PUT',
headers,
body: JSON.stringify({
id: song.id,
title: song.title,
artist: song.artist,
releaseYear: year
})
});
if (updateRes.ok) {
updated++;
} else {
console.error(` ❌ Failed to update API: ${updateRes.status}`);
failed++;
}
} else {
console.log(` Create (No Change): ${year}`);
skipped++;
}
} else {
console.log(` ⚠️ No year found.`);
failed++;
}
// Rate Limit Delay (15s = 4 req/min)
await sleep(15000);
}
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
console.log('✅ Done!');
console.log(`Updated: ${updated} | Skipped: ${skipped} | Failed: ${failed}`);
} catch (error) {
console.error('❌ Fatal Error:', error);
}
}
main();