Compare commits

...

43 Commits

Author SHA1 Message Date
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
Hördle Bot
3630745169 Merge security-audit-improvements: comprehensive security enhancements 2025-11-24 09:46:16 +01:00
Hördle Bot
831adcaf17 Add logout function and ADMIN_PASSWORD environment validation 2025-11-24 09:42:58 +01:00
Hördle Bot
2d6481a42f Security audit improvements: authentication, path traversal protection, file validation, rate limiting, security headers 2025-11-24 09:34:54 +01:00
Hördle Bot
0f7d66c619 Add automatic baseline fallback to docker-compose command 2025-11-23 22:49:02 +01:00
Hördle Bot
bc36a09b81 Add baseline script for production database migration history 2025-11-23 22:47:36 +01:00
Hördle Bot
a71afa4f6f Fix: Add release year migration to docker-compose command chain 2025-11-23 22:43:37 +01:00
Hördle Bot
ceae0266b8 Fix: Enhance release year migration with fuzzy search, more retries, and detailed logging 2025-11-23 22:35:10 +01:00
Hördle Bot
27ed9eedb2 Fix: Improve release year migration reliability (retry on restart, better logging, slower rate limit) 2025-11-23 22:29:24 +01:00
Hördle Bot
015862ce0c Fix: Prevent bonus round reappearance on reload and enable autoplay after wrong guess 2025-11-23 22:23:51 +01:00
Hördle Bot
4d807c77d0 Fix: Remove .release-years-migrated from git to ensure migration runs on deployment 2025-11-23 20:37:48 +01:00
Hördle Bot
7b975dc3e3 Finalize scoring system, release year integration, and fix song deletion 2025-11-23 20:37:23 +01:00
Hördle Bot
e5b0512884 chore: remove unnecessary console logs 2025-11-23 17:17:21 +01:00
Hördle Bot
e9a8c41a7d feat: Add descriptions for duplicate detection and intelligent artist recognition to Admin Dashboard features. 2025-11-23 16:08:46 +01:00
Hördle Bot
d280106336 Add duplicate detection with fuzzy matching and fix artist metadata extraction 2025-11-23 16:06:55 +01:00
Hördle Bot
d75910ecc5 docs: Add iFrame embedding instructions to README. 2025-11-23 15:38:53 +01:00
Hördle Bot
d09dbece5f Fix upcoming specials display on genre pages 2025-11-23 15:35:34 +01:00
Hördle Bot
80e6066c17 Add subtitles to Genres and Specials 2025-11-23 15:20:12 +01:00
Hördle Bot
b8321cef56 Show favicon as fallback when cover image is missing 2025-11-23 14:43:35 +01:00
42 changed files with 2141 additions and 194 deletions

View File

@@ -0,0 +1,47 @@
---
description: Add subtitles to Genres and Specials
---
# Implementation Plan - Add Subtitles to Genres and Specials
The goal is to add a `subtitle` field to both `Genre` and `Special` models, allowing administrators to provide descriptions. These subtitles will be displayed as tooltips on the homepage.
## 1. Database Schema Changes
- [ ] Modify `prisma/schema.prisma`:
- Add `subtitle String?` to the `Genre` model.
- Add `subtitle String?` to the `Special` model.
- [ ] Create a migration: `npx prisma migrate dev --name add_subtitles`
## 2. Backend API Updates
- [ ] Update `app/api/genres/route.ts`:
- Update `POST` to accept `subtitle`.
- Add `PUT` method to allow updating genre name and subtitle.
- [ ] Update `app/api/specials/route.ts`:
- Update `POST` to accept `subtitle`.
- Update `PUT` to accept `subtitle`.
## 3. Admin UI Updates
- [ ] Update `app/admin/page.tsx`:
- **Genres**:
- Update the "Add Genre" form to include an input for `subtitle`.
- Add an "Edit" button for each genre.
- Implement a form/modal to edit genre name and subtitle.
- Display the subtitle in the list of genres.
- **Specials**:
- Update the "Create Special" form to include an input for `subtitle`.
- Update the "Edit Special" form (in the conditional rendering) to include `subtitle`.
- [ ] Update `app/admin/specials/[id]/page.tsx`:
- Update the display to show the subtitle under the title.
## 4. Frontend Updates
- [ ] Update `app/page.tsx`:
- Fetch `subtitle` for genres and specials (already covered by `findMany`).
- Add a tooltip to the links.
- For `Link` components, we can use the `title` attribute for a native tooltip, or build a custom CSS tooltip. The user asked for "gut lesbarer Tooltip" (readable tooltip). Native `title` is often small and delayed. A custom CSS tooltip (using a group/hover pattern) would be better.
- I will implement a simple CSS-based tooltip component or style.
## 5. Verification
- [ ] Verify database migration.
- [ ] Verify creating a genre with a subtitle.
- [ ] Verify creating/editing a special with a subtitle.
- [ ] Verify tooltips on the homepage.

2
.gitignore vendored
View File

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

View File

@@ -13,9 +13,16 @@ RUN npm ci
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
# Install git to extract version information
RUN apk add --no-cache git
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Extract version from git
RUN git describe --tags --always 2>/dev/null > /tmp/version.txt || echo "unknown" > /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 +60,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

109
README.md
View File

@@ -8,20 +8,26 @@ Eine Web-App inspiriert von Heardle, bei der Nutzer täglich einen Song anhand k
- **Inkrementelle Hinweise:** Startet mit 2 Sekunden, dann 4s, 7s, 11s, 16s, 30s, bis 60s (7 Versuche).
- **Admin Dashboard:**
- Upload von MP3-Dateien.
- **Duplikatserkennung:** Automatische Erkennung von bereits vorhandenen Songs mit Fuzzy-Matching (toleriert Variationen wie "AC/DC" vs "AC DC").
- 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.
@@ -37,6 +43,19 @@ Eine Web-App inspiriert von Heardle, bei der Nutzer täglich einen Song anhand k
- Einzelne Segmente zum Testen abspielen.
- Manuelle Speicherung mit visueller Bestätigung.
## Spielregeln & Punktesystem
Das Ziel ist es, den Song mit so wenigen Hinweisen wie möglich zu erraten und dabei einen möglichst hohen Highscore zu erzielen.
- **Start-Punktestand:** 90 Punkte
- **Richtige Antwort:** +20 Punkte
- **Falsche Antwort:** -3 Punkte
- **Überspringen (Skip):** -5 Punkte
- **Snippet erneut abspielen (Replay):** -1 Punkt
- **Bonus-Runde (Release-Jahr erraten):** +10 Punkte (0 bei falscher Antwort)
- **Aufgeben / Verloren:** Der Punktestand wird auf 0 gesetzt.
- **Minimum:** Der Punktestand kann nicht unter 0 fallen.
## Tech Stack
- **Framework:** Next.js 16 (App Router)
@@ -154,6 +173,92 @@ server {
Eine vollständige Beispiel-Konfiguration findest du in `nginx.conf.example`.
## iFrame-Einbindung
Hördle kann problemlos als iFrame in andere Webseiten eingebettet werden. Die App ist responsive und passt sich automatisch an die iFrame-Größe an.
### Grundlegende Einbindung
```html
<iframe
src="https://hoerdle.elpatron.me"
width="100%"
height="800"
frameborder="0"
allow="autoplay"
title="Hördle - Daily Music Quiz">
</iframe>
```
### Genre-spezifische Einbindung
Einzelne Genres können direkt eingebunden werden:
```html
<!-- Rock Genre -->
<iframe
src="https://hoerdle.elpatron.me/Rock"
width="100%"
height="800"
frameborder="0"
allow="autoplay"
title="Hördle Rock Quiz">
</iframe>
<!-- Pop Genre -->
<iframe
src="https://hoerdle.elpatron.me/Pop"
width="100%"
height="800"
frameborder="0"
allow="autoplay"
title="Hördle Pop Quiz">
</iframe>
```
### Special-Einbindung
Auch thematische Specials können direkt eingebettet werden:
```html
<iframe
src="https://hoerdle.elpatron.me/special/Weihnachtslieder"
width="100%"
height="800"
frameborder="0"
allow="autoplay"
title="Hördle Weihnachts-Special">
</iframe>
```
### Empfohlene Einstellungen
- **Mindesthöhe:** 800px (damit alle Elemente sichtbar sind)
- **Breite:** 100% oder mindestens 600px
- **`allow="autoplay"`:** Erforderlich für Audio-Wiedergabe
- **Responsive:** Die App passt sich automatisch an mobile Geräte an
### Beispiel mit responsiver Höhe
```html
<div style="position: relative; padding-bottom: 133%; height: 0; overflow: hidden;">
<iframe
src="https://hoerdle.elpatron.me"
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;"
frameborder="0"
allow="autoplay"
title="Hördle">
</iframe>
</div>
```
### Hinweise
- Der Spielfortschritt wird im LocalStorage des iFrames gespeichert
- Nutzer können innerhalb des iFrames zwischen Genres wechseln (Navigation bleibt erhalten)
- Die Teilen-Funktion funktioniert auch im iFrame
- Für beste Performance sollte der iFrame auf derselben Domain wie die Hauptseite gehostet werden (vermeidet CORS-Probleme)
## Troubleshooting
### Audio-Dateien lassen sich nicht abspielen (in Produktion mit Nginx)

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,10 +15,34 @@ 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();
const activeSpecials = specials.filter(s => {
const isStarted = !s.launchDate || s.launchDate <= now;
const isEnded = s.endDate && s.endDate < now;
return isStarted && !isEnded;
});
const upcomingSpecials = specials.filter(s => {
return s.launchDate && s.launchDate > now;
});
return (
<>
<div style={{ textAlign: 'center', padding: '1rem', background: '#f3f4f6' }}>
@@ -40,12 +65,12 @@ export default async function GenrePage({ params }: PageProps) {
))}
{/* Separator if both exist */}
{genres.length > 0 && specials.length > 0 && (
{genres.length > 0 && activeSpecials.length > 0 && (
<span style={{ color: '#d1d5db' }}>|</span>
)}
{/* Specials */}
{specials.map(s => (
{activeSpecials.map(s => (
<Link
key={s.id}
href={`/special/${s.name}`}
@@ -59,6 +84,23 @@ export default async function GenrePage({ params }: PageProps) {
</Link>
))}
</div>
{/* Upcoming Specials */}
{upcomingSpecials.length > 0 && (
<div style={{ marginTop: '0.5rem', fontSize: '0.875rem', color: '#666' }}>
Coming soon: {upcomingSpecials.map(s => (
<span key={s.id} style={{ marginLeft: '0.5rem' }}>
{s.name} ({s.launchDate ? new Date(s.launchDate).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
timeZone: process.env.TZ
}) : ''})
{s.curator && <span style={{ fontStyle: 'italic', marginLeft: '0.25rem' }}>Curated by {s.curator}</span>}
</span>
))}
</div>
)}
</div>
<Game dailyPuzzle={dailyPuzzle} genre={decodedGenre} />
</>

View File

@@ -3,13 +3,14 @@
const GOTIFY_URL = process.env.GOTIFY_URL;
const GOTIFY_APP_TOKEN = process.env.GOTIFY_APP_TOKEN;
export async function sendGotifyNotification(attempts: number, status: 'won' | 'lost', puzzleId: number, genre?: string | null) {
export async function sendGotifyNotification(attempts: number, status: 'won' | 'lost', puzzleId: number, genre?: string | null, score?: number) {
try {
const genreText = genre ? `[${genre}] ` : '';
const title = `Hördle ${genreText}#${puzzleId} ${status === 'won' ? 'Solved!' : 'Failed'}`;
const scoreText = score !== undefined ? ` with a score of ${score}` : '';
const message = status === 'won'
? `Puzzle #${puzzleId} ${genre ? `(${genre}) ` : ''}was solved in ${attempts} attempt(s).`
: `Puzzle #${puzzleId} ${genre ? `(${genre}) ` : ''}was failed after ${attempts} attempt(s).`;
? `Puzzle #${puzzleId} ${genre ? `(${genre}) ` : ''}was solved in ${attempts} attempt(s)${scoreText}.`
: `Puzzle #${puzzleId} ${genre ? `(${genre}) ` : ''}was failed after ${attempts} attempt(s)${scoreText}.`;
const response = await fetch(`${GOTIFY_URL}/message?token=${GOTIFY_APP_TOKEN}`, {
method: 'POST',

View File

@@ -1,11 +1,12 @@
'use client';
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
interface Special {
id: number;
name: string;
subtitle?: string;
maxAttempts: number;
unlockSteps: string;
launchDate?: string;
@@ -19,6 +20,8 @@ interface Special {
interface Genre {
id: number;
name: string;
subtitle?: string;
active: boolean;
_count?: {
songs: number;
};
@@ -38,15 +41,17 @@ interface Song {
artist: string;
filename: string;
createdAt: string;
releaseYear: number | null;
activations: number;
puzzles: DailyPuzzle[];
genres: Genre[];
specials: Special[];
averageRating: number;
ratingCount: number;
excludeFromGlobal: boolean;
}
type SortField = 'id' | 'title' | 'artist' | 'createdAt';
type SortField = 'id' | 'title' | 'artist' | 'createdAt' | 'releaseYear' | 'activations' | 'averageRating';
type SortDirection = 'asc' | 'desc';
export default function AdminPage() {
@@ -61,10 +66,17 @@ export default function AdminPage() {
const [songs, setSongs] = useState<Song[]>([]);
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[]>([]);
const [newSpecialName, setNewSpecialName] = useState('');
const [newSpecialSubtitle, setNewSpecialSubtitle] = useState('');
const [newSpecialMaxAttempts, setNewSpecialMaxAttempts] = useState(7);
const [newSpecialUnlockSteps, setNewSpecialUnlockSteps] = useState('[2,4,7,11,16,30,60]');
const [newSpecialLaunchDate, setNewSpecialLaunchDate] = useState('');
@@ -73,6 +85,7 @@ export default function AdminPage() {
const [editingSpecialId, setEditingSpecialId] = useState<number | null>(null);
const [editSpecialName, setEditSpecialName] = useState('');
const [editSpecialSubtitle, setEditSpecialSubtitle] = useState('');
const [editSpecialMaxAttempts, setEditSpecialMaxAttempts] = useState(7);
const [editSpecialUnlockSteps, setEditSpecialUnlockSteps] = useState('[2,4,7,11,16,30,60]');
const [editSpecialLaunchDate, setEditSpecialLaunchDate] = useState('');
@@ -83,12 +96,18 @@ export default function AdminPage() {
const [editingId, setEditingId] = useState<number | null>(null);
const [editTitle, setEditTitle] = useState('');
const [editArtist, setEditArtist] = useState('');
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);
@@ -113,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(() => {
@@ -141,8 +161,30 @@ export default function AdminPage() {
}
};
const handleLogout = () => {
localStorage.removeItem('hoerdle_admin_auth');
setIsAuthenticated(false);
setPassword('');
// Reset all state
setSongs([]);
setGenres([]);
setSpecials([]);
setDailyPuzzles([]);
};
// Helper function to add auth headers to requests
const getAuthHeaders = () => {
const authToken = localStorage.getItem('hoerdle_admin_auth');
return {
'Content-Type': 'application/json',
'x-admin-auth': authToken || ''
};
};
const fetchSongs = async () => {
const res = await fetch('/api/songs');
const res = await fetch('/api/songs', {
headers: getAuthHeaders()
});
if (res.ok) {
const data = await res.json();
setSongs(data);
@@ -150,7 +192,9 @@ export default function AdminPage() {
};
const fetchGenres = async () => {
const res = await fetch('/api/genres');
const res = await fetch('/api/genres', {
headers: getAuthHeaders()
});
if (res.ok) {
const data = await res.json();
setGenres(data);
@@ -161,19 +205,55 @@ export default function AdminPage() {
if (!newGenreName.trim()) return;
const res = await fetch('/api/genres', {
method: 'POST',
body: JSON.stringify({ name: newGenreName }),
headers: getAuthHeaders(),
body: JSON.stringify({
name: newGenreName,
subtitle: newGenreSubtitle,
active: newGenreActive
}),
});
if (res.ok) {
setNewGenreName('');
setNewGenreSubtitle('');
setNewGenreActive(true);
fetchGenres();
} else {
alert('Failed to create genre');
}
};
const startEditGenre = (genre: Genre) => {
setEditingGenreId(genre.id);
setEditGenreName(genre.name);
setEditGenreSubtitle(genre.subtitle || '');
setEditGenreActive(genre.active !== undefined ? genre.active : true);
};
const saveEditedGenre = async () => {
if (editingGenreId === null) return;
const res = await fetch('/api/genres', {
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify({
id: editingGenreId,
name: editGenreName,
subtitle: editGenreSubtitle,
active: editGenreActive
}),
});
if (res.ok) {
setEditingGenreId(null);
fetchGenres();
} else {
alert('Failed to update genre');
}
};
// Specials functions
const fetchSpecials = async () => {
const res = await fetch('/api/specials');
const res = await fetch('/api/specials', {
headers: getAuthHeaders()
});
if (res.ok) {
const data = await res.json();
setSpecials(data);
@@ -184,9 +264,10 @@ export default function AdminPage() {
e.preventDefault();
const res = await fetch('/api/specials', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: getAuthHeaders(),
body: JSON.stringify({
name: newSpecialName,
subtitle: newSpecialSubtitle,
maxAttempts: newSpecialMaxAttempts,
unlockSteps: newSpecialUnlockSteps,
launchDate: newSpecialLaunchDate || null,
@@ -196,6 +277,7 @@ export default function AdminPage() {
});
if (res.ok) {
setNewSpecialName('');
setNewSpecialSubtitle('');
setNewSpecialMaxAttempts(7);
setNewSpecialUnlockSteps('[2,4,7,11,16,30,60]');
setNewSpecialLaunchDate('');
@@ -211,7 +293,7 @@ export default function AdminPage() {
if (!confirm('Delete this special?')) return;
const res = await fetch('/api/specials', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
headers: getAuthHeaders(),
body: JSON.stringify({ id }),
});
if (res.ok) fetchSpecials();
@@ -220,7 +302,9 @@ export default function AdminPage() {
// Daily Puzzles functions
const fetchDailyPuzzles = async () => {
const res = await fetch('/api/admin/daily-puzzles');
const res = await fetch('/api/admin/daily-puzzles', {
headers: getAuthHeaders()
});
if (res.ok) {
const data = await res.json();
setDailyPuzzles(data);
@@ -231,7 +315,7 @@ export default function AdminPage() {
if (!confirm('Delete this daily puzzle? A new one will be generated automatically.')) return;
const res = await fetch('/api/admin/daily-puzzles', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
headers: getAuthHeaders(),
body: JSON.stringify({ puzzleId }),
});
if (res.ok) {
@@ -278,6 +362,7 @@ export default function AdminPage() {
const startEditSpecial = (special: Special) => {
setEditingSpecialId(special.id);
setEditSpecialName(special.name);
setEditSpecialSubtitle(special.subtitle || '');
setEditSpecialMaxAttempts(special.maxAttempts);
setEditSpecialUnlockSteps(special.unlockSteps);
setEditSpecialLaunchDate(special.launchDate ? new Date(special.launchDate).toISOString().split('T')[0] : '');
@@ -289,10 +374,11 @@ export default function AdminPage() {
if (editingSpecialId === null) return;
const res = await fetch('/api/specials', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
headers: getAuthHeaders(),
body: JSON.stringify({
id: editingSpecialId,
name: editSpecialName,
subtitle: editSpecialSubtitle,
maxAttempts: editSpecialMaxAttempts,
unlockSteps: editSpecialUnlockSteps,
launchDate: editSpecialLaunchDate || null,
@@ -317,6 +403,7 @@ export default function AdminPage() {
if (!confirm('Delete this genre?')) return;
const res = await fetch('/api/genres', {
method: 'DELETE',
headers: getAuthHeaders(),
body: JSON.stringify({ id }),
});
if (res.ok) {
@@ -343,7 +430,7 @@ export default function AdminPage() {
while (hasMore) {
const res = await fetch('/api/categorize', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: getAuthHeaders(),
body: JSON.stringify({ offset })
});
@@ -408,34 +495,55 @@ 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',
headers: { 'x-admin-auth': localStorage.getItem('hoerdle_admin_auth') || '' },
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,
song: data.song,
validation: data.validation
});
} else {
} 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,
error: 'Upload failed'
isDuplicate: true,
duplicate: data.duplicate,
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 (${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'}`
});
}
}
@@ -443,48 +551,139 @@ 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;
const duplicateCount = results.filter(r => r.isDuplicate).length;
const failedCount = results.filter(r => !r.success && !r.isDuplicate).length;
if (successCount > 0) {
setMessage(`✅ Uploaded ${successCount}/${files.length} songs successfully!\n\n🤖 Starting auto-categorization...`);
let msg = `✅ Uploaded ${successCount}/${files.length} songs successfully!`;
if (duplicateCount > 0) {
msg += `\n⚠ Skipped ${duplicateCount} duplicate(s)`;
}
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
setTimeout(() => {
handleAICategorization();
}, 1000);
} else if (duplicateCount > 0 && failedCount === 0) {
setMessage(`⚠️ All ${duplicateCount} file(s) were duplicates - nothing uploaded.`);
} else {
setMessage(`❌ All uploads failed.`);
}
};
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);
}
}
};
@@ -493,7 +692,7 @@ export default function AdminPage() {
const res = await fetch('/api/songs', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
headers: getAuthHeaders(),
body: JSON.stringify({
id: uploadedSong.id,
title: uploadedSong.title,
@@ -507,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');
@@ -517,28 +717,34 @@ export default function AdminPage() {
setEditingId(song.id);
setEditTitle(song.title);
setEditArtist(song.artist);
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 = () => {
setEditingId(null);
setEditTitle('');
setEditArtist('');
setEditReleaseYear('');
setEditGenreIds([]);
setEditSpecialIds([]);
setEditExcludeFromGlobal(false);
};
const saveEditing = async (id: number) => {
const res = await fetch('/api/songs', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
headers: getAuthHeaders(),
body: JSON.stringify({
id,
title: editTitle,
artist: editArtist,
releaseYear: editReleaseYear === '' ? null : Number(editReleaseYear),
genreIds: editGenreIds,
specialIds: editSpecialIds
specialIds: editSpecialIds,
excludeFromGlobal: editExcludeFromGlobal
}),
});
@@ -546,6 +752,7 @@ export default function AdminPage() {
setEditingId(null);
fetchSongs();
fetchGenres();
fetchSpecials(); // Update special counts
} else {
alert('Failed to update song');
}
@@ -558,13 +765,14 @@ export default function AdminPage() {
const res = await fetch('/api/songs', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
headers: getAuthHeaders(),
body: JSON.stringify({ id }),
});
if (res.ok) {
fetchSongs();
fetchGenres();
fetchSpecials(); // Update special counts
} else {
alert('Failed to delete song');
}
@@ -639,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;
}
}
@@ -646,10 +856,21 @@ export default function AdminPage() {
});
const sortedSongs = [...filteredSongs].sort((a, b) => {
// Handle numeric sorting for ID
// Handle numeric sorting for ID, Release Year, Activations, and Rating
if (sortField === 'id') {
return sortDirection === 'asc' ? a.id - b.id : b.id - a.id;
}
if (sortField === 'releaseYear') {
const yearA = a.releaseYear || 0;
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();
@@ -689,7 +910,24 @@ export default function AdminPage() {
return (
<div className="admin-container">
<h1 className="title" style={{ marginBottom: '2rem' }}>Hördle Admin Dashboard</h1>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2rem' }}>
<h1 className="title" style={{ margin: 0 }}>Hördle Admin Dashboard</h1>
<button
onClick={handleLogout}
className="btn-secondary"
style={{
padding: '0.5rem 1rem',
backgroundColor: '#dc3545',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '0.9rem'
}}
>
🚪 Logout
</button>
</div>
{/* Special Management */}
<div className="admin-card" style={{ marginBottom: '2rem' }}>
@@ -700,6 +938,10 @@ export default function AdminPage() {
<label style={{ fontSize: '0.75rem', color: '#666' }}>Name</label>
<input type="text" placeholder="Special name" value={newSpecialName} onChange={e => setNewSpecialName(e.target.value)} className="form-input" required />
</div>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<label style={{ fontSize: '0.75rem', color: '#666' }}>Subtitle</label>
<input type="text" placeholder="Subtitle" value={newSpecialSubtitle} onChange={e => setNewSpecialSubtitle(e.target.value)} className="form-input" />
</div>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<label style={{ fontSize: '0.75rem', color: '#666' }}>Max Attempts</label>
<input type="number" placeholder="Max attempts" value={newSpecialMaxAttempts} onChange={e => setNewSpecialMaxAttempts(Number(e.target.value))} className="form-input" min={1} style={{ width: '80px' }} />
@@ -735,6 +977,7 @@ export default function AdminPage() {
fontSize: '0.875rem'
}}>
<span>{special.name} ({special._count?.songs || 0})</span>
{special.subtitle && <span style={{ fontSize: '0.75rem', color: '#666', marginLeft: '0.25rem' }}>- {special.subtitle}</span>}
<a href={`/admin/specials/${special.id}`} className="btn-primary" style={{ marginRight: '0.5rem', textDecoration: 'none' }}>Curate</a>
<button onClick={() => startEditSpecial(special)} className="btn-secondary" style={{ marginRight: '0.5rem' }}>Edit</button>
<button onClick={() => handleDeleteSpecial(special.id)} className="btn-danger">Delete</button>
@@ -749,6 +992,10 @@ export default function AdminPage() {
<label style={{ fontSize: '0.75rem', color: '#666' }}>Name</label>
<input type="text" value={editSpecialName} onChange={e => setEditSpecialName(e.target.value)} className="form-input" />
</div>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<label style={{ fontSize: '0.75rem', color: '#666' }}>Subtitle</label>
<input type="text" value={editSpecialSubtitle} onChange={e => setEditSpecialSubtitle(e.target.value)} className="form-input" />
</div>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<label style={{ fontSize: '0.75rem', color: '#666' }}>Max Attempts</label>
<input type="number" value={editSpecialMaxAttempts} onChange={e => setEditSpecialMaxAttempts(Number(e.target.value))} className="form-input" min={1} style={{ width: '80px' }} />
@@ -779,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}
@@ -788,12 +1035,29 @@ export default function AdminPage() {
className="form-input"
style={{ maxWidth: '200px' }}
/>
<input
type="text"
value={newGenreSubtitle}
onChange={e => setNewGenreSubtitle(e.target.value)}
placeholder="Subtitle"
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',
@@ -802,15 +1066,39 @@ export default function AdminPage() {
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>
{genre.subtitle && <span style={{ fontSize: '0.75rem', color: '#666' }}>- {genre.subtitle}</span>}
<button onClick={() => startEditGenre(genre)} className="btn-secondary" style={{ padding: '0.1rem 0.5rem', fontSize: '0.75rem' }}>Edit</button>
<button onClick={() => deleteGenre(genre.id)} className="btn-danger" style={{ padding: '0.1rem 0.5rem', fontSize: '0.75rem' }}>×</button>
</div>
))}
</div>
{editingGenreId !== null && (
<div style={{ marginTop: '1rem', padding: '1rem', background: '#f9fafb', borderRadius: '0.5rem' }}>
<h3>Edit Genre</h3>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'flex-end' }}>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<label style={{ fontSize: '0.75rem', color: '#666' }}>Name</label>
<input type="text" value={editGenreName} onChange={e => setEditGenreName(e.target.value)} className="form-input" />
</div>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<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>
</div>
)}
{/* AI Categorization */}
<div style={{ marginTop: '1.5rem', paddingTop: '1rem', borderTop: '1px solid #e5e7eb' }}>
@@ -899,6 +1187,7 @@ export default function AdminPage() {
<form onSubmit={handleBatchUpload}>
{/* Drag & Drop Zone */}
<div
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
@@ -912,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' }}>
@@ -922,7 +1211,7 @@ export default function AdminPage() {
or click to browse
</p>
<input
id="file-input"
ref={fileInputRef}
type="file"
accept="audio/mpeg"
multiple
@@ -962,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"
@@ -1082,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 => (
@@ -1132,18 +1479,33 @@ export default function AdminPage() {
style={{ padding: '0.75rem', cursor: 'pointer', userSelect: 'none' }}
onClick={() => handleSort('title')}
>
Title {sortField === 'title' && (sortDirection === 'asc' ? '' : '')}
Song {sortField === 'title' && (sortDirection === 'asc' ? '' : '')}
</th>
<th style={{ padding: '0.75rem' }}>Genres</th>
<th
style={{ padding: '0.75rem', cursor: 'pointer', userSelect: 'none' }}
onClick={() => handleSort('releaseYear')}
>
Year {sortField === 'releaseYear' && (sortDirection === 'asc' ? '' : '')}
</th>
<th style={{ padding: '0.75rem' }}>Genres / Specials</th>
<th
style={{ padding: '0.75rem', cursor: 'pointer', userSelect: 'none' }}
onClick={() => handleSort('createdAt')}
>
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>
@@ -1172,6 +1534,16 @@ export default function AdminPage() {
placeholder="Artist"
/>
</td>
<td style={{ padding: '0.75rem' }}>
<input
type="number"
value={editReleaseYear}
onChange={e => setEditReleaseYear(e.target.value === '' ? '' : Number(e.target.value))}
className="form-input"
style={{ padding: '0.25rem', width: '80px' }}
placeholder="Year"
/>
</td>
<td style={{ padding: '0.75rem' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
{genres.map(genre => (
@@ -1209,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')}
@@ -1248,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 => {
@@ -1278,6 +1678,9 @@ export default function AdminPage() {
})}
</div>
</td>
<td style={{ padding: '0.75rem', color: '#666' }}>
{song.releaseYear || '-'}
</td>
<td style={{ padding: '0.75rem' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
{song.genres?.map(g => (
@@ -1433,6 +1836,8 @@ export default function AdminPage() {
>
Rebuild Database
</button>
</div>
</div>
);

View File

@@ -22,6 +22,7 @@ interface SpecialSong {
interface Special {
id: number;
name: string;
subtitle?: string;
maxAttempts: number;
unlockSteps: string;
songs: SpecialSong[];
@@ -139,6 +140,11 @@ export default function SpecialEditorPage() {
<h1 style={{ fontSize: '2rem', fontWeight: 'bold' }}>
Edit Special: {special.name}
</h1>
{special.subtitle && (
<p style={{ fontSize: '1.125rem', color: '#4b5563', marginTop: '0.25rem' }}>
{special.subtitle}
</p>
)}
<p style={{ color: '#666', marginTop: '0.5rem' }}>
Max Attempts: {special.maxAttempts} | Puzzle Duration: {totalDuration}s
</p>

View File

@@ -1,5 +1,6 @@
import { NextResponse } from 'next/server';
import { PrismaClient } from '@prisma/client';
import { requireAdminAuth } from '@/lib/auth';
const prisma = new PrismaClient();
@@ -63,6 +64,10 @@ export async function GET() {
}
export async function DELETE(request: Request) {
// Check authentication
const authError = await requireAdminAuth(request as any);
if (authError) return authError;
try {
const { puzzleId } = await request.json();

View File

@@ -1,7 +1,12 @@
import { NextResponse } from 'next/server';
import { NextRequest, NextResponse } from 'next/server';
import bcrypt from 'bcryptjs';
import { rateLimit } from '@/lib/rateLimit';
export async function POST(request: NextRequest) {
// Rate limiting: 5 login attempts per minute
const rateLimitError = rateLimit(request, { windowMs: 60000, maxRequests: 5 });
if (rateLimitError) return rateLimitError;
export async function POST(request: Request) {
try {
const { password } = await request.json();
// Default is hash for 'admin123'

View File

@@ -8,8 +8,28 @@ export async function GET(
) {
try {
const { filename } = await params;
// Security: Prevent path traversal attacks
// Only allow alphanumeric, hyphens, underscores, and dots
const safeFilenamePattern = /^[a-zA-Z0-9_\-\.]+\.mp3$/;
if (!safeFilenamePattern.test(filename)) {
return new NextResponse('Invalid filename', { status: 400 });
}
// Additional check: ensure no path separators
if (filename.includes('/') || filename.includes('\\') || filename.includes('..')) {
return new NextResponse('Invalid filename', { status: 400 });
}
const filePath = path.join(process.cwd(), 'public/uploads', filename);
// Security: Verify the resolved path is still within uploads directory
const uploadsDir = path.join(process.cwd(), 'public/uploads');
const resolvedPath = path.resolve(filePath);
if (!resolvedPath.startsWith(uploadsDir)) {
return new NextResponse('Forbidden', { status: 403 });
}
// Check if file exists
try {
await stat(filePath);

View File

@@ -1,6 +1,7 @@
'use server';
import { PrismaClient } from '@prisma/client';
import { requireAdminAuth } from '@/lib/auth';
const prisma = new PrismaClient();
@@ -16,6 +17,10 @@ interface CategorizeResult {
}
export async function POST(request: Request) {
// Check authentication
const authError = await requireAdminAuth(request as any);
if (authError) return authError;
try {
if (!OPENROUTER_API_KEY) {
return Response.json(

View File

@@ -1,5 +1,6 @@
import { NextResponse } from 'next/server';
import { PrismaClient } from '@prisma/client';
import { requireAdminAuth } from '@/lib/auth';
const prisma = new PrismaClient();
@@ -21,15 +22,23 @@ export async function GET() {
}
export async function POST(request: Request) {
// Check authentication
const authError = await requireAdminAuth(request as any);
if (authError) return authError;
try {
const { name } = await request.json();
const { name, subtitle, active } = await request.json();
if (!name || typeof name !== 'string') {
return NextResponse.json({ error: 'Invalid name' }, { status: 400 });
}
const genre = await prisma.genre.create({
data: { name: name.trim() },
data: {
name: name.trim(),
subtitle: subtitle ? subtitle.trim() : null,
active: active !== undefined ? active : true
},
});
return NextResponse.json(genre);
@@ -40,6 +49,10 @@ export async function POST(request: Request) {
}
export async function DELETE(request: Request) {
// Check authentication
const authError = await requireAdminAuth(request as any);
if (authError) return authError;
try {
const { id } = await request.json();
@@ -57,3 +70,31 @@ export async function DELETE(request: Request) {
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}
export async function PUT(request: Request) {
// Check authentication
const authError = await requireAdminAuth(request as any);
if (authError) return authError;
try {
const { id, name, subtitle, active } = await request.json();
if (!id) {
return NextResponse.json({ error: 'Missing id' }, { status: 400 });
}
const genre = await prisma.genre.update({
where: { id: Number(id) },
data: {
...(name && { name: name.trim() }),
subtitle: subtitle ? subtitle.trim() : null,
...(active !== undefined && { active })
},
});
return NextResponse.json(genre);
} catch (error) {
console.error('Error updating genre:', error);
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}

View File

@@ -3,9 +3,15 @@ import { PrismaClient } from '@prisma/client';
import { writeFile, unlink } from 'fs/promises';
import path from 'path';
import { parseBuffer } from 'music-metadata';
import { isDuplicateSong } from '@/lib/fuzzyMatch';
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' },
@@ -28,29 +34,70 @@ export async function GET() {
filename: song.filename,
createdAt: song.createdAt,
coverImage: song.coverImage,
releaseYear: song.releaseYear,
activations: song.puzzles.length,
puzzles: song.puzzles,
genres: song.genres,
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) {
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 });
}
// Security: Validate file size (max 50MB)
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB
if (file.size > MAX_FILE_SIZE) {
return NextResponse.json({
error: `File too large. Maximum size is 50MB, got ${(file.size / 1024 / 1024).toFixed(2)}MB`
}, { status: 400 });
}
// Security: Validate MIME type
const allowedMimeTypes = ['audio/mpeg', 'audio/mp3'];
if (!allowedMimeTypes.includes(file.type)) {
return NextResponse.json({
error: `Invalid file type. Expected MP3, got ${file.type}`
}, { status: 400 });
}
// Security: Validate file extension
if (!file.name.toLowerCase().endsWith('.mp3')) {
return NextResponse.json({
error: 'Invalid file extension. Only .mp3 files are allowed'
}, { status: 400 });
}
const buffer = Buffer.from(await file.arrayBuffer());
console.log('[UPLOAD] Buffer created, size:', buffer.length, 'bytes');
// Validate and extract metadata from file
let metadata;
@@ -72,8 +119,16 @@ export async function POST(request: Request) {
if (metadata.common.title) {
title = metadata.common.title;
}
if (metadata.common.artist) {
// Handle artist - prefer artists array if available
if (metadata.common.artists && metadata.common.artists.length > 0) {
// Join multiple artists with '/'
artist = metadata.common.artists.join('/');
} else if (metadata.common.artist) {
artist = metadata.common.artist;
} else if (metadata.common.albumartist) {
// Fallback to album artist
artist = metadata.common.albumartist;
}
// Validation info
@@ -114,6 +169,28 @@ export async function POST(request: Request) {
if (!title) title = 'Unknown Title';
if (!artist) artist = 'Unknown Artist';
// Check for duplicates
const existingSongs = await prisma.song.findMany({
select: { id: true, title: true, artist: true, filename: true }
});
for (const existing of existingSongs) {
if (isDuplicateSong(artist, title, existing.artist, existing.title)) {
return NextResponse.json(
{
error: 'Duplicate song detected',
duplicate: {
id: existing.id,
title: existing.title,
artist: existing.artist,
filename: existing.filename
}
},
{ status: 409 }
);
}
}
// Create URL-safe filename
const originalName = file.name.replace(/\.mp3$/i, '');
const sanitizedName = originalName
@@ -148,12 +225,27 @@ export async function POST(request: Request) {
console.error('Failed to extract cover image:', e);
}
// Fetch release year from iTunes
let releaseYear = null;
try {
const { getReleaseYearFromItunes } = await import('@/lib/itunes');
releaseYear = await getReleaseYearFromItunes(artist, title);
if (releaseYear) {
console.log(`Fetched release year ${releaseYear} from iTunes for "${title}" by "${artist}"`);
}
} catch (e) {
console.error('Failed to fetch release year:', e);
}
const song = await prisma.song.create({
data: {
title,
artist,
filename,
coverImage,
releaseYear,
excludeFromGlobal,
},
include: { genres: true, specials: true }
});
@@ -169,8 +261,12 @@ export async function POST(request: Request) {
}
export async function PUT(request: Request) {
// Check authentication
const authError = await requireAdminAuth(request as any);
if (authError) return authError;
try {
const { id, title, artist, 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 });
@@ -178,6 +274,15 @@ export async function PUT(request: Request) {
const data: any = { title, artist };
// Update releaseYear if provided (can be null to clear it)
if (releaseYear !== undefined) {
data.releaseYear = releaseYear;
}
if (excludeFromGlobal !== undefined) {
data.excludeFromGlobal = excludeFromGlobal;
}
if (genreIds) {
data.genres = {
set: genreIds.map((gId: number) => ({ id: gId }))
@@ -239,6 +344,10 @@ export async function PUT(request: Request) {
}
export async function DELETE(request: Request) {
// Check authentication
const authError = await requireAdminAuth(request as any);
if (authError) return authError;
try {
const { id } = await request.json();

View File

@@ -1,5 +1,6 @@
import { PrismaClient, Special } from '@prisma/client';
import { NextResponse } from 'next/server';
import { requireAdminAuth } from '@/lib/auth';
const prisma = new PrismaClient();
@@ -16,13 +17,18 @@ export async function GET() {
}
export async function POST(request: Request) {
const { name, maxAttempts = 7, unlockSteps = '[2,4,7,11,16,30,60]', launchDate, endDate, curator } = await request.json();
// Check authentication
const authError = await requireAdminAuth(request as any);
if (authError) return authError;
const { name, subtitle, maxAttempts = 7, unlockSteps = '[2,4,7,11,16,30,60]', launchDate, endDate, curator } = await request.json();
if (!name) {
return NextResponse.json({ error: 'Name is required' }, { status: 400 });
}
const special = await prisma.special.create({
data: {
name,
subtitle: subtitle || null,
maxAttempts: Number(maxAttempts),
unlockSteps,
launchDate: launchDate ? new Date(launchDate) : null,
@@ -34,6 +40,10 @@ export async function POST(request: Request) {
}
export async function DELETE(request: Request) {
// Check authentication
const authError = await requireAdminAuth(request as any);
if (authError) return authError;
const { id } = await request.json();
if (!id) {
return NextResponse.json({ error: 'ID required' }, { status: 400 });
@@ -43,7 +53,11 @@ export async function DELETE(request: Request) {
}
export async function PUT(request: Request) {
const { id, name, maxAttempts, unlockSteps, launchDate, endDate, curator } = await request.json();
// Check authentication
const authError = await requireAdminAuth(request as any);
if (authError) return authError;
const { id, name, subtitle, maxAttempts, unlockSteps, launchDate, endDate, curator } = await request.json();
if (!id) {
return NextResponse.json({ error: 'ID required' }, { status: 400 });
}
@@ -51,6 +65,7 @@ export async function PUT(request: Request) {
where: { id: Number(id) },
data: {
...(name && { name }),
subtitle: subtitle || null, // Allow clearing or setting
...(maxAttempts && { maxAttempts: Number(maxAttempts) }),
...(unlockSteps && { unlockSteps }),
launchDate: launchDate ? new Date(launchDate) : null,

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

@@ -0,0 +1,47 @@
import { NextResponse } from 'next/server';
import { execSync } from 'child_process';
export async function GET() {
try {
// First check if version is set via environment variable (Docker build)
if (process.env.APP_VERSION) {
return NextResponse.json({ version: process.env.APP_VERSION });
}
// Try to get the git tag/version
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

@@ -410,4 +410,50 @@ body {
font-size: 1.25rem;
font-weight: bold;
color: #000;
}
/* Tooltip */
.tooltip {
position: relative;
display: inline-flex;
flex-direction: column;
align-items: center;
}
.tooltip .tooltip-text {
visibility: hidden;
width: 200px;
background-color: #333;
color: #fff;
text-align: center;
border-radius: 6px;
padding: 5px;
position: absolute;
z-index: 100;
top: 100%;
left: 50%;
margin-left: -100px;
margin-top: 5px;
opacity: 0;
transition: opacity 0.3s;
font-size: 0.75rem;
font-weight: normal;
pointer-events: none;
line-height: 1.2;
}
.tooltip:hover .tooltip-text {
visibility: visible;
opacity: 1;
}
.tooltip .tooltip-text::after {
content: "";
position: absolute;
bottom: 100%;
left: 50%;
margin-left: -5px;
border-width: 5px;
border-style: solid;
border-color: transparent transparent #333 transparent;
}

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();
@@ -28,13 +31,19 @@ export default async function Home() {
<>
<div style={{ textAlign: 'center', padding: '1rem', background: '#f3f4f6' }}>
<div style={{ display: 'flex', justifyContent: 'center', gap: '1rem', flexWrap: 'wrap', alignItems: 'center' }}>
<Link href="/" style={{ fontWeight: 'bold', textDecoration: 'underline' }}>Global</Link>
<div className="tooltip">
<Link href="/" style={{ fontWeight: 'bold', textDecoration: 'underline' }}>Global</Link>
<span className="tooltip-text">A random song from the entire collection</span>
</div>
{/* Genres */}
{genres.map(g => (
<Link key={g.id} href={`/${g.name}`} style={{ color: '#4b5563', textDecoration: 'none' }}>
{g.name}
</Link>
<div key={g.id} className="tooltip">
<Link href={`/${g.name}`} style={{ color: '#4b5563', textDecoration: 'none' }}>
{g.name}
</Link>
{g.subtitle && <span className="tooltip-text">{g.subtitle}</span>}
</div>
))}
{/* Separator if both exist */}
@@ -45,16 +54,19 @@ export default async function Home() {
{/* Active Specials */}
{activeSpecials.map(s => (
<div key={s.id} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<Link
href={`/special/${s.name}`}
style={{
color: '#be185d', // Pink-700
textDecoration: 'none',
fontWeight: '500'
}}
>
{s.name}
</Link>
<div className="tooltip">
<Link
href={`/special/${s.name}`}
style={{
color: '#be185d', // Pink-700
textDecoration: 'none',
fontWeight: '500'
}}
>
{s.name}
</Link>
{s.subtitle && <span className="tooltip-text">{s.subtitle}</span>}
</div>
{s.curator && (
<span style={{ fontSize: '0.75rem', color: '#666' }}>
Curated by {s.curator}

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

@@ -7,13 +7,15 @@ interface AudioPlayerProps {
unlockedSeconds: number; // 2, 4, 7, 11, 16, 30 (or full length)
startTime?: number; // Start offset in seconds (for curated specials)
onPlay?: () => void;
onReplay?: () => void;
autoPlay?: boolean;
}
export default function AudioPlayer({ src, unlockedSeconds, startTime = 0, onPlay, autoPlay = false }: AudioPlayerProps) {
export default function AudioPlayer({ src, unlockedSeconds, startTime = 0, onPlay, onReplay, autoPlay = false }: AudioPlayerProps) {
const audioRef = useRef<HTMLAudioElement>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [progress, setProgress] = useState(0);
const [hasPlayedOnce, setHasPlayedOnce] = useState(false);
useEffect(() => {
if (audioRef.current) {
@@ -21,6 +23,7 @@ export default function AudioPlayer({ src, unlockedSeconds, startTime = 0, onPla
audioRef.current.currentTime = startTime;
setIsPlaying(false);
setProgress(0);
setHasPlayedOnce(false); // Reset for new segment
if (autoPlay) {
const playPromise = audioRef.current.play();
@@ -29,6 +32,7 @@ export default function AudioPlayer({ src, unlockedSeconds, startTime = 0, onPla
.then(() => {
setIsPlaying(true);
onPlay?.();
setHasPlayedOnce(true);
})
.catch(error => {
console.log("Autoplay prevented:", error);
@@ -47,6 +51,12 @@ export default function AudioPlayer({ src, unlockedSeconds, startTime = 0, onPla
} else {
audioRef.current.play();
onPlay?.();
if (hasPlayedOnce) {
onReplay?.();
} else {
setHasPlayedOnce(true);
}
}
setIsPlaying(!isPlaying);
};

View File

@@ -16,6 +16,7 @@ interface GameProps {
title: string;
artist: string;
coverImage: string | null;
releaseYear?: number | null;
startTime?: number;
} | null;
genre?: string | null;
@@ -27,7 +28,7 @@ interface GameProps {
const DEFAULT_UNLOCK_STEPS = [2, 4, 7, 11, 16, 30, 60];
export default function Game({ dailyPuzzle, genre = null, isSpecial = false, maxAttempts = 7, unlockSteps = DEFAULT_UNLOCK_STEPS }: GameProps) {
const { gameState, statistics, addGuess } = useGameState(genre, maxAttempts);
const { gameState, statistics, addGuess, giveUp, addReplay, addYearBonus, skipYearBonus } = useGameState(genre, maxAttempts);
const [hasWon, setHasWon] = useState(false);
const [hasLost, setHasLost] = useState(false);
const [shareText, setShareText] = useState('🔗 Share');
@@ -35,6 +36,7 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
const [isProcessingGuess, setIsProcessingGuess] = useState(false);
const [timeUntilNext, setTimeUntilNext] = useState('');
const [hasRated, setHasRated] = useState(false);
const [showYearModal, setShowYearModal] = useState(false);
useEffect(() => {
const updateCountdown = () => {
@@ -50,7 +52,7 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
};
updateCountdown();
const interval = setInterval(updateCountdown, 1000); // Update every second to be accurate
const interval = setInterval(updateCountdown, 1000);
return () => clearInterval(interval);
}, []);
@@ -58,6 +60,11 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
if (gameState && dailyPuzzle) {
setHasWon(gameState.isSolved);
setHasLost(gameState.isFailed);
// Show year modal if won but year not guessed yet and release year is available
if (gameState.isSolved && !gameState.yearGuessed && dailyPuzzle.releaseYear) {
setShowYearModal(true);
}
}
}, [gameState, dailyPuzzle]);
@@ -87,37 +94,62 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
if (!gameState) return <div>Loading state...</div>;
const handleGuess = (song: any) => {
if (isProcessingGuess) return; // Prevent multiple guesses
if (isProcessingGuess) return;
setIsProcessingGuess(true);
setLastAction('GUESS');
if (song.id === dailyPuzzle.songId) {
addGuess(song.title, true);
setHasWon(true);
sendGotifyNotification(gameState.guesses.length + 1, 'won', dailyPuzzle.id, genre);
// Notification sent after year guess or skip
if (!dailyPuzzle.releaseYear) {
sendGotifyNotification(gameState.guesses.length + 1, 'won', dailyPuzzle.id, genre, gameState.score);
}
} else {
addGuess(song.title, false);
if (gameState.guesses.length + 1 >= maxAttempts) {
setHasLost(true);
setHasWon(false); // Ensure won is false
sendGotifyNotification(maxAttempts, 'lost', dailyPuzzle.id, genre);
setHasWon(false);
sendGotifyNotification(maxAttempts, 'lost', dailyPuzzle.id, genre, 0); // Score is 0 on failure
}
}
// Reset after a short delay to allow UI update
setTimeout(() => setIsProcessingGuess(false), 500);
};
const handleSkip = () => {
setLastAction('SKIP');
addGuess("SKIPPED", false);
if (gameState.guesses.length + 1 >= maxAttempts) {
setHasLost(true);
setHasWon(false);
sendGotifyNotification(maxAttempts, 'lost', dailyPuzzle.id, genre, 0); // Score is 0 on failure
}
};
const handleGiveUp = () => {
setLastAction('SKIP');
addGuess("SKIPPED", false);
giveUp(); // Ensure game is marked as failed and score reset to 0
setHasLost(true);
setHasWon(false);
sendGotifyNotification(maxAttempts, 'lost', dailyPuzzle.id, genre);
sendGotifyNotification(maxAttempts, 'lost', dailyPuzzle.id, genre, 0);
};
const handleYearGuess = (year: number) => {
const correct = year === dailyPuzzle.releaseYear;
addYearBonus(correct);
setShowYearModal(false);
// Send notification now that game is fully complete
sendGotifyNotification(gameState.guesses.length, 'won', dailyPuzzle.id, genre, gameState.score + (correct ? 10 : 0));
};
const handleYearSkip = () => {
skipYearBonus();
setShowYearModal(false);
// Send notification now that game is fully complete
sendGotifyNotification(gameState.guesses.length, 'won', dailyPuzzle.id, genre, gameState.score);
};
const unlockedSeconds = unlockSteps[Math.min(gameState.guesses.length, unlockSteps.length - 1)];
@@ -126,29 +158,24 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
let emojiGrid = '';
const totalGuesses = maxAttempts;
// Build the grid
for (let i = 0; i < totalGuesses; i++) {
if (i < gameState.guesses.length) {
// If this was the winning guess (last one and won)
if (hasWon && i === gameState.guesses.length - 1) {
emojiGrid += '🟩';
} else if (gameState.guesses[i] === 'SKIPPED') {
// Skipped
emojiGrid += '⬛';
} else {
// Wrong guess
emojiGrid += '🟥';
}
} else {
// Unused attempts
emojiGrid += '⬜';
}
}
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` : '';
// Generate URL with genre/special path
let shareUrl = 'https://hoerdle.elpatron.me';
if (genre) {
if (isSpecial) {
@@ -158,9 +185,8 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
}
}
const text = `Hördle #${dailyPuzzle.puzzleNumber}\n${genreText}\n${speaker}${emojiGrid}\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}`;
// Try native Web Share API only on mobile devices
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
if (isMobile && navigator.share) {
@@ -173,14 +199,12 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
setTimeout(() => setShareText('🔗 Share'), 2000);
return;
} catch (err) {
// User cancelled or error - fall through to clipboard
if ((err as Error).name !== 'AbortError') {
console.error('Share failed:', err);
}
}
}
// Fallback: Copy to clipboard
try {
await navigator.clipboard.writeText(text);
setShareText('✓ Copied!');
@@ -192,8 +216,6 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
}
};
const handleRatingSubmit = async (rating: number) => {
if (!dailyPuzzle) return;
@@ -201,7 +223,6 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
await submitRating(dailyPuzzle.songId, rating, genre, isSpecial, dailyPuzzle.puzzleNumber);
setHasRated(true);
// Persist to localStorage
const ratedPuzzles = JSON.parse(localStorage.getItem('hoerdle_rated_puzzles') || '[]');
if (!ratedPuzzles.includes(dailyPuzzle.id)) {
ratedPuzzles.push(dailyPuzzle.id);
@@ -222,17 +243,20 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
</header>
<main className="game-board">
<div style={{ borderBottom: '1px solid #e5e7eb', paddingBottom: '1rem' }}>
<div className="status-bar">
<span>Attempt {gameState.guesses.length + 1} / {maxAttempts}</span>
<span>{unlockedSeconds}s unlocked</span>
</div>
<ScoreDisplay score={gameState.score} breakdown={gameState.scoreBreakdown} />
<AudioPlayer
src={dailyPuzzle.audioUrl}
unlockedSeconds={unlockedSeconds}
startTime={dailyPuzzle.startTime}
autoPlay={lastAction === 'SKIP'}
autoPlay={lastAction === 'SKIP' || (lastAction === 'GUESS' && !hasWon && !hasLost)}
onReplay={addReplay}
/>
</div>
@@ -253,7 +277,7 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
{!hasWon && !hasLost && (
<>
<GuessInput onGuess={handleGuess} disabled={isProcessingGuess} />
{gameState.guesses.length < 6 ? (
{gameState.guesses.length < maxAttempts - 1 ? (
<button
onClick={handleSkip}
className="skip-button"
@@ -275,29 +299,49 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
</>
)}
{hasWon && (
<div className="message-box success">
<h2 style={{ fontSize: '1.5rem', fontWeight: 'bold', marginBottom: '0.5rem' }}>You won!</h2>
<p>Come back tomorrow for a new song.</p>
{(hasWon || hasLost) && (
<div className={`message-box ${hasWon ? 'success' : 'failure'}`}>
<h2 style={{ fontSize: '1.5rem', fontWeight: 'bold', marginBottom: '0.5rem' }}>
{hasWon ? 'You won!' : 'Game Over'}
</h2>
<div style={{ fontSize: '2rem', fontWeight: 'bold', margin: '1rem 0', color: hasWon ? '#059669' : '#dc2626' }}>
Score: {gameState.score}
</div>
<details style={{ marginBottom: '1rem', cursor: 'pointer', fontSize: '0.9rem', color: '#666' }}>
<summary>Score Breakdown</summary>
<ul style={{ listStyle: 'none', padding: '0.5rem', textAlign: 'left', background: 'rgba(255,255,255,0.5)', borderRadius: '0.5rem', marginTop: '0.5rem' }}>
{gameState.scoreBreakdown.map((item, i) => (
<li key={i} style={{ display: 'flex', justifyContent: 'space-between', padding: '0.25rem 0' }}>
<span>{item.reason}</span>
<span style={{ fontWeight: 'bold', color: item.value >= 0 ? 'green' : 'red' }}>
{item.value > 0 ? '+' : ''}{item.value}
</span>
</li>
))}
</ul>
</details>
<p>{hasWon ? 'Come back tomorrow for a new song.' : 'The song was:'}</p>
{/* Song Details */}
<div style={{ margin: '1.5rem 0', padding: '1rem', background: 'rgba(255,255,255,0.5)', borderRadius: '0.5rem', display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
{dailyPuzzle.coverImage && (
<img
src={dailyPuzzle.coverImage}
alt="Album Cover"
style={{ width: '150px', height: '150px', objectFit: 'cover', borderRadius: '0.5rem', marginBottom: '1rem', boxShadow: '0 4px 6px rgba(0,0,0,0.1)' }}
/>
)}
<img
src={dailyPuzzle.coverImage || '/favicon.ico'}
alt="Album Cover"
style={{ width: '150px', height: '150px', objectFit: 'cover', borderRadius: '0.5rem', marginBottom: '1rem', boxShadow: '0 4px 6px rgba(0,0,0,0.1)' }}
/>
<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 1rem 0' }}>{dailyPuzzle.artist}</p>
<p style={{ fontSize: '0.875rem', color: '#666', margin: '0 0 0.5rem 0' }}>{dailyPuzzle.artist}</p>
{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%' }}>
<source src={dailyPuzzle.audioUrl} type="audio/mpeg" />
Your browser does not support the audio element.
</audio>
</div>
{/* Rating Component */}
<div style={{ marginBottom: '1rem' }}>
<StarRating onRate={handleRatingSubmit} hasRated={hasRated} />
</div>
@@ -308,42 +352,150 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
</button>
</div>
)}
{hasLost && (
<div className="message-box failure">
<h2 style={{ fontSize: '1.5rem', fontWeight: 'bold', marginBottom: '0.5rem' }}>Game Over</h2>
<p>The song was:</p>
{/* Song Details */}
<div style={{ margin: '1.5rem 0', padding: '1rem', background: 'rgba(255,255,255,0.5)', borderRadius: '0.5rem', display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
{dailyPuzzle.coverImage && (
<img
src={dailyPuzzle.coverImage}
alt="Album Cover"
style={{ width: '150px', height: '150px', objectFit: 'cover', borderRadius: '0.5rem', marginBottom: '1rem', boxShadow: '0 4px 6px rgba(0,0,0,0.1)' }}
/>
)}
<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 1rem 0' }}>{dailyPuzzle.artist}</p>
<audio controls style={{ width: '100%' }}>
<source src={dailyPuzzle.audioUrl} type="audio/mpeg" />
Your browser does not support the audio element.
</audio>
</div>
{/* Rating Component */}
<div style={{ marginBottom: '1rem' }}>
<StarRating onRate={handleRatingSubmit} hasRated={hasRated} />
</div>
{statistics && <Statistics statistics={statistics} />}
<button onClick={handleShare} className="btn-primary" style={{ marginTop: '1rem' }}>
{shareText}
</button>
</div>
)}
</main>
{showYearModal && dailyPuzzle.releaseYear && (
<YearGuessModal
correctYear={dailyPuzzle.releaseYear}
onGuess={handleYearGuess}
onSkip={handleYearSkip}
/>
)}
</div>
);
}
function ScoreDisplay({ score, breakdown }: { score: number, breakdown: Array<{ value: number, reason: string }> }) {
const tooltipText = breakdown.map(item => `${item.reason}: ${item.value > 0 ? '+' : ''}${item.value}`).join('\n');
// Create expression: "90 - 2 - 5 + 10"
// Limit to last 5 items to avoid overflow if too long
const displayItems = breakdown.length > 5 ?
[{ value: breakdown[0].value, reason: 'Start' }, ...breakdown.slice(-4)] :
breakdown;
const expression = displayItems.map((item, index) => {
if (index === 0 && breakdown.length <= 5) return item.value.toString();
if (index === 0 && breakdown.length > 5) return `${item.value} ...`;
return item.value >= 0 ? `+ ${item.value}` : `- ${Math.abs(item.value)}`;
}).join(' ');
return (
<div className="score-display" title={tooltipText} style={{
textAlign: 'center',
margin: '0.5rem 0',
padding: '0.5rem',
background: '#f3f4f6',
borderRadius: '0.5rem',
fontSize: '0.9rem',
fontFamily: 'monospace',
cursor: 'help'
}}>
<span style={{ color: '#666' }}>{expression} = </span>
<span style={{ fontWeight: 'bold', color: 'var(--primary)', fontSize: '1.1rem' }}>{score}</span>
</div>
);
}
function YearGuessModal({ correctYear, onGuess, onSkip }: { correctYear: number, onGuess: (year: number) => void, onSkip: () => void }) {
const [options, setOptions] = useState<number[]>([]);
useEffect(() => {
const currentYear = new Date().getFullYear();
const minYear = 1950;
const closeOptions = new Set<number>();
closeOptions.add(correctYear);
// Add 2 close years (+/- 2)
while (closeOptions.size < 3) {
const offset = Math.floor(Math.random() * 5) - 2;
const year = correctYear + offset;
if (year <= currentYear && year >= minYear && year !== correctYear) {
closeOptions.add(year);
}
}
const allOptions = new Set(closeOptions);
// Fill up to 10 with random years
while (allOptions.size < 10) {
const year = Math.floor(Math.random() * (currentYear - minYear + 1)) + minYear;
allOptions.add(year);
}
setOptions(Array.from(allOptions).sort((a, b) => a - b));
}, [correctYear]);
return (
<div style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0,0,0,0.8)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000,
padding: '1rem'
}}>
<div style={{
background: 'white',
padding: '2rem',
borderRadius: '1rem',
maxWidth: '500px',
width: '100%',
textAlign: 'center',
boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.1)'
}}>
<h3 style={{ fontSize: '1.5rem', fontWeight: 'bold', marginBottom: '0.5rem', color: '#1f2937' }}>Bonus Round!</h3>
<p style={{ marginBottom: '1.5rem', color: '#4b5563' }}>Guess the release year for <strong style={{ color: '#10b981' }}>+10 points</strong>!</p>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(80px, 1fr))',
gap: '0.75rem',
marginBottom: '1.5rem'
}}>
{options.map(year => (
<button
key={year}
onClick={() => onGuess(year)}
style={{
padding: '0.75rem',
background: '#f3f4f6',
border: '2px solid #e5e7eb',
borderRadius: '0.5rem',
fontSize: '1.1rem',
fontWeight: 'bold',
color: '#374151',
cursor: 'pointer',
transition: 'all 0.2s'
}}
onMouseOver={e => e.currentTarget.style.borderColor = '#10b981'}
onMouseOut={e => e.currentTarget.style.borderColor = '#e5e7eb'}
>
{year}
</button>
))}
</div>
<button
onClick={onSkip}
style={{
background: 'none',
border: 'none',
color: '#6b7280',
textDecoration: 'underline',
cursor: 'pointer',
fontSize: '0.9rem'
}}
>
Skip Bonus
</button>
</div>
</div>
);
}

View File

@@ -24,6 +24,6 @@ services:
timeout: 10s
retries: 3
start_period: 40s
# Initialize DB if needed and run migration
# Run migrations and start server (auto-baseline on first run if needed)
command: >
sh -c "npx -y prisma@6.19.0 db push && 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"

37
lib/auth.ts Normal file
View File

@@ -0,0 +1,37 @@
import { NextRequest, NextResponse } from 'next/server';
/**
* Authentication middleware for admin API routes
* Verifies that the request includes a valid admin session token
*/
export async function requireAdminAuth(request: NextRequest): Promise<NextResponse | null> {
const authHeader = request.headers.get('x-admin-auth');
if (!authHeader || authHeader !== 'authenticated') {
return NextResponse.json(
{ error: 'Unauthorized - Admin authentication required' },
{ status: 401 }
);
}
return null; // Auth successful
}
/**
* Helper to verify admin password
*/
export async function verifyAdminPassword(password: string): Promise<boolean> {
const bcrypt = await import('bcryptjs');
// Validate that ADMIN_PASSWORD is set (security best practice)
if (!process.env.ADMIN_PASSWORD) {
console.error('SECURITY WARNING: ADMIN_PASSWORD environment variable is not set!');
// Fallback to default hash only in development
if (process.env.NODE_ENV === 'production') {
throw new Error('ADMIN_PASSWORD environment variable is required in production');
}
}
const adminPasswordHash = process.env.ADMIN_PASSWORD || '$2b$10$SHOt9G1qUNIvHoWre7499.eEtp5PtOII0daOQGNV.dhDEuPmOUdsq';
return bcrypt.compare(password, adminPasswordHash);
}

View File

@@ -27,13 +27,13 @@ export async function getOrCreateDailyPuzzle(genreName: string | null = null) {
include: { song: true },
});
console.log(`[Daily Puzzle] Date: ${today}, Genre: ${genreName || 'Global'}, Found existing: ${!!dailyPuzzle}`);
if (!dailyPuzzle) {
// 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,
@@ -118,6 +118,7 @@ export async function getOrCreateDailyPuzzle(genreName: string | null = null) {
title: dailyPuzzle.song.title,
artist: dailyPuzzle.song.artist,
coverImage: dailyPuzzle.song.coverImage ? `/uploads/covers/${dailyPuzzle.song.coverImage}` : null,
releaseYear: dailyPuzzle.song.releaseYear,
genre: genreName
};
@@ -230,6 +231,7 @@ export async function getOrCreateSpecialPuzzle(specialName: string) {
title: dailyPuzzle.song.title,
artist: dailyPuzzle.song.artist,
coverImage: dailyPuzzle.song.coverImage ? `/uploads/covers/${dailyPuzzle.song.coverImage}` : null,
releaseYear: dailyPuzzle.song.releaseYear,
special: specialName,
maxAttempts: special.maxAttempts,
unlockSteps: JSON.parse(special.unlockSteps),

97
lib/fuzzyMatch.ts Normal file
View File

@@ -0,0 +1,97 @@
/**
* Fuzzy string matching utility for duplicate detection
* Uses Levenshtein distance to compare strings with tolerance for formatting variations
*/
/**
* Normalize a string for comparison
* - Converts to lowercase
* - Removes special characters
* - Normalizes whitespace
*/
function normalizeString(str: string): string {
return str
.toLowerCase()
.replace(/[^a-z0-9\s]/g, '') // Remove special chars
.replace(/\s+/g, ' ') // Normalize whitespace
.trim();
}
/**
* Calculate Levenshtein distance between two strings
* Returns the minimum number of single-character edits needed to change one string into the other
*/
function levenshteinDistance(a: string, b: string): number {
if (a.length === 0) return b.length;
if (b.length === 0) return a.length;
const matrix: number[][] = [];
// Initialize first column
for (let i = 0; i <= b.length; i++) {
matrix[i] = [i];
}
// Initialize first row
for (let j = 0; j <= a.length; j++) {
matrix[0][j] = j;
}
// Fill in the rest of the matrix
for (let i = 1; i <= b.length; i++) {
for (let j = 1; j <= a.length; j++) {
if (b.charAt(i - 1) === a.charAt(j - 1)) {
matrix[i][j] = matrix[i - 1][j - 1];
} else {
matrix[i][j] = Math.min(
matrix[i - 1][j - 1] + 1, // substitution
matrix[i][j - 1] + 1, // insertion
matrix[i - 1][j] + 1 // deletion
);
}
}
}
return matrix[b.length][a.length];
}
/**
* Check if two strings are similar based on Levenshtein distance
* @param str1 First string to compare
* @param str2 Second string to compare
* @param threshold Similarity threshold (0-1), default 0.85
* @returns true if strings are similar enough
*/
export function isSimilar(str1: string, str2: string, threshold = 0.85): boolean {
if (!str1 || !str2) return false;
const norm1 = normalizeString(str1);
const norm2 = normalizeString(str2);
// Exact match after normalization
if (norm1 === norm2) return true;
const distance = levenshteinDistance(norm1, norm2);
const maxLen = Math.max(norm1.length, norm2.length);
// Avoid division by zero
if (maxLen === 0) return true;
const similarity = 1 - (distance / maxLen);
return similarity >= threshold;
}
/**
* Check if a song (artist + title) is a duplicate of another
* Both artist AND title must be similar for a match
*/
export function isDuplicateSong(
artist1: string,
title1: string,
artist2: string,
title2: string,
threshold = 0.85
): boolean {
return isSimilar(artist1, artist2, threshold) && isSimilar(title1, title2, threshold);
}

View File

@@ -9,6 +9,11 @@ export interface GameState {
isSolved: boolean;
isFailed: boolean;
lastPlayed: number; // Timestamp
score: number;
replayCount: number;
skipCount: number;
scoreBreakdown: Array<{ value: number; reason: string }>;
yearGuessed: boolean;
}
export interface Statistics {
@@ -22,19 +27,31 @@ export interface Statistics {
failed: number;
}
const STORAGE_KEY = 'hoerdle_game_state';
const STATS_KEY = 'hoerdle_statistics';
const STORAGE_KEY_PREFIX = 'hoerdle_game_state';
const STATS_KEY_PREFIX = 'hoerdle_statistics';
const INITIAL_SCORE = 90;
export function useGameState(genre: string | null = null, maxAttempts: number = 7) {
const [gameState, setGameState] = useState<GameState | null>(null);
const [statistics, setStatistics] = useState<Statistics | null>(null);
const STORAGE_KEY_PREFIX = 'hoerdle_game_state';
const STATS_KEY_PREFIX = 'hoerdle_statistics';
const getStorageKey = () => genre ? `${STORAGE_KEY_PREFIX}_${genre}` : STORAGE_KEY_PREFIX;
const getStatsKey = () => genre ? `${STATS_KEY_PREFIX}_${genre}` : STATS_KEY_PREFIX;
const createNewState = (date: string): GameState => ({
date,
guesses: [],
isSolved: false,
isFailed: false,
lastPlayed: Date.now(),
score: INITIAL_SCORE,
replayCount: 0,
skipCount: 0,
scoreBreakdown: [{ value: INITIAL_SCORE, reason: 'Start value' }],
yearGuessed: false
});
useEffect(() => {
// Load game state
const storageKey = getStorageKey();
@@ -42,30 +59,29 @@ export function useGameState(genre: string | null = null, maxAttempts: number =
const today = getTodayISOString();
if (stored) {
const parsed: GameState = JSON.parse(stored);
const parsed = JSON.parse(stored);
if (parsed.date === today) {
setGameState(parsed);
// Migration for existing states without score
if (parsed.score === undefined) {
parsed.score = INITIAL_SCORE;
parsed.replayCount = 0;
parsed.skipCount = 0;
parsed.scoreBreakdown = [{ value: INITIAL_SCORE, reason: 'Start value' }];
parsed.yearGuessed = false;
// Retroactively deduct points for existing guesses if possible,
// but simpler to just start at 90 for active games to avoid confusion
}
setGameState(parsed as GameState);
} else {
// New day
const newState: GameState = {
date: today,
guesses: [],
isSolved: false,
isFailed: false,
lastPlayed: Date.now(),
};
const newState = createNewState(today);
setGameState(newState);
localStorage.setItem(storageKey, JSON.stringify(newState));
}
} else {
// No state
const newState: GameState = {
date: today,
guesses: [],
isSolved: false,
isFailed: false,
lastPlayed: Date.now(),
};
const newState = createNewState(today);
setGameState(newState);
localStorage.setItem(storageKey, JSON.stringify(newState));
}
@@ -116,8 +132,6 @@ export function useGameState(genre: string | null = null, maxAttempts: number =
case 6: newStats.solvedIn6++; break;
case 7: newStats.solvedIn7++; break;
default:
// For custom attempts > 7, we currently don't have specific stats buckets
// We could add a 'solvedInOther' or just ignore for now
break;
}
} else {
@@ -135,12 +149,43 @@ export function useGameState(genre: string | null = null, maxAttempts: number =
const isSolved = correct;
const isFailed = !correct && newGuesses.length >= maxAttempts;
let newScore = gameState.score;
const newBreakdown = [...gameState.scoreBreakdown];
if (correct) {
newScore += 20;
newBreakdown.push({ value: 20, reason: 'Correct Answer' });
} else {
if (guess === 'SKIPPED') {
newScore -= 5;
newBreakdown.push({ value: -5, reason: 'Skip' });
} else {
newScore -= 3;
newBreakdown.push({ value: -3, reason: 'Wrong guess' });
}
}
// If failed, reset score to 0
if (isFailed) {
if (newScore > 0) {
newBreakdown.push({ value: -newScore, reason: 'Game Over' });
newScore = 0;
}
}
// Ensure score doesn't go below 0
newScore = Math.max(0, newScore);
const newState = {
...gameState,
guesses: newGuesses,
isSolved,
isFailed,
lastPlayed: Date.now(),
score: newScore,
scoreBreakdown: newBreakdown,
// Update skip count if skipped
skipCount: guess === 'SKIPPED' ? gameState.skipCount + 1 : gameState.skipCount
};
saveState(newState);
@@ -151,5 +196,79 @@ export function useGameState(genre: string | null = null, maxAttempts: number =
}
};
return { gameState, statistics, addGuess };
const giveUp = () => {
if (!gameState || gameState.isSolved || gameState.isFailed) return;
let newScore = 0;
const newBreakdown = [...gameState.scoreBreakdown];
if (gameState.score > 0) {
newBreakdown.push({ value: -gameState.score, reason: 'Gave Up' });
}
const newState = {
...gameState,
isFailed: true,
score: 0,
scoreBreakdown: newBreakdown,
lastPlayed: Date.now()
};
saveState(newState);
updateStatistics(gameState.guesses.length, false);
};
const addReplay = () => {
if (!gameState || gameState.isSolved || gameState.isFailed) return;
let newScore = gameState.score - 1;
// Ensure score doesn't go below 0
newScore = Math.max(0, newScore);
const newBreakdown = [...gameState.scoreBreakdown, { value: -1, reason: 'Replay snippet' }];
const newState = {
...gameState,
replayCount: gameState.replayCount + 1,
score: newScore,
scoreBreakdown: newBreakdown
};
saveState(newState);
};
const addYearBonus = (correct: boolean) => {
if (!gameState) return;
let newScore = gameState.score;
const newBreakdown = [...gameState.scoreBreakdown];
if (correct) {
newScore += 10;
newBreakdown.push({ value: 10, reason: 'Bonus: Correct Year' });
} else {
newBreakdown.push({ value: 0, reason: 'Bonus: Wrong Year' });
}
const newState = {
...gameState,
score: newScore,
scoreBreakdown: newBreakdown,
yearGuessed: true
};
saveState(newState);
};
const skipYearBonus = () => {
if (!gameState) return;
const newBreakdown = [...gameState.scoreBreakdown, { value: 0, reason: 'Bonus: Skipped' }];
const newState = {
...gameState,
scoreBreakdown: newBreakdown,
yearGuessed: true
};
saveState(newState);
};
return { gameState, statistics, addGuess, giveUp, addReplay, addYearBonus, skipYearBonus };
}

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;
}
}

76
lib/rateLimit.ts Normal file
View File

@@ -0,0 +1,76 @@
import { NextRequest, NextResponse } from 'next/server';
/**
* Rate limiting configuration
* Simple in-memory rate limiter for API endpoints
*/
interface RateLimitEntry {
count: number;
resetTime: number;
}
const rateLimitMap = new Map<string, RateLimitEntry>();
// Clean up old entries every 5 minutes
setInterval(() => {
const now = Date.now();
for (const [key, entry] of rateLimitMap.entries()) {
if (now > entry.resetTime) {
rateLimitMap.delete(key);
}
}
}, 5 * 60 * 1000);
export interface RateLimitConfig {
windowMs: number; // Time window in milliseconds
maxRequests: number; // Maximum requests per window
}
/**
* Rate limiting middleware
* @param request - The incoming request
* @param config - Rate limit configuration
* @returns NextResponse with 429 status if rate limit exceeded, null otherwise
*/
export function rateLimit(
request: NextRequest,
config: RateLimitConfig = { windowMs: 60000, maxRequests: 100 }
): NextResponse | null {
// Get client identifier (IP address or fallback)
const identifier =
request.headers.get('x-forwarded-for')?.split(',')[0] ||
request.headers.get('x-real-ip') ||
'unknown';
const now = Date.now();
const entry = rateLimitMap.get(identifier);
if (!entry || now > entry.resetTime) {
// Create new entry or reset expired entry
rateLimitMap.set(identifier, {
count: 1,
resetTime: now + config.windowMs
});
return null;
}
if (entry.count >= config.maxRequests) {
const retryAfter = Math.ceil((entry.resetTime - now) / 1000);
return NextResponse.json(
{ error: 'Too many requests. Please try again later.' },
{
status: 429,
headers: {
'Retry-After': retryAfter.toString(),
'X-RateLimit-Limit': config.maxRequests.toString(),
'X-RateLimit-Remaining': '0',
'X-RateLimit-Reset': new Date(entry.resetTime).toISOString()
}
}
);
}
// Increment counter
entry.count++;
return null;
}

52
middleware.ts Normal file
View File

@@ -0,0 +1,52 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const response = NextResponse.next();
// Security Headers
const headers = response.headers;
// Prevent clickjacking
headers.set('X-Frame-Options', 'SAMEORIGIN');
// XSS Protection (legacy but still useful)
headers.set('X-XSS-Protection', '1; mode=block');
// Prevent MIME type sniffing
headers.set('X-Content-Type-Options', 'nosniff');
// Referrer Policy
headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
// Permissions Policy (restrict features)
headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
// Content Security Policy
const csp = [
"default-src 'self'",
"script-src 'self' 'unsafe-inline' 'unsafe-eval'", // Next.js requires unsafe-inline/eval
"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",
"media-src 'self' blob:",
"frame-ancestors 'self'",
].join('; ');
headers.set('Content-Security-Policy', csp);
return response;
}
// Apply middleware to all routes
export const config = {
matcher: [
/*
* Match all request paths except for the ones starting with:
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico (favicon file)
*/
'/((?!_next/static|_next/image|favicon.ico).*)',
],
};

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,5 @@
-- AlterTable
ALTER TABLE "Genre" ADD COLUMN "subtitle" TEXT;
-- AlterTable
ALTER TABLE "Special" ADD COLUMN "subtitle" TEXT;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Song" ADD COLUMN "releaseYear" INTEGER;

View File

@@ -0,0 +1,20 @@
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_DailyPuzzle" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"date" TEXT NOT NULL,
"songId" INTEGER NOT NULL,
"genreId" INTEGER,
"specialId" INTEGER,
CONSTRAINT "DailyPuzzle_songId_fkey" FOREIGN KEY ("songId") REFERENCES "Song" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "DailyPuzzle_genreId_fkey" FOREIGN KEY ("genreId") REFERENCES "Genre" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "DailyPuzzle_specialId_fkey" FOREIGN KEY ("specialId") REFERENCES "Special" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
INSERT INTO "new_DailyPuzzle" ("date", "genreId", "id", "songId", "specialId") SELECT "date", "genreId", "id", "songId", "specialId" FROM "DailyPuzzle";
DROP TABLE "DailyPuzzle";
ALTER TABLE "new_DailyPuzzle" RENAME TO "DailyPuzzle";
CREATE UNIQUE INDEX "DailyPuzzle_date_genreId_specialId_key" ON "DailyPuzzle"("date", "genreId", "specialId");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

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,17 +16,21 @@ model Song {
artist String
filename String // Filename in public/uploads
coverImage String? // Filename in public/uploads/covers
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[]
}
@@ -34,6 +38,7 @@ model Genre {
model Special {
id Int @id @default(autoincrement())
name String @unique
subtitle String?
maxAttempts Int @default(7)
unlockSteps String // JSON string: e.g. "[2, 4, 7, 11, 16, 30]"
createdAt DateTime @default(now())
@@ -60,7 +65,7 @@ model DailyPuzzle {
id Int @id @default(autoincrement())
date String // Format: YYYY-MM-DD
songId Int
song Song @relation(fields: [songId], references: [id])
song Song @relation(fields: [songId], references: [id], onDelete: Cascade)
genreId Int?
genre Genre? @relation(fields: [genreId], references: [id])
specialId Int?

18
scripts/baseline-migrations.sh Executable file
View File

@@ -0,0 +1,18 @@
#!/bin/bash
# One-time script to baseline existing production database with migration history
# Run this ONCE on production server: docker exec hoerdle sh scripts/baseline-migrations.sh
echo "🔧 Baselining migration history for existing database..."
# Mark all existing migrations as applied
npx prisma migrate resolve --applied "20251122121934_add_specials"
npx prisma migrate resolve --applied "20251122140952_add_specials_real"
npx prisma migrate resolve --applied "20251123012306_add_special_curator"
npx prisma migrate resolve --applied "20251123012308_add_special_song_model"
npx prisma migrate resolve --applied "20251123020226_add_special_scheduling"
npx prisma migrate resolve --applied "20251123083856_add_rating_system"
npx prisma migrate resolve --applied "20251123140527_add_subtitles"
npx prisma migrate resolve --applied "20251123181922_add_release_year"
npx prisma migrate resolve --applied "20251123204000_fix_cascade_delete"
echo "✅ Baseline complete! Restart the container to apply migrations normally."

View File

@@ -1,12 +1,20 @@
#!/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
# Start the application
echo "Starting application..."
exec node server.js

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,16 +65,20 @@ async function migrate() {
data: { coverImage: coverFilename }
});
console.log(`✅ Extracted cover for ${song.title}`);
} else {
console.log(`⚠️ No cover found 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

@@ -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();