Compare commits
27 Commits
0f7d66c619
...
v0.1.0.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5102ca86cb | ||
|
|
eb3d2c86d7 | ||
|
|
883875b82a | ||
|
|
4c13817e77 | ||
|
|
35fe5f2d44 | ||
|
|
70501d626b | ||
|
|
41ce6c12ce | ||
|
|
a744393335 | ||
|
|
0ee3a48770 | ||
|
|
187774bce7 | ||
|
|
67cf85dc22 | ||
|
|
326023a705 | ||
|
|
41e2ec1495 | ||
|
|
62402d7000 | ||
|
|
0599c066d9 | ||
|
|
f7de7f2684 | ||
|
|
e5d06029ef | ||
|
|
e8e0aa27fb | ||
|
|
7f455053e7 | ||
|
|
3309b5c5ee | ||
|
|
cd30476349 | ||
|
|
cd19a6c04d | ||
|
|
7011a24b46 | ||
|
|
9a98830245 | ||
|
|
3630745169 | ||
|
|
831adcaf17 | ||
|
|
2d6481a42f |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -49,3 +49,4 @@ next-env.d.ts
|
|||||||
!/public/uploads/.gitkeep
|
!/public/uploads/.gitkeep
|
||||||
/data
|
/data
|
||||||
.release-years-migrated
|
.release-years-migrated
|
||||||
|
.covers-migrated
|
||||||
|
|||||||
@@ -12,18 +12,22 @@ Eine Web-App inspiriert von Heardle, bei der Nutzer täglich einen Song anhand k
|
|||||||
- Automatische Extraktion von ID3-Tags (Titel, Interpret).
|
- Automatische Extraktion von ID3-Tags (Titel, Interpret).
|
||||||
- Intelligente Artist-Erkennung (unterstützt Multi-Artist-Tags).
|
- Intelligente Artist-Erkennung (unterstützt Multi-Artist-Tags).
|
||||||
- Bearbeitung von Metadaten.
|
- 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.
|
- Play/Pause-Funktion zum Vorhören in der Bibliothek.
|
||||||
- **Cover Art:**
|
- **Cover Art:**
|
||||||
- Automatische Extraktion von Cover-Bildern aus MP3-Dateien.
|
- Automatische Extraktion von Cover-Bildern aus MP3-Dateien.
|
||||||
- Anzeige des Covers nach Spielende (Sieg/Niederlage).
|
- Anzeige des Covers nach Spielende (Sieg/Niederlage).
|
||||||
- Automatische Migration bestehender Songs.
|
- 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).
|
- **PWA Support:** Installierbar als App auf Desktop und Mobilgeräten (Manifest & Icons).
|
||||||
- **Persistenz:** Spielstatus wird lokal im Browser gespeichert.
|
- **Persistenz:** Spielstatus wird lokal im Browser gespeichert.
|
||||||
- **Benachrichtigungen:** Integration mit Gotify für Push-Nachrichten bei Spielabschluss.
|
- **Benachrichtigungen:** Integration mit Gotify für Push-Nachrichten bei Spielabschluss.
|
||||||
- **Genre-Management:**
|
- **Genre-Management:**
|
||||||
- Erstellen und Verwalten von Musik-Genres.
|
- 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.
|
- Manuelle Zuweisung von Genres zu Songs.
|
||||||
- KI-gestützte automatische Kategorisierung mit OpenRouter (Claude 3.5 Haiku).
|
- KI-gestützte automatische Kategorisierung mit OpenRouter (Claude 3.5 Haiku).
|
||||||
- Genre-spezifische tägliche Rätsel.
|
- Genre-spezifische tägliche Rätsel.
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import Game from '@/components/Game';
|
|||||||
import { getOrCreateDailyPuzzle } from '@/lib/dailyPuzzle';
|
import { getOrCreateDailyPuzzle } from '@/lib/dailyPuzzle';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
@@ -14,8 +15,21 @@ interface PageProps {
|
|||||||
export default async function GenrePage({ params }: PageProps) {
|
export default async function GenrePage({ params }: PageProps) {
|
||||||
const { genre } = await params;
|
const { genre } = await params;
|
||||||
const decodedGenre = decodeURIComponent(genre);
|
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 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 specials = await prisma.special.findMany({ orderBy: { name: 'asc' } });
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
|
||||||
interface Special {
|
interface Special {
|
||||||
@@ -21,6 +21,7 @@ interface Genre {
|
|||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
subtitle?: string;
|
subtitle?: string;
|
||||||
|
active: boolean;
|
||||||
_count?: {
|
_count?: {
|
||||||
songs: number;
|
songs: number;
|
||||||
};
|
};
|
||||||
@@ -47,9 +48,10 @@ interface Song {
|
|||||||
specials: Special[];
|
specials: Special[];
|
||||||
averageRating: number;
|
averageRating: number;
|
||||||
ratingCount: number;
|
ratingCount: number;
|
||||||
|
excludeFromGlobal: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type SortField = 'id' | 'title' | 'artist' | 'createdAt' | 'releaseYear';
|
type SortField = 'id' | 'title' | 'artist' | 'createdAt' | 'releaseYear' | 'activations' | 'averageRating';
|
||||||
type SortDirection = 'asc' | 'desc';
|
type SortDirection = 'asc' | 'desc';
|
||||||
|
|
||||||
export default function AdminPage() {
|
export default function AdminPage() {
|
||||||
@@ -65,9 +67,11 @@ export default function AdminPage() {
|
|||||||
const [genres, setGenres] = useState<Genre[]>([]);
|
const [genres, setGenres] = useState<Genre[]>([]);
|
||||||
const [newGenreName, setNewGenreName] = useState('');
|
const [newGenreName, setNewGenreName] = useState('');
|
||||||
const [newGenreSubtitle, setNewGenreSubtitle] = useState('');
|
const [newGenreSubtitle, setNewGenreSubtitle] = useState('');
|
||||||
|
const [newGenreActive, setNewGenreActive] = useState(true);
|
||||||
const [editingGenreId, setEditingGenreId] = useState<number | null>(null);
|
const [editingGenreId, setEditingGenreId] = useState<number | null>(null);
|
||||||
const [editGenreName, setEditGenreName] = useState('');
|
const [editGenreName, setEditGenreName] = useState('');
|
||||||
const [editGenreSubtitle, setEditGenreSubtitle] = useState('');
|
const [editGenreSubtitle, setEditGenreSubtitle] = useState('');
|
||||||
|
const [editGenreActive, setEditGenreActive] = useState(true);
|
||||||
|
|
||||||
// Specials state
|
// Specials state
|
||||||
const [specials, setSpecials] = useState<Special[]>([]);
|
const [specials, setSpecials] = useState<Special[]>([]);
|
||||||
@@ -95,10 +99,15 @@ export default function AdminPage() {
|
|||||||
const [editReleaseYear, setEditReleaseYear] = useState<number | ''>('');
|
const [editReleaseYear, setEditReleaseYear] = useState<number | ''>('');
|
||||||
const [editGenreIds, setEditGenreIds] = useState<number[]>([]);
|
const [editGenreIds, setEditGenreIds] = useState<number[]>([]);
|
||||||
const [editSpecialIds, setEditSpecialIds] = useState<number[]>([]);
|
const [editSpecialIds, setEditSpecialIds] = useState<number[]>([]);
|
||||||
|
const [editExcludeFromGlobal, setEditExcludeFromGlobal] = useState(false);
|
||||||
|
|
||||||
// Post-upload state
|
// Post-upload state
|
||||||
const [uploadedSong, setUploadedSong] = useState<Song | null>(null);
|
const [uploadedSong, setUploadedSong] = useState<Song | null>(null);
|
||||||
const [uploadGenreIds, setUploadGenreIds] = useState<number[]>([]);
|
const [uploadGenreIds, setUploadGenreIds] = useState<number[]>([]);
|
||||||
|
const [uploadExcludeFromGlobal, setUploadExcludeFromGlobal] = useState(false);
|
||||||
|
|
||||||
|
// Batch upload genre selection
|
||||||
|
const [batchUploadGenreIds, setBatchUploadGenreIds] = useState<number[]>([]);
|
||||||
|
|
||||||
// AI Categorization state
|
// AI Categorization state
|
||||||
const [isCategorizing, setIsCategorizing] = useState(false);
|
const [isCategorizing, setIsCategorizing] = useState(false);
|
||||||
@@ -123,6 +132,7 @@ export default function AdminPage() {
|
|||||||
const [dailyPuzzles, setDailyPuzzles] = useState<any[]>([]);
|
const [dailyPuzzles, setDailyPuzzles] = useState<any[]>([]);
|
||||||
const [playingPuzzleId, setPlayingPuzzleId] = useState<number | null>(null);
|
const [playingPuzzleId, setPlayingPuzzleId] = useState<number | null>(null);
|
||||||
const [showDailyPuzzles, setShowDailyPuzzles] = useState(false);
|
const [showDailyPuzzles, setShowDailyPuzzles] = useState(false);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
// Check for existing auth on mount
|
// Check for existing auth on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -151,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 fetchSongs = async () => {
|
||||||
const res = await fetch('/api/songs');
|
const res = await fetch('/api/songs', {
|
||||||
|
headers: getAuthHeaders()
|
||||||
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setSongs(data);
|
setSongs(data);
|
||||||
@@ -160,7 +192,9 @@ export default function AdminPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const fetchGenres = async () => {
|
const fetchGenres = async () => {
|
||||||
const res = await fetch('/api/genres');
|
const res = await fetch('/api/genres', {
|
||||||
|
headers: getAuthHeaders()
|
||||||
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setGenres(data);
|
setGenres(data);
|
||||||
@@ -171,11 +205,17 @@ export default function AdminPage() {
|
|||||||
if (!newGenreName.trim()) return;
|
if (!newGenreName.trim()) return;
|
||||||
const res = await fetch('/api/genres', {
|
const res = await fetch('/api/genres', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ name: newGenreName, subtitle: newGenreSubtitle }),
|
headers: getAuthHeaders(),
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: newGenreName,
|
||||||
|
subtitle: newGenreSubtitle,
|
||||||
|
active: newGenreActive
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
setNewGenreName('');
|
setNewGenreName('');
|
||||||
setNewGenreSubtitle('');
|
setNewGenreSubtitle('');
|
||||||
|
setNewGenreActive(true);
|
||||||
fetchGenres();
|
fetchGenres();
|
||||||
} else {
|
} else {
|
||||||
alert('Failed to create genre');
|
alert('Failed to create genre');
|
||||||
@@ -186,17 +226,19 @@ export default function AdminPage() {
|
|||||||
setEditingGenreId(genre.id);
|
setEditingGenreId(genre.id);
|
||||||
setEditGenreName(genre.name);
|
setEditGenreName(genre.name);
|
||||||
setEditGenreSubtitle(genre.subtitle || '');
|
setEditGenreSubtitle(genre.subtitle || '');
|
||||||
|
setEditGenreActive(genre.active !== undefined ? genre.active : true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveEditedGenre = async () => {
|
const saveEditedGenre = async () => {
|
||||||
if (editingGenreId === null) return;
|
if (editingGenreId === null) return;
|
||||||
const res = await fetch('/api/genres', {
|
const res = await fetch('/api/genres', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: getAuthHeaders(),
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
id: editingGenreId,
|
id: editingGenreId,
|
||||||
name: editGenreName,
|
name: editGenreName,
|
||||||
subtitle: editGenreSubtitle
|
subtitle: editGenreSubtitle,
|
||||||
|
active: editGenreActive
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
@@ -209,7 +251,9 @@ export default function AdminPage() {
|
|||||||
|
|
||||||
// Specials functions
|
// Specials functions
|
||||||
const fetchSpecials = async () => {
|
const fetchSpecials = async () => {
|
||||||
const res = await fetch('/api/specials');
|
const res = await fetch('/api/specials', {
|
||||||
|
headers: getAuthHeaders()
|
||||||
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setSpecials(data);
|
setSpecials(data);
|
||||||
@@ -220,7 +264,7 @@ export default function AdminPage() {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const res = await fetch('/api/specials', {
|
const res = await fetch('/api/specials', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: getAuthHeaders(),
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
name: newSpecialName,
|
name: newSpecialName,
|
||||||
subtitle: newSpecialSubtitle,
|
subtitle: newSpecialSubtitle,
|
||||||
@@ -249,7 +293,7 @@ export default function AdminPage() {
|
|||||||
if (!confirm('Delete this special?')) return;
|
if (!confirm('Delete this special?')) return;
|
||||||
const res = await fetch('/api/specials', {
|
const res = await fetch('/api/specials', {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: getAuthHeaders(),
|
||||||
body: JSON.stringify({ id }),
|
body: JSON.stringify({ id }),
|
||||||
});
|
});
|
||||||
if (res.ok) fetchSpecials();
|
if (res.ok) fetchSpecials();
|
||||||
@@ -258,7 +302,9 @@ export default function AdminPage() {
|
|||||||
|
|
||||||
// Daily Puzzles functions
|
// Daily Puzzles functions
|
||||||
const fetchDailyPuzzles = async () => {
|
const fetchDailyPuzzles = async () => {
|
||||||
const res = await fetch('/api/admin/daily-puzzles');
|
const res = await fetch('/api/admin/daily-puzzles', {
|
||||||
|
headers: getAuthHeaders()
|
||||||
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setDailyPuzzles(data);
|
setDailyPuzzles(data);
|
||||||
@@ -269,7 +315,7 @@ export default function AdminPage() {
|
|||||||
if (!confirm('Delete this daily puzzle? A new one will be generated automatically.')) return;
|
if (!confirm('Delete this daily puzzle? A new one will be generated automatically.')) return;
|
||||||
const res = await fetch('/api/admin/daily-puzzles', {
|
const res = await fetch('/api/admin/daily-puzzles', {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: getAuthHeaders(),
|
||||||
body: JSON.stringify({ puzzleId }),
|
body: JSON.stringify({ puzzleId }),
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
@@ -328,7 +374,7 @@ export default function AdminPage() {
|
|||||||
if (editingSpecialId === null) return;
|
if (editingSpecialId === null) return;
|
||||||
const res = await fetch('/api/specials', {
|
const res = await fetch('/api/specials', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: getAuthHeaders(),
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
id: editingSpecialId,
|
id: editingSpecialId,
|
||||||
name: editSpecialName,
|
name: editSpecialName,
|
||||||
@@ -357,6 +403,7 @@ export default function AdminPage() {
|
|||||||
if (!confirm('Delete this genre?')) return;
|
if (!confirm('Delete this genre?')) return;
|
||||||
const res = await fetch('/api/genres', {
|
const res = await fetch('/api/genres', {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
|
headers: getAuthHeaders(),
|
||||||
body: JSON.stringify({ id }),
|
body: JSON.stringify({ id }),
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
@@ -383,7 +430,7 @@ export default function AdminPage() {
|
|||||||
while (hasMore) {
|
while (hasMore) {
|
||||||
const res = await fetch('/api/categorize', {
|
const res = await fetch('/api/categorize', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: getAuthHeaders(),
|
||||||
body: JSON.stringify({ offset })
|
body: JSON.stringify({ offset })
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -448,16 +495,23 @@ export default function AdminPage() {
|
|||||||
setUploadProgress({ current: i + 1, total: files.length });
|
setUploadProgress({ current: i + 1, total: files.length });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
console.log(`Uploading file ${i + 1}/${files.length}: ${file.name} (${(file.size / 1024 / 1024).toFixed(2)}MB)`);
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
|
formData.append('excludeFromGlobal', String(uploadExcludeFromGlobal));
|
||||||
|
|
||||||
const res = await fetch('/api/songs', {
|
const res = await fetch('/api/songs', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
headers: { 'x-admin-auth': localStorage.getItem('hoerdle_admin_auth') || '' },
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log(`Response status for ${file.name}: ${res.status}`);
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
console.log(`Upload successful for ${file.name}:`, data);
|
||||||
results.push({
|
results.push({
|
||||||
filename: file.name,
|
filename: file.name,
|
||||||
success: true,
|
success: true,
|
||||||
@@ -467,6 +521,7 @@ export default function AdminPage() {
|
|||||||
} else if (res.status === 409) {
|
} else if (res.status === 409) {
|
||||||
// Duplicate detected
|
// Duplicate detected
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
console.log(`Duplicate detected for ${file.name}:`, data);
|
||||||
results.push({
|
results.push({
|
||||||
filename: file.name,
|
filename: file.name,
|
||||||
success: false,
|
success: false,
|
||||||
@@ -475,17 +530,20 @@ export default function AdminPage() {
|
|||||||
error: `Duplicate: Already exists as "${data.duplicate.title}" by "${data.duplicate.artist}"`
|
error: `Duplicate: Already exists as "${data.duplicate.title}" by "${data.duplicate.artist}"`
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
const errorText = await res.text();
|
||||||
|
console.error(`Upload failed for ${file.name} (${res.status}):`, errorText);
|
||||||
results.push({
|
results.push({
|
||||||
filename: file.name,
|
filename: file.name,
|
||||||
success: false,
|
success: false,
|
||||||
error: 'Upload failed'
|
error: `Upload failed (${res.status}): ${errorText.substring(0, 100)}`
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error(`Network error for ${file.name}:`, error);
|
||||||
results.push({
|
results.push({
|
||||||
filename: file.name,
|
filename: file.name,
|
||||||
success: false,
|
success: false,
|
||||||
error: 'Network error'
|
error: `Network error: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -493,8 +551,31 @@ export default function AdminPage() {
|
|||||||
setUploadResults(results);
|
setUploadResults(results);
|
||||||
setFiles([]);
|
setFiles([]);
|
||||||
setIsUploading(false);
|
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();
|
fetchSongs();
|
||||||
fetchGenres();
|
fetchGenres();
|
||||||
|
fetchSpecials(); // Update special counts
|
||||||
|
|
||||||
// Auto-trigger categorization after uploads
|
// Auto-trigger categorization after uploads
|
||||||
const successCount = results.filter(r => r.success).length;
|
const successCount = results.filter(r => r.success).length;
|
||||||
@@ -508,6 +589,13 @@ export default function AdminPage() {
|
|||||||
if (failedCount > 0) {
|
if (failedCount > 0) {
|
||||||
msg += `\n❌ ${failedCount} failed`;
|
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...';
|
msg += '\n\n🤖 Starting auto-categorization...';
|
||||||
setMessage(msg);
|
setMessage(msg);
|
||||||
// Small delay to let user see the message
|
// Small delay to let user see the message
|
||||||
@@ -521,32 +609,81 @@ export default function AdminPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDragEnter = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsDragging(true);
|
||||||
|
};
|
||||||
|
|
||||||
const handleDragOver = (e: React.DragEvent) => {
|
const handleDragOver = (e: React.DragEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setIsDragging(true);
|
e.stopPropagation();
|
||||||
|
e.dataTransfer.dropEffect = 'copy';
|
||||||
|
if (!isDragging) setIsDragging(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDragLeave = (e: React.DragEvent) => {
|
const handleDragLeave = (e: React.DragEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
// Prevent flickering when dragging over children
|
||||||
|
if (e.currentTarget.contains(e.relatedTarget as Node)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
setIsDragging(false);
|
setIsDragging(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDrop = (e: React.DragEvent) => {
|
const handleDrop = (e: React.DragEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
setIsDragging(false);
|
setIsDragging(false);
|
||||||
|
|
||||||
const droppedFiles = Array.from(e.dataTransfer.files).filter(
|
const droppedFiles = Array.from(e.dataTransfer.files);
|
||||||
file => file.type === 'audio/mpeg' || file.name.endsWith('.mp3')
|
|
||||||
);
|
|
||||||
|
|
||||||
if (droppedFiles.length > 0) {
|
// Validate file types
|
||||||
setFiles(droppedFiles);
|
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>) => {
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
if (e.target.files) {
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -555,7 +692,7 @@ export default function AdminPage() {
|
|||||||
|
|
||||||
const res = await fetch('/api/songs', {
|
const res = await fetch('/api/songs', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: getAuthHeaders(),
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
id: uploadedSong.id,
|
id: uploadedSong.id,
|
||||||
title: uploadedSong.title,
|
title: uploadedSong.title,
|
||||||
@@ -569,6 +706,7 @@ export default function AdminPage() {
|
|||||||
setUploadGenreIds([]);
|
setUploadGenreIds([]);
|
||||||
fetchSongs();
|
fetchSongs();
|
||||||
fetchGenres();
|
fetchGenres();
|
||||||
|
fetchSpecials(); // Update special counts if song was assigned to specials
|
||||||
setMessage(prev => prev + '\n✅ Genres assigned successfully!');
|
setMessage(prev => prev + '\n✅ Genres assigned successfully!');
|
||||||
} else {
|
} else {
|
||||||
alert('Failed to assign genres');
|
alert('Failed to assign genres');
|
||||||
@@ -582,6 +720,7 @@ export default function AdminPage() {
|
|||||||
setEditReleaseYear(song.releaseYear || '');
|
setEditReleaseYear(song.releaseYear || '');
|
||||||
setEditGenreIds(song.genres.map(g => g.id));
|
setEditGenreIds(song.genres.map(g => g.id));
|
||||||
setEditSpecialIds(song.specials ? song.specials.map(s => s.id) : []);
|
setEditSpecialIds(song.specials ? song.specials.map(s => s.id) : []);
|
||||||
|
setEditExcludeFromGlobal(song.excludeFromGlobal || false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const cancelEditing = () => {
|
const cancelEditing = () => {
|
||||||
@@ -591,19 +730,21 @@ export default function AdminPage() {
|
|||||||
setEditReleaseYear('');
|
setEditReleaseYear('');
|
||||||
setEditGenreIds([]);
|
setEditGenreIds([]);
|
||||||
setEditSpecialIds([]);
|
setEditSpecialIds([]);
|
||||||
|
setEditExcludeFromGlobal(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveEditing = async (id: number) => {
|
const saveEditing = async (id: number) => {
|
||||||
const res = await fetch('/api/songs', {
|
const res = await fetch('/api/songs', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: getAuthHeaders(),
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
id,
|
id,
|
||||||
title: editTitle,
|
title: editTitle,
|
||||||
artist: editArtist,
|
artist: editArtist,
|
||||||
releaseYear: editReleaseYear === '' ? null : Number(editReleaseYear),
|
releaseYear: editReleaseYear === '' ? null : Number(editReleaseYear),
|
||||||
genreIds: editGenreIds,
|
genreIds: editGenreIds,
|
||||||
specialIds: editSpecialIds
|
specialIds: editSpecialIds,
|
||||||
|
excludeFromGlobal: editExcludeFromGlobal
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -611,6 +752,7 @@ export default function AdminPage() {
|
|||||||
setEditingId(null);
|
setEditingId(null);
|
||||||
fetchSongs();
|
fetchSongs();
|
||||||
fetchGenres();
|
fetchGenres();
|
||||||
|
fetchSpecials(); // Update special counts
|
||||||
} else {
|
} else {
|
||||||
alert('Failed to update song');
|
alert('Failed to update song');
|
||||||
}
|
}
|
||||||
@@ -623,13 +765,14 @@ export default function AdminPage() {
|
|||||||
|
|
||||||
const res = await fetch('/api/songs', {
|
const res = await fetch('/api/songs', {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: getAuthHeaders(),
|
||||||
body: JSON.stringify({ id }),
|
body: JSON.stringify({ id }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
fetchSongs();
|
fetchSongs();
|
||||||
fetchGenres();
|
fetchGenres();
|
||||||
|
fetchSpecials(); // Update special counts
|
||||||
} else {
|
} else {
|
||||||
alert('Failed to delete song');
|
alert('Failed to delete song');
|
||||||
}
|
}
|
||||||
@@ -704,6 +847,8 @@ export default function AdminPage() {
|
|||||||
} else if (selectedGenreFilter === 'daily') {
|
} else if (selectedGenreFilter === 'daily') {
|
||||||
const today = new Date().toISOString().split('T')[0];
|
const today = new Date().toISOString().split('T')[0];
|
||||||
matchesFilter = song.puzzles?.some(p => p.date === today) || false;
|
matchesFilter = song.puzzles?.some(p => p.date === today) || false;
|
||||||
|
} else if (selectedGenreFilter === 'no-global') {
|
||||||
|
matchesFilter = song.excludeFromGlobal === true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -711,7 +856,7 @@ export default function AdminPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const sortedSongs = [...filteredSongs].sort((a, b) => {
|
const sortedSongs = [...filteredSongs].sort((a, b) => {
|
||||||
// Handle numeric sorting for ID and Release Year
|
// Handle numeric sorting for ID, Release Year, Activations, and Rating
|
||||||
if (sortField === 'id') {
|
if (sortField === 'id') {
|
||||||
return sortDirection === 'asc' ? a.id - b.id : b.id - a.id;
|
return sortDirection === 'asc' ? a.id - b.id : b.id - a.id;
|
||||||
}
|
}
|
||||||
@@ -720,6 +865,12 @@ export default function AdminPage() {
|
|||||||
const yearB = b.releaseYear || 0;
|
const yearB = b.releaseYear || 0;
|
||||||
return sortDirection === 'asc' ? yearA - yearB : yearB - yearA;
|
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
|
// String sorting for other fields
|
||||||
const valA = String(a[sortField]).toLowerCase();
|
const valA = String(a[sortField]).toLowerCase();
|
||||||
@@ -759,7 +910,24 @@ export default function AdminPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="admin-container">
|
<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 */}
|
{/* Special Management */}
|
||||||
<div className="admin-card" style={{ marginBottom: '2rem' }}>
|
<div className="admin-card" style={{ marginBottom: '2rem' }}>
|
||||||
@@ -858,7 +1026,7 @@ export default function AdminPage() {
|
|||||||
{/* Genre Management */}
|
{/* Genre Management */}
|
||||||
<div className="admin-card" style={{ marginBottom: '2rem' }}>
|
<div className="admin-card" style={{ marginBottom: '2rem' }}>
|
||||||
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem' }}>Manage Genres</h2>
|
<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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={newGenreName}
|
value={newGenreName}
|
||||||
@@ -875,12 +1043,21 @@ export default function AdminPage() {
|
|||||||
className="form-input"
|
className="form-input"
|
||||||
style={{ maxWidth: '300px' }}
|
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>
|
<button onClick={createGenre} className="btn-primary">Add Genre</button>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
|
||||||
{genres.map(genre => (
|
{genres.map(genre => (
|
||||||
<div key={genre.id} style={{
|
<div key={genre.id} style={{
|
||||||
background: '#f3f4f6',
|
background: genre.active ? '#f3f4f6' : '#fee2e2',
|
||||||
|
opacity: genre.active ? 1 : 0.8,
|
||||||
padding: '0.25rem 0.75rem',
|
padding: '0.25rem 0.75rem',
|
||||||
borderRadius: '999px',
|
borderRadius: '999px',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -907,6 +1084,16 @@ export default function AdminPage() {
|
|||||||
<label style={{ fontSize: '0.75rem', color: '#666' }}>Subtitle</label>
|
<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' }} />
|
<input type="text" value={editGenreSubtitle} onChange={e => setEditGenreSubtitle(e.target.value)} className="form-input" style={{ width: '300px' }} />
|
||||||
</div>
|
</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={saveEditedGenre} className="btn-primary">Save</button>
|
||||||
<button onClick={() => setEditingGenreId(null)} className="btn-secondary">Cancel</button>
|
<button onClick={() => setEditingGenreId(null)} className="btn-secondary">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1000,6 +1187,7 @@ export default function AdminPage() {
|
|||||||
<form onSubmit={handleBatchUpload}>
|
<form onSubmit={handleBatchUpload}>
|
||||||
{/* Drag & Drop Zone */}
|
{/* Drag & Drop Zone */}
|
||||||
<div
|
<div
|
||||||
|
onDragEnter={handleDragEnter}
|
||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
onDragLeave={handleDragLeave}
|
onDragLeave={handleDragLeave}
|
||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
@@ -1013,7 +1201,7 @@ export default function AdminPage() {
|
|||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
transition: 'all 0.2s'
|
transition: 'all 0.2s'
|
||||||
}}
|
}}
|
||||||
onClick={() => document.getElementById('file-input')?.click()}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
>
|
>
|
||||||
<div style={{ fontSize: '3rem', marginBottom: '0.5rem' }}>📁</div>
|
<div style={{ fontSize: '3rem', marginBottom: '0.5rem' }}>📁</div>
|
||||||
<p style={{ fontWeight: 'bold', marginBottom: '0.25rem' }}>
|
<p style={{ fontWeight: 'bold', marginBottom: '0.25rem' }}>
|
||||||
@@ -1023,7 +1211,7 @@ export default function AdminPage() {
|
|||||||
or click to browse
|
or click to browse
|
||||||
</p>
|
</p>
|
||||||
<input
|
<input
|
||||||
id="file-input"
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept="audio/mpeg"
|
accept="audio/mpeg"
|
||||||
multiple
|
multiple
|
||||||
@@ -1063,6 +1251,63 @@ export default function AdminPage() {
|
|||||||
</div>
|
</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
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="btn-primary"
|
className="btn-primary"
|
||||||
@@ -1183,6 +1428,7 @@ export default function AdminPage() {
|
|||||||
>
|
>
|
||||||
<option value="">All Content</option>
|
<option value="">All Content</option>
|
||||||
<option value="daily">📅 Song of the Day</option>
|
<option value="daily">📅 Song of the Day</option>
|
||||||
|
<option value="no-global">🚫 No Global</option>
|
||||||
<optgroup label="Genres">
|
<optgroup label="Genres">
|
||||||
<option value="genre:-1">No Genre</option>
|
<option value="genre:-1">No Genre</option>
|
||||||
{genres.map(genre => (
|
{genres.map(genre => (
|
||||||
@@ -1248,8 +1494,18 @@ export default function AdminPage() {
|
|||||||
>
|
>
|
||||||
Added {sortField === 'createdAt' && (sortDirection === 'asc' ? '↑' : '↓')}
|
Added {sortField === 'createdAt' && (sortDirection === 'asc' ? '↑' : '↓')}
|
||||||
</th>
|
</th>
|
||||||
<th style={{ padding: '0.75rem' }}>Activations</th>
|
<th
|
||||||
<th style={{ padding: '0.75rem' }}>Rating</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>
|
<th style={{ padding: '0.75rem' }}>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -1325,6 +1581,16 @@ export default function AdminPage() {
|
|||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
||||||
<td style={{ padding: '0.75rem', color: '#666', fontSize: '0.75rem' }}>
|
<td style={{ padding: '0.75rem', color: '#666', fontSize: '0.75rem' }}>
|
||||||
{new Date(song.createdAt).toLocaleDateString('de-DE')}
|
{new Date(song.createdAt).toLocaleDateString('de-DE')}
|
||||||
@@ -1364,6 +1630,24 @@ export default function AdminPage() {
|
|||||||
<div style={{ fontWeight: 'bold', color: '#111827' }}>{song.title}</div>
|
<div style={{ fontWeight: 'bold', color: '#111827' }}>{song.title}</div>
|
||||||
<div style={{ fontSize: '0.875rem', color: '#6b7280' }}>{song.artist}</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 */}
|
{/* Daily Puzzle Badges */}
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem', marginTop: '0.25rem' }}>
|
<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 => {
|
{song.puzzles?.filter(p => p.date === new Date().toISOString().split('T')[0]).map(p => {
|
||||||
@@ -1552,6 +1836,8 @@ export default function AdminPage() {
|
|||||||
>
|
>
|
||||||
☢️ Rebuild Database
|
☢️ Rebuild Database
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { requireAdminAuth } from '@/lib/auth';
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
@@ -63,6 +64,10 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function DELETE(request: Request) {
|
export async function DELETE(request: Request) {
|
||||||
|
// Check authentication
|
||||||
|
const authError = await requireAdminAuth(request as any);
|
||||||
|
if (authError) return authError;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { puzzleId } = await request.json();
|
const { puzzleId } = await request.json();
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import bcrypt from 'bcryptjs';
|
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 {
|
try {
|
||||||
const { password } = await request.json();
|
const { password } = await request.json();
|
||||||
// Default is hash for 'admin123'
|
// Default is hash for 'admin123'
|
||||||
|
|||||||
@@ -8,8 +8,28 @@ export async function GET(
|
|||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const { filename } = await params;
|
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);
|
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
|
// Check if file exists
|
||||||
try {
|
try {
|
||||||
await stat(filePath);
|
await stat(filePath);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { requireAdminAuth } from '@/lib/auth';
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
@@ -16,6 +17,10 @@ interface CategorizeResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
|
// Check authentication
|
||||||
|
const authError = await requireAdminAuth(request as any);
|
||||||
|
if (authError) return authError;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!OPENROUTER_API_KEY) {
|
if (!OPENROUTER_API_KEY) {
|
||||||
return Response.json(
|
return Response.json(
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { requireAdminAuth } from '@/lib/auth';
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
@@ -21,8 +22,12 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
|
// Check authentication
|
||||||
|
const authError = await requireAdminAuth(request as any);
|
||||||
|
if (authError) return authError;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { name, subtitle } = await request.json();
|
const { name, subtitle, active } = await request.json();
|
||||||
|
|
||||||
if (!name || typeof name !== 'string') {
|
if (!name || typeof name !== 'string') {
|
||||||
return NextResponse.json({ error: 'Invalid name' }, { status: 400 });
|
return NextResponse.json({ error: 'Invalid name' }, { status: 400 });
|
||||||
@@ -31,7 +36,8 @@ export async function POST(request: Request) {
|
|||||||
const genre = await prisma.genre.create({
|
const genre = await prisma.genre.create({
|
||||||
data: {
|
data: {
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
subtitle: subtitle ? subtitle.trim() : null
|
subtitle: subtitle ? subtitle.trim() : null,
|
||||||
|
active: active !== undefined ? active : true
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -43,6 +49,10 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function DELETE(request: Request) {
|
export async function DELETE(request: Request) {
|
||||||
|
// Check authentication
|
||||||
|
const authError = await requireAdminAuth(request as any);
|
||||||
|
if (authError) return authError;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { id } = await request.json();
|
const { id } = await request.json();
|
||||||
|
|
||||||
@@ -62,8 +72,12 @@ export async function DELETE(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function PUT(request: Request) {
|
export async function PUT(request: Request) {
|
||||||
|
// Check authentication
|
||||||
|
const authError = await requireAdminAuth(request as any);
|
||||||
|
if (authError) return authError;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { id, name, subtitle } = await request.json();
|
const { id, name, subtitle, active } = await request.json();
|
||||||
|
|
||||||
if (!id) {
|
if (!id) {
|
||||||
return NextResponse.json({ error: 'Missing id' }, { status: 400 });
|
return NextResponse.json({ error: 'Missing id' }, { status: 400 });
|
||||||
@@ -73,7 +87,8 @@ export async function PUT(request: Request) {
|
|||||||
where: { id: Number(id) },
|
where: { id: Number(id) },
|
||||||
data: {
|
data: {
|
||||||
...(name && { name: name.trim() }),
|
...(name && { name: name.trim() }),
|
||||||
subtitle: subtitle ? subtitle.trim() : null // Allow clearing subtitle if empty string passed? Or just update if provided? Let's assume null/empty string clears it.
|
subtitle: subtitle ? subtitle.trim() : null,
|
||||||
|
...(active !== undefined && { active })
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,14 @@ import { writeFile, unlink } from 'fs/promises';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { parseBuffer } from 'music-metadata';
|
import { parseBuffer } from 'music-metadata';
|
||||||
import { isDuplicateSong } from '@/lib/fuzzyMatch';
|
import { isDuplicateSong } from '@/lib/fuzzyMatch';
|
||||||
|
import { requireAdminAuth } from '@/lib/auth';
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
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() {
|
export async function GET() {
|
||||||
const songs = await prisma.song.findMany({
|
const songs = await prisma.song.findMany({
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
@@ -36,23 +41,63 @@ export async function GET() {
|
|||||||
specials: song.specials.map(ss => ss.special),
|
specials: song.specials.map(ss => ss.special),
|
||||||
averageRating: song.averageRating,
|
averageRating: song.averageRating,
|
||||||
ratingCount: song.ratingCount,
|
ratingCount: song.ratingCount,
|
||||||
|
excludeFromGlobal: song.excludeFromGlobal,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return NextResponse.json(songsWithActivations);
|
return NextResponse.json(songsWithActivations);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
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 {
|
try {
|
||||||
|
console.log('[UPLOAD] Parsing form data...');
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const file = formData.get('file') as File;
|
const file = formData.get('file') as File;
|
||||||
let title = '';
|
let title = '';
|
||||||
let artist = '';
|
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) {
|
if (!file) {
|
||||||
|
console.error('[UPLOAD] No file provided');
|
||||||
return NextResponse.json({ error: 'No file provided' }, { status: 400 });
|
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());
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
|
console.log('[UPLOAD] Buffer created, size:', buffer.length, 'bytes');
|
||||||
|
|
||||||
// Validate and extract metadata from file
|
// Validate and extract metadata from file
|
||||||
let metadata;
|
let metadata;
|
||||||
@@ -180,16 +225,17 @@ export async function POST(request: Request) {
|
|||||||
console.error('Failed to extract cover image:', e);
|
console.error('Failed to extract cover image:', e);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch release year from MusicBrainz
|
// Fetch release year from iTunes
|
||||||
let releaseYear = null;
|
let releaseYear = null;
|
||||||
try {
|
try {
|
||||||
const { getReleaseYear } = await import('@/lib/musicbrainz');
|
const { getReleaseYearFromItunes } = await import('@/lib/itunes');
|
||||||
releaseYear = await getReleaseYear(artist, title);
|
releaseYear = await getReleaseYearFromItunes(artist, title);
|
||||||
|
|
||||||
if (releaseYear) {
|
if (releaseYear) {
|
||||||
console.log(`Fetched release year ${releaseYear} for "${title}" by "${artist}"`);
|
console.log(`Fetched release year ${releaseYear} from iTunes for "${title}" by "${artist}"`);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to fetch release year from MusicBrainz:', e);
|
console.error('Failed to fetch release year:', e);
|
||||||
}
|
}
|
||||||
|
|
||||||
const song = await prisma.song.create({
|
const song = await prisma.song.create({
|
||||||
@@ -199,6 +245,7 @@ export async function POST(request: Request) {
|
|||||||
filename,
|
filename,
|
||||||
coverImage,
|
coverImage,
|
||||||
releaseYear,
|
releaseYear,
|
||||||
|
excludeFromGlobal,
|
||||||
},
|
},
|
||||||
include: { genres: true, specials: true }
|
include: { genres: true, specials: true }
|
||||||
});
|
});
|
||||||
@@ -214,8 +261,12 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function PUT(request: Request) {
|
export async function PUT(request: Request) {
|
||||||
|
// Check authentication
|
||||||
|
const authError = await requireAdminAuth(request as any);
|
||||||
|
if (authError) return authError;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { id, title, artist, releaseYear, genreIds, specialIds } = await request.json();
|
const { id, title, artist, releaseYear, genreIds, specialIds, excludeFromGlobal } = await request.json();
|
||||||
|
|
||||||
if (!id || !title || !artist) {
|
if (!id || !title || !artist) {
|
||||||
return NextResponse.json({ error: 'Missing fields' }, { status: 400 });
|
return NextResponse.json({ error: 'Missing fields' }, { status: 400 });
|
||||||
@@ -228,6 +279,10 @@ export async function PUT(request: Request) {
|
|||||||
data.releaseYear = releaseYear;
|
data.releaseYear = releaseYear;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (excludeFromGlobal !== undefined) {
|
||||||
|
data.excludeFromGlobal = excludeFromGlobal;
|
||||||
|
}
|
||||||
|
|
||||||
if (genreIds) {
|
if (genreIds) {
|
||||||
data.genres = {
|
data.genres = {
|
||||||
set: genreIds.map((gId: number) => ({ id: gId }))
|
set: genreIds.map((gId: number) => ({ id: gId }))
|
||||||
@@ -289,6 +344,10 @@ export async function PUT(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function DELETE(request: Request) {
|
export async function DELETE(request: Request) {
|
||||||
|
// Check authentication
|
||||||
|
const authError = await requireAdminAuth(request as any);
|
||||||
|
if (authError) return authError;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { id } = await request.json();
|
const { id } = await request.json();
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { PrismaClient, Special } from '@prisma/client';
|
import { PrismaClient, Special } from '@prisma/client';
|
||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
|
import { requireAdminAuth } from '@/lib/auth';
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
@@ -16,6 +17,10 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
|
// 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();
|
const { name, subtitle, maxAttempts = 7, unlockSteps = '[2,4,7,11,16,30,60]', launchDate, endDate, curator } = await request.json();
|
||||||
if (!name) {
|
if (!name) {
|
||||||
return NextResponse.json({ error: 'Name is required' }, { status: 400 });
|
return NextResponse.json({ error: 'Name is required' }, { status: 400 });
|
||||||
@@ -35,6 +40,10 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function DELETE(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();
|
const { id } = await request.json();
|
||||||
if (!id) {
|
if (!id) {
|
||||||
return NextResponse.json({ error: 'ID required' }, { status: 400 });
|
return NextResponse.json({ error: 'ID required' }, { status: 400 });
|
||||||
@@ -44,6 +53,10 @@ export async function DELETE(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function PUT(request: Request) {
|
export async function PUT(request: Request) {
|
||||||
|
// 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();
|
const { id, name, subtitle, maxAttempts, unlockSteps, launchDate, endDate, curator } = await request.json();
|
||||||
if (!id) {
|
if (!id) {
|
||||||
return NextResponse.json({ error: 'ID required' }, { status: 400 });
|
return NextResponse.json({ error: 'ID required' }, { status: 400 });
|
||||||
|
|||||||
42
app/api/version/route.ts
Normal file
42
app/api/version/route.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { execSync } from 'child_process';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
// 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' });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ export const viewport: Viewport = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
import InstallPrompt from "@/components/InstallPrompt";
|
import InstallPrompt from "@/components/InstallPrompt";
|
||||||
|
import AppFooter from "@/components/AppFooter";
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
@@ -36,15 +37,7 @@ export default function RootLayout({
|
|||||||
<body className={`${geistSans.variable} ${geistMono.variable}`}>
|
<body className={`${geistSans.variable} ${geistMono.variable}`}>
|
||||||
{children}
|
{children}
|
||||||
<InstallPrompt />
|
<InstallPrompt />
|
||||||
<footer className="app-footer">
|
<AppFooter />
|
||||||
<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>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,7 +9,10 @@ const prisma = new PrismaClient();
|
|||||||
|
|
||||||
export default async function Home() {
|
export default async function Home() {
|
||||||
const dailyPuzzle = await getOrCreateDailyPuzzle(null); // Global puzzle
|
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 specials = await prisma.special.findMany({ orderBy: { name: 'asc' } });
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|||||||
34
components/AppFooter.tsx
Normal file
34
components/AppFooter.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -173,7 +173,8 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
}
|
}
|
||||||
|
|
||||||
const speaker = hasWon ? '🔉' : '🔇';
|
const speaker = hasWon ? '🔉' : '🔇';
|
||||||
const genreText = genre ? `Genre: ${genre}\n` : '';
|
const bonusStar = (hasWon && gameState.yearGuessed && dailyPuzzle.releaseYear && gameState.scoreBreakdown.some(item => item.reason === 'Bonus: Correct Year')) ? '⭐' : '';
|
||||||
|
const genreText = genre ? `${isSpecial ? 'Special' : 'Genre'}: ${genre}\n` : '';
|
||||||
|
|
||||||
let shareUrl = 'https://hoerdle.elpatron.me';
|
let shareUrl = 'https://hoerdle.elpatron.me';
|
||||||
if (genre) {
|
if (genre) {
|
||||||
@@ -184,7 +185,7 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const text = `Hördle #${dailyPuzzle.puzzleNumber}\n${genreText}\n${speaker}${emojiGrid}\nScore: ${gameState.score}\n\n#Hördle #Music\n\n${shareUrl}`;
|
const text = `Hördle #${dailyPuzzle.puzzleNumber}\n${genreText}\n${speaker}${emojiGrid}${bonusStar}\nScore: ${gameState.score}\n\n#Hördle #Music\n\n${shareUrl}`;
|
||||||
|
|
||||||
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
|
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
|
||||||
|
|
||||||
@@ -332,7 +333,7 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
/>
|
/>
|
||||||
<h3 style={{ fontSize: '1.125rem', fontWeight: 'bold', margin: '0 0 0.5rem 0' }}>{dailyPuzzle.title}</h3>
|
<h3 style={{ fontSize: '1.125rem', fontWeight: 'bold', margin: '0 0 0.5rem 0' }}>{dailyPuzzle.title}</h3>
|
||||||
<p style={{ fontSize: '0.875rem', color: '#666', margin: '0 0 0.5rem 0' }}>{dailyPuzzle.artist}</p>
|
<p style={{ fontSize: '0.875rem', color: '#666', margin: '0 0 0.5rem 0' }}>{dailyPuzzle.artist}</p>
|
||||||
{dailyPuzzle.releaseYear && (
|
{dailyPuzzle.releaseYear && gameState.yearGuessed && (
|
||||||
<p style={{ fontSize: '0.875rem', color: '#666', margin: '0 0 1rem 0' }}>Released: {dailyPuzzle.releaseYear}</p>
|
<p style={{ fontSize: '0.875rem', color: '#666', margin: '0 0 1rem 0' }}>Released: {dailyPuzzle.releaseYear}</p>
|
||||||
)}
|
)}
|
||||||
<audio controls style={{ width: '100%' }}>
|
<audio controls style={{ width: '100%' }}>
|
||||||
|
|||||||
@@ -26,4 +26,4 @@ services:
|
|||||||
start_period: 40s
|
start_period: 40s
|
||||||
# Run migrations and start server (auto-baseline on first run if needed)
|
# Run migrations and start server (auto-baseline on first run if needed)
|
||||||
command: >
|
command: >
|
||||||
sh -c "npx prisma migrate deploy || (echo 'Baselining existing database...' && sh scripts/baseline-migrations.sh && npx prisma migrate deploy) && node scripts/migrate-release-years.mjs && node scripts/migrate-covers.mjs && node server.js"
|
sh -c "npx prisma migrate deploy || (echo 'Baselining existing database...' && sh scripts/baseline-migrations.sh && npx prisma migrate deploy) && node server.js"
|
||||||
|
|||||||
37
lib/auth.ts
Normal file
37
lib/auth.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -33,7 +33,7 @@ export async function getOrCreateDailyPuzzle(genreName: string | null = null) {
|
|||||||
// Get songs available for this genre
|
// Get songs available for this genre
|
||||||
const whereClause = genreId
|
const whereClause = genreId
|
||||||
? { genres: { some: { id: 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({
|
const allSongs = await prisma.song.findMany({
|
||||||
where: whereClause,
|
where: whereClause,
|
||||||
|
|||||||
125
lib/itunes.ts
Normal file
125
lib/itunes.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
/**
|
|
||||||
* MusicBrainz API integration for fetching release years
|
|
||||||
* API Documentation: https://musicbrainz.org/doc/MusicBrainz_API
|
|
||||||
* Rate Limiting: 50 requests per second for meaningful User-Agent strings
|
|
||||||
*/
|
|
||||||
|
|
||||||
const MUSICBRAINZ_API_BASE = 'https://musicbrainz.org/ws/2';
|
|
||||||
const USER_AGENT = 'hoerdle/0.1.0 ( elpatron@mailbox.org )';
|
|
||||||
const RATE_LIMIT_DELAY = 25; // 25ms between requests = ~40 req/s (safe margin)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sleep utility for rate limiting
|
|
||||||
*/
|
|
||||||
function sleep(ms: number): Promise<void> {
|
|
||||||
return new Promise(resolve => setTimeout(resolve, ms));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch with retry logic for HTTP 503 (rate limit exceeded)
|
|
||||||
*/
|
|
||||||
async function fetchWithRetry(url: string, maxRetries = 5): Promise<Response> {
|
|
||||||
let lastError: Error | null = null;
|
|
||||||
|
|
||||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
||||||
try {
|
|
||||||
const response = await fetch(url, {
|
|
||||||
headers: {
|
|
||||||
'User-Agent': USER_AGENT,
|
|
||||||
'Accept': 'application/json'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// If rate limited (503), wait with exponential backoff
|
|
||||||
if (response.status === 503) {
|
|
||||||
const waitTime = Math.pow(2, attempt) * 1000; // 1s, 2s, 4s, 8s, 16s
|
|
||||||
console.log(`Rate limited (503), waiting ${waitTime}ms before retry ${attempt + 1}/${maxRetries}...`);
|
|
||||||
await sleep(waitTime);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
return response;
|
|
||||||
} catch (error) {
|
|
||||||
lastError = error as Error;
|
|
||||||
if (attempt < maxRetries - 1) {
|
|
||||||
const waitTime = Math.pow(2, attempt) * 1000;
|
|
||||||
console.log(`Network error, waiting ${waitTime}ms before retry ${attempt + 1}/${maxRetries}...`);
|
|
||||||
await sleep(waitTime);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw lastError || new Error('Max retries exceeded');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the earliest release year for a song from MusicBrainz
|
|
||||||
* @param artist Artist name
|
|
||||||
* @param title Song title
|
|
||||||
* @returns Release year or null if not found
|
|
||||||
*/
|
|
||||||
export async function getReleaseYear(artist: string, title: string): Promise<number | null> {
|
|
||||||
try {
|
|
||||||
// Build search query using Lucene syntax
|
|
||||||
const query = `artist:"${artist}" AND recording:"${title}"`;
|
|
||||||
const url = `${MUSICBRAINZ_API_BASE}/recording?query=${encodeURIComponent(query)}&fmt=json&limit=10`;
|
|
||||||
|
|
||||||
// Add rate limiting delay
|
|
||||||
await sleep(RATE_LIMIT_DELAY);
|
|
||||||
|
|
||||||
const response = await fetchWithRetry(url);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
console.error(`MusicBrainz API error: ${response.status} ${response.statusText}`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (!data.recordings || data.recordings.length === 0) {
|
|
||||||
console.log(`No recordings found for "${title}" by "${artist}"`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find the earliest release year from all recordings
|
|
||||||
let earliestYear: number | null = null;
|
|
||||||
|
|
||||||
for (const recording of data.recordings) {
|
|
||||||
// Check if recording has releases
|
|
||||||
if (recording.releases && recording.releases.length > 0) {
|
|
||||||
for (const release of recording.releases) {
|
|
||||||
if (release.date) {
|
|
||||||
// Extract year from date (format: YYYY-MM-DD or YYYY)
|
|
||||||
const year = parseInt(release.date.split('-')[0]);
|
|
||||||
if (!isNaN(year) && (earliestYear === null || year < earliestYear)) {
|
|
||||||
earliestYear = year;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Also check first-release-date on the recording itself
|
|
||||||
if (recording['first-release-date']) {
|
|
||||||
const year = parseInt(recording['first-release-date'].split('-')[0]);
|
|
||||||
if (!isNaN(year) && (earliestYear === null || year < earliestYear)) {
|
|
||||||
earliestYear = year;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (earliestYear) {
|
|
||||||
console.log(`Found release year ${earliestYear} for "${title}" by "${artist}"`);
|
|
||||||
} else {
|
|
||||||
console.log(`No release year found for "${title}" by "${artist}"`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return earliestYear;
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Error fetching release year for "${title}" by "${artist}":`, error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
76
lib/rateLimit.ts
Normal file
76
lib/rateLimit.ts
Normal 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
52
middleware.ts
Normal 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).*)',
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -8,6 +8,7 @@ const nextConfig: NextConfig = {
|
|||||||
serverActions: {
|
serverActions: {
|
||||||
bodySizeLimit: '50mb',
|
bodySizeLimit: '50mb',
|
||||||
},
|
},
|
||||||
|
middlewareClientMaxBodySize: '50mb',
|
||||||
},
|
},
|
||||||
env: {
|
env: {
|
||||||
TZ: process.env.TZ || 'Europe/Berlin',
|
TZ: process.env.TZ || 'Europe/Berlin',
|
||||||
|
|||||||
BIN
prisma/dev.db.bak
Normal file
BIN
prisma/dev.db.bak
Normal file
Binary file not shown.
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -16,19 +16,21 @@ model Song {
|
|||||||
artist String
|
artist String
|
||||||
filename String // Filename in public/uploads
|
filename String // Filename in public/uploads
|
||||||
coverImage String? // Filename in public/uploads/covers
|
coverImage String? // Filename in public/uploads/covers
|
||||||
releaseYear Int? // Release year from MusicBrainz
|
releaseYear Int? // Release year from iTunes
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
puzzles DailyPuzzle[]
|
puzzles DailyPuzzle[]
|
||||||
genres Genre[]
|
genres Genre[]
|
||||||
specials SpecialSong[]
|
specials SpecialSong[]
|
||||||
averageRating Float @default(0)
|
averageRating Float @default(0)
|
||||||
ratingCount Int @default(0)
|
ratingCount Int @default(0)
|
||||||
|
excludeFromGlobal Boolean @default(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
model Genre {
|
model Genre {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
name String @unique
|
name String @unique
|
||||||
subtitle String?
|
subtitle String?
|
||||||
|
active Boolean @default(true)
|
||||||
songs Song[]
|
songs Song[]
|
||||||
dailyPuzzles DailyPuzzle[]
|
dailyPuzzles DailyPuzzle[]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,10 +7,7 @@ echo "Starting deployment..."
|
|||||||
echo "Running database migrations..."
|
echo "Running database migrations..."
|
||||||
npx prisma migrate deploy
|
npx prisma migrate deploy
|
||||||
|
|
||||||
# Run release year migration (only if not already done)
|
|
||||||
# Run release year migration (idempotent, skips if all done)
|
|
||||||
echo "Running release year migration check..."
|
|
||||||
node scripts/migrate-release-years.mjs
|
|
||||||
|
|
||||||
# Start the application
|
# Start the application
|
||||||
echo "Starting application..."
|
echo "Starting application..."
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
import { parseBuffer } from 'music-metadata';
|
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 path from 'path';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
@@ -11,6 +11,16 @@ const __dirname = path.dirname(__filename);
|
|||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
async function migrate() {
|
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...');
|
console.log('Starting cover art migration...');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -23,10 +33,18 @@ async function migrate() {
|
|||||||
|
|
||||||
console.log(`Found ${songs.length} songs without cover image.`);
|
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) {
|
for (const song of songs) {
|
||||||
try {
|
try {
|
||||||
const filePath = path.join(process.cwd(), 'public/uploads', song.filename);
|
const filePath = path.join(process.cwd(), 'public/uploads', song.filename);
|
||||||
console.log(`Processing ${song.title} (${song.filename})...`);
|
|
||||||
|
|
||||||
const buffer = await readFile(filePath);
|
const buffer = await readFile(filePath);
|
||||||
const metadata = await parseBuffer(buffer);
|
const metadata = await parseBuffer(buffer);
|
||||||
@@ -47,14 +65,20 @@ async function migrate() {
|
|||||||
data: { coverImage: coverFilename }
|
data: { coverImage: coverFilename }
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`✅ Extracted cover for ${song.title}`);
|
successful++;
|
||||||
}
|
}
|
||||||
|
processed++;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`❌ Failed to process ${song.title}:`, e.message);
|
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) {
|
} catch (e) {
|
||||||
console.error('Migration failed:', e);
|
console.error('Migration failed:', e);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -1,208 +0,0 @@
|
|||||||
import { PrismaClient } from '@prisma/client';
|
|
||||||
import { writeFile } from 'fs/promises';
|
|
||||||
import path from 'path';
|
|
||||||
import { fileURLToPath } from 'url';
|
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
|
||||||
const __dirname = path.dirname(__filename);
|
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
// --- MusicBrainz Logic (Embedded to avoid TS import issues in Docker) ---
|
|
||||||
|
|
||||||
const MUSICBRAINZ_API_BASE = 'https://musicbrainz.org/ws/2';
|
|
||||||
const USER_AGENT = 'hoerdle/0.1.0 ( elpatron@mailbox.org )';
|
|
||||||
const RATE_LIMIT_DELAY = 250; // 250ms between requests (very conservative)
|
|
||||||
|
|
||||||
function sleep(ms) {
|
|
||||||
return new Promise(resolve => setTimeout(resolve, ms));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchWithRetry(url, maxRetries = 10) {
|
|
||||||
let lastError = null;
|
|
||||||
|
|
||||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
||||||
try {
|
|
||||||
const response = await fetch(url, {
|
|
||||||
headers: {
|
|
||||||
'User-Agent': USER_AGENT,
|
|
||||||
'Accept': 'application/json'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.status === 503) {
|
|
||||||
const waitTime = Math.pow(2, attempt) * 1000;
|
|
||||||
console.log(`Rate limited (503), waiting ${waitTime}ms before retry ${attempt + 1}/${maxRetries}...`);
|
|
||||||
await sleep(waitTime);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
return response;
|
|
||||||
} catch (error) {
|
|
||||||
lastError = error;
|
|
||||||
if (attempt < maxRetries - 1) {
|
|
||||||
const waitTime = Math.pow(2, attempt) * 1000;
|
|
||||||
console.log(`Network error, waiting ${waitTime}ms before retry ${attempt + 1}/${maxRetries}...`);
|
|
||||||
await sleep(waitTime);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw lastError || new Error('Max retries exceeded');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getReleaseYear(artist, title) {
|
|
||||||
const search = async (query, type) => {
|
|
||||||
const url = `${MUSICBRAINZ_API_BASE}/recording?query=${encodeURIComponent(query)}&fmt=json&limit=5`;
|
|
||||||
await sleep(RATE_LIMIT_DELAY);
|
|
||||||
const response = await fetchWithRetry(url);
|
|
||||||
if (!response.ok) throw new Error(`API Error ${response.status}: ${response.statusText}`);
|
|
||||||
return response.json();
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 1. Strict Search
|
|
||||||
let data = await search(`artist:"${artist}" AND recording:"${title}"`, 'strict');
|
|
||||||
|
|
||||||
// 2. Fallback: Fuzzy Search if no recordings found
|
|
||||||
if (!data.recordings || data.recordings.length === 0) {
|
|
||||||
// Remove special chars and quotes for fuzzy search
|
|
||||||
const cleanArtist = artist.replace(/[^\w\s]/g, ' ').replace(/\s+/g, ' ').trim();
|
|
||||||
const cleanTitle = title.replace(/[^\w\s]/g, ' ').replace(/\s+/g, ' ').trim();
|
|
||||||
|
|
||||||
// Only try fuzzy if the cleaned strings are valid
|
|
||||||
if (cleanArtist && cleanTitle) {
|
|
||||||
console.log(` Trying fuzzy search for: ${cleanTitle} by ${cleanArtist}`);
|
|
||||||
data = await search(`artist:${cleanArtist} AND recording:${cleanTitle}`, 'fuzzy');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!data.recordings || data.recordings.length === 0) {
|
|
||||||
console.log(` ❌ No recordings found for "${title}" by "${artist}"`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
let earliestYear = null;
|
|
||||||
|
|
||||||
for (const recording of data.recordings) {
|
|
||||||
// Check releases linked to recording
|
|
||||||
if (recording.releases && recording.releases.length > 0) {
|
|
||||||
for (const release of recording.releases) {
|
|
||||||
if (release.date) {
|
|
||||||
const year = parseInt(release.date.split('-')[0]);
|
|
||||||
if (!isNaN(year) && (earliestYear === null || year < earliestYear)) {
|
|
||||||
earliestYear = year;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check first-release-date on recording itself
|
|
||||||
if (recording['first-release-date']) {
|
|
||||||
const year = parseInt(recording['first-release-date'].split('-')[0]);
|
|
||||||
if (!isNaN(year) && (earliestYear === null || year < earliestYear)) {
|
|
||||||
earliestYear = year;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (earliestYear) {
|
|
||||||
// console.log(` ✅ Found year: ${earliestYear}`);
|
|
||||||
} else {
|
|
||||||
console.log(` ⚠️ Recordings found but NO YEAR for "${title}" by "${artist}"`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return earliestYear;
|
|
||||||
} catch (error) {
|
|
||||||
console.error(` ❌ Error fetching release year for "${title}" by "${artist}":`, error.message);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Migration Logic ---
|
|
||||||
|
|
||||||
async function migrate() {
|
|
||||||
console.log('🎵 Starting release year migration...');
|
|
||||||
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Find songs without release year
|
|
||||||
const songs = await prisma.song.findMany({
|
|
||||||
where: {
|
|
||||||
releaseYear: null
|
|
||||||
},
|
|
||||||
orderBy: {
|
|
||||||
id: 'asc'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`📊 Found ${songs.length} songs without release year.\n`);
|
|
||||||
|
|
||||||
if (songs.length === 0) {
|
|
||||||
console.log('✅ All songs already have release years!');
|
|
||||||
await createFlagFile();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let processed = 0;
|
|
||||||
let successful = 0;
|
|
||||||
let failed = 0;
|
|
||||||
const startTime = Date.now();
|
|
||||||
|
|
||||||
for (const song of songs) {
|
|
||||||
processed++;
|
|
||||||
// const progress = `[${processed}/${songs.length}]`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// console.log(`${progress} Processing: "${song.title}" by "${song.artist}"`);
|
|
||||||
|
|
||||||
const releaseYear = await getReleaseYear(song.artist, song.title);
|
|
||||||
|
|
||||||
if (releaseYear) {
|
|
||||||
await prisma.song.update({
|
|
||||||
where: { id: song.id },
|
|
||||||
data: { releaseYear }
|
|
||||||
});
|
|
||||||
successful++;
|
|
||||||
// console.log(` ✅ Updated with year: ${releaseYear}`);
|
|
||||||
} else {
|
|
||||||
failed++;
|
|
||||||
console.log(` ⚠️ No release year found for "${song.title}" by "${song.artist}"`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
failed++;
|
|
||||||
console.error(` ❌ Error processing song:`, error instanceof Error ? error.message : error);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Progress update every 10 songs (less verbose)
|
|
||||||
if (processed % 10 === 0 || processed === songs.length) {
|
|
||||||
const elapsed = Math.round((Date.now() - startTime) / 1000);
|
|
||||||
const rate = processed / (elapsed || 1);
|
|
||||||
const remaining = songs.length - processed;
|
|
||||||
const eta = Math.round(remaining / rate);
|
|
||||||
process.stdout.write(`\r📈 Progress: ${processed}/${songs.length} | Success: ${successful} | Failed: ${failed} | ETA: ${eta}s`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalTime = Math.round((Date.now() - startTime) / 1000);
|
|
||||||
console.log('\n\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
||||||
console.log('✅ Migration completed!');
|
|
||||||
console.log(`📊 Total: ${processed} | Success: ${successful} | Failed: ${failed}`);
|
|
||||||
console.log(`⏱️ Time: ${totalTime}s (${(processed / (totalTime || 1)).toFixed(2)} songs/s)`);
|
|
||||||
|
|
||||||
await createFlagFile();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Migration failed:', error);
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
await prisma.$disconnect();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createFlagFile() {
|
|
||||||
const flagPath = path.join(process.cwd(), '.release-years-migrated');
|
|
||||||
await writeFile(flagPath, new Date().toISOString());
|
|
||||||
console.log(`\n🏁 Created flag file: ${flagPath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
migrate();
|
|
||||||
211
scripts/slow-refresh-itunes.js
Normal file
211
scripts/slow-refresh-itunes.js
Normal 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();
|
||||||
Reference in New Issue
Block a user