Compare commits

...

10 Commits

14 changed files with 277 additions and 172 deletions

View File

@@ -13,9 +13,16 @@ RUN npm ci
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
# Install git to extract version information
RUN apk add --no-cache git
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Extract version from git
RUN git describe --tags --always 2>/dev/null > /tmp/version.txt || echo "unknown" > /tmp/version.txt
# Next.js collects completely anonymous telemetry data about general usage.
# Learn more here: https://nextjs.org/telemetry
# Uncomment the following line in case you want to disable telemetry during the build.
@@ -53,6 +60,9 @@ COPY --from=builder --chown=nextjs:nodejs /app/node_modules ./node_modules
# Create uploads directory and set permissions
RUN mkdir -p public/uploads/covers && chown -R nextjs:nodejs public/uploads
# Copy version file from builder
COPY --from=builder /tmp/version.txt /app/version.txt
USER nextjs
EXPOSE 3000

View File

@@ -12,18 +12,22 @@ Eine Web-App inspiriert von Heardle, bei der Nutzer täglich einen Song anhand k
- Automatische Extraktion von ID3-Tags (Titel, Interpret).
- Intelligente Artist-Erkennung (unterstützt Multi-Artist-Tags).
- Bearbeitung von Metadaten.
- Sortierbare Song-Bibliothek (Titel, Interpret, Hinzugefügt am).
- Sortierbare Song-Bibliothek (Titel, Interpret, Hinzugefügt am, Erscheinungsjahr, Aktivierungen, Rating).
- Play/Pause-Funktion zum Vorhören in der Bibliothek.
- **Cover Art:**
- Automatische Extraktion von Cover-Bildern aus MP3-Dateien.
- Anzeige des Covers nach Spielende (Sieg/Niederlage).
- Automatische Migration bestehender Songs.
- **Teilen-Funktion:** Ergebnisse können als Emoji-Grid geteilt werden.
- **Teilen-Funktion:**
- Ergebnisse können als Emoji-Grid geteilt werden.
- Stern-Symbol (⭐) bei korrekt beantworteter Bonusfrage.
- Automatische Anpassung für Genre- und Special-Rätsel.
- **PWA Support:** Installierbar als App auf Desktop und Mobilgeräten (Manifest & Icons).
- **Persistenz:** Spielstatus wird lokal im Browser gespeichert.
- **Benachrichtigungen:** Integration mit Gotify für Push-Nachrichten bei Spielabschluss.
- **Genre-Management:**
- Erstellen und Verwalten von Musik-Genres.
- **Aktivierung/Deaktivierung:** Genres können aktiviert oder deaktiviert werden (deaktivierte Genres sind nicht auf der Startseite sichtbar und ihre Routen sind nicht erreichbar).
- Manuelle Zuweisung von Genres zu Songs.
- KI-gestützte automatische Kategorisierung mit OpenRouter (Claude 3.5 Haiku).
- Genre-spezifische tägliche Rätsel.

View File

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

View File

@@ -21,6 +21,7 @@ interface Genre {
id: number;
name: string;
subtitle?: string;
active: boolean;
_count?: {
songs: number;
};
@@ -50,7 +51,7 @@ interface Song {
excludeFromGlobal: boolean;
}
type SortField = 'id' | 'title' | 'artist' | 'createdAt' | 'releaseYear';
type SortField = 'id' | 'title' | 'artist' | 'createdAt' | 'releaseYear' | 'activations' | 'averageRating';
type SortDirection = 'asc' | 'desc';
export default function AdminPage() {
@@ -66,9 +67,11 @@ export default function AdminPage() {
const [genres, setGenres] = useState<Genre[]>([]);
const [newGenreName, setNewGenreName] = useState('');
const [newGenreSubtitle, setNewGenreSubtitle] = useState('');
const [newGenreActive, setNewGenreActive] = useState(true);
const [editingGenreId, setEditingGenreId] = useState<number | null>(null);
const [editGenreName, setEditGenreName] = useState('');
const [editGenreSubtitle, setEditGenreSubtitle] = useState('');
const [editGenreActive, setEditGenreActive] = useState(true);
// Specials state
const [specials, setSpecials] = useState<Special[]>([]);
@@ -103,6 +106,9 @@ export default function AdminPage() {
const [uploadGenreIds, setUploadGenreIds] = useState<number[]>([]);
const [uploadExcludeFromGlobal, setUploadExcludeFromGlobal] = useState(false);
// Batch upload genre selection
const [batchUploadGenreIds, setBatchUploadGenreIds] = useState<number[]>([]);
// AI Categorization state
const [isCategorizing, setIsCategorizing] = useState(false);
const [categorizationResults, setCategorizationResults] = useState<any>(null);
@@ -200,11 +206,16 @@ export default function AdminPage() {
const res = await fetch('/api/genres', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ name: newGenreName, subtitle: newGenreSubtitle }),
body: JSON.stringify({
name: newGenreName,
subtitle: newGenreSubtitle,
active: newGenreActive
}),
});
if (res.ok) {
setNewGenreName('');
setNewGenreSubtitle('');
setNewGenreActive(true);
fetchGenres();
} else {
alert('Failed to create genre');
@@ -215,6 +226,7 @@ export default function AdminPage() {
setEditingGenreId(genre.id);
setEditGenreName(genre.name);
setEditGenreSubtitle(genre.subtitle || '');
setEditGenreActive(genre.active !== undefined ? genre.active : true);
};
const saveEditedGenre = async () => {
@@ -225,7 +237,8 @@ export default function AdminPage() {
body: JSON.stringify({
id: editingGenreId,
name: editGenreName,
subtitle: editGenreSubtitle
subtitle: editGenreSubtitle,
active: editGenreActive
}),
});
if (res.ok) {
@@ -538,6 +551,28 @@ export default function AdminPage() {
setUploadResults(results);
setFiles([]);
setIsUploading(false);
// Assign genres to successfully uploaded songs
if (batchUploadGenreIds.length > 0) {
const successfulUploads = results.filter(r => r.success && r.song);
for (const result of successfulUploads) {
try {
await fetch('/api/songs', {
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify({
id: result.song.id,
title: result.song.title,
artist: result.song.artist,
genreIds: batchUploadGenreIds
}),
});
} catch (error) {
console.error(`Failed to assign genres to ${result.song.title}:`, error);
}
}
}
fetchSongs();
fetchGenres();
fetchSpecials(); // Update special counts
@@ -554,6 +589,13 @@ export default function AdminPage() {
if (failedCount > 0) {
msg += `\n❌ ${failedCount} failed`;
}
if (batchUploadGenreIds.length > 0) {
const selectedGenreNames = genres
.filter(g => batchUploadGenreIds.includes(g.id))
.map(g => g.name)
.join(', ');
msg += `\n🏷 Assigned genres: ${selectedGenreNames}`;
}
msg += '\n\n🤖 Starting auto-categorization...';
setMessage(msg);
// Small delay to let user see the message
@@ -814,7 +856,7 @@ export default function AdminPage() {
});
const sortedSongs = [...filteredSongs].sort((a, b) => {
// Handle numeric sorting for ID and Release Year
// Handle numeric sorting for ID, Release Year, Activations, and Rating
if (sortField === 'id') {
return sortDirection === 'asc' ? a.id - b.id : b.id - a.id;
}
@@ -823,6 +865,12 @@ export default function AdminPage() {
const yearB = b.releaseYear || 0;
return sortDirection === 'asc' ? yearA - yearB : yearB - yearA;
}
if (sortField === 'activations') {
return sortDirection === 'asc' ? a.activations - b.activations : b.activations - a.activations;
}
if (sortField === 'averageRating') {
return sortDirection === 'asc' ? a.averageRating - b.averageRating : b.averageRating - a.averageRating;
}
// String sorting for other fields
const valA = String(a[sortField]).toLowerCase();
@@ -978,7 +1026,7 @@ export default function AdminPage() {
{/* Genre Management */}
<div className="admin-card" style={{ marginBottom: '2rem' }}>
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem' }}>Manage Genres</h2>
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1rem' }}>
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1rem', alignItems: 'center' }}>
<input
type="text"
value={newGenreName}
@@ -995,12 +1043,21 @@ export default function AdminPage() {
className="form-input"
style={{ maxWidth: '300px' }}
/>
<label style={{ display: 'flex', alignItems: 'center', gap: '0.25rem', fontSize: '0.875rem', cursor: 'pointer' }}>
<input
type="checkbox"
checked={newGenreActive}
onChange={e => setNewGenreActive(e.target.checked)}
/>
Active
</label>
<button onClick={createGenre} className="btn-primary">Add Genre</button>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
{genres.map(genre => (
<div key={genre.id} style={{
background: '#f3f4f6',
background: genre.active ? '#f3f4f6' : '#fee2e2',
opacity: genre.active ? 1 : 0.8,
padding: '0.25rem 0.75rem',
borderRadius: '999px',
display: 'flex',
@@ -1027,6 +1084,16 @@ export default function AdminPage() {
<label style={{ fontSize: '0.75rem', color: '#666' }}>Subtitle</label>
<input type="text" value={editGenreSubtitle} onChange={e => setEditGenreSubtitle(e.target.value)} className="form-input" style={{ width: '300px' }} />
</div>
<div style={{ display: 'flex', flexDirection: 'column', justifyContent: 'flex-end', paddingBottom: '0.5rem' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '0.25rem', fontSize: '0.875rem', cursor: 'pointer' }}>
<input
type="checkbox"
checked={editGenreActive}
onChange={e => setEditGenreActive(e.target.checked)}
/>
Active
</label>
</div>
<button onClick={saveEditedGenre} className="btn-primary">Save</button>
<button onClick={() => setEditingGenreId(null)} className="btn-secondary">Cancel</button>
</div>
@@ -1184,6 +1251,48 @@ export default function AdminPage() {
</div>
)}
<div style={{ marginBottom: '1rem' }}>
<label style={{ fontWeight: '500', display: 'block', marginBottom: '0.5rem' }}>
Assign Genres (optional)
</label>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
{genres.map(genre => (
<label
key={genre.id}
style={{
display: 'flex',
alignItems: 'center',
gap: '0.25rem',
padding: '0.25rem 0.5rem',
background: batchUploadGenreIds.includes(genre.id) ? '#dbeafe' : '#f3f4f6',
border: batchUploadGenreIds.includes(genre.id) ? '2px solid #3b82f6' : '2px solid transparent',
borderRadius: '0.25rem',
cursor: 'pointer',
fontSize: '0.875rem',
transition: 'all 0.2s'
}}
>
<input
type="checkbox"
checked={batchUploadGenreIds.includes(genre.id)}
onChange={e => {
if (e.target.checked) {
setBatchUploadGenreIds([...batchUploadGenreIds, genre.id]);
} else {
setBatchUploadGenreIds(batchUploadGenreIds.filter(id => id !== genre.id));
}
}}
style={{ margin: 0 }}
/>
{genre.name}
</label>
))}
</div>
<p style={{ fontSize: '0.875rem', color: '#666', marginTop: '0.25rem' }}>
Selected genres will be assigned to all uploaded songs.
</p>
</div>
<div style={{ marginBottom: '1rem' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer' }}>
<input
@@ -1385,8 +1494,18 @@ export default function AdminPage() {
>
Added {sortField === 'createdAt' && (sortDirection === 'asc' ? '' : '')}
</th>
<th style={{ padding: '0.75rem' }}>Activations</th>
<th style={{ padding: '0.75rem' }}>Rating</th>
<th
style={{ padding: '0.75rem', cursor: 'pointer', userSelect: 'none' }}
onClick={() => handleSort('activations')}
>
Activations {sortField === 'activations' && (sortDirection === 'asc' ? '' : '')}
</th>
<th
style={{ padding: '0.75rem', cursor: 'pointer', userSelect: 'none' }}
onClick={() => handleSort('averageRating')}
>
Rating {sortField === 'averageRating' && (sortDirection === 'asc' ? '' : '')}
</th>
<th style={{ padding: '0.75rem' }}>Actions</th>
</tr>
</thead>
@@ -1718,73 +1837,7 @@ export default function AdminPage() {
Rebuild Database
</button>
<div style={{ marginTop: '1rem', borderTop: '1px solid #eee', paddingTop: '1rem' }}>
<p style={{ marginBottom: '1rem', color: '#666' }}>
Update release years for all songs using the iTunes API. This will overwrite existing years.
</p>
<button
onClick={async () => {
if (window.confirm('This will scan all songs and overwrite their release years using data from iTunes. This process may take a while.\n\nContinue?')) {
try {
let offset = 0;
let hasMore = true;
let totalUpdated = 0;
let totalSkipped = 0;
let totalFailed = 0;
let totalProcessed = 0;
let totalSongs = 0;
setMessage('Initializing release year refresh...');
while (hasMore) {
const res = await fetch('/api/admin/refresh-years', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ offset, limit: 10 }) // Process 10 at a time
});
if (!res.ok) {
throw new Error('Batch request failed');
}
const data = await res.json();
totalUpdated += data.updated;
totalSkipped += data.skipped;
totalFailed += data.failed;
totalProcessed += data.processed;
totalSongs = data.total;
hasMore = data.hasMore;
offset = data.nextOffset;
setMessage(`Processing... ${totalProcessed} / ${totalSongs} songs.\nUpdated: ${totalUpdated} | Skipped: ${totalSkipped} | Failed: ${totalFailed}`);
}
const finalMsg = `✅ Completed!\nTotal Processed: ${totalProcessed}\nUpdated: ${totalUpdated}\nSkipped: ${totalSkipped}\nFailed: ${totalFailed}`;
alert(finalMsg);
setMessage(finalMsg);
fetchSongs(); // Refresh the table
} catch (e) {
console.error(e);
alert('Process failed due to network error or timeout.');
setMessage('Refresh failed.');
}
}
}}
style={{
padding: '0.75rem 1.5rem',
background: '#f59e0b',
color: 'white',
border: 'none',
borderRadius: '0.25rem',
cursor: 'pointer',
fontWeight: 'bold'
}}
>
🔄 Refresh Release Years (iTunes)
</button>
</div>
</div>
</div>
);

View File

@@ -1,78 +0,0 @@
import { NextResponse } from 'next/server';
import { PrismaClient } from '@prisma/client';
import { requireAdminAuth } from '@/lib/auth';
import { getReleaseYearFromItunes } from '@/lib/itunes';
const prisma = new PrismaClient();
// Helper to delay execution to avoid rate limits
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
export async function POST(request: Request) {
// Check authentication
const authError = await requireAdminAuth(request as any);
if (authError) return authError;
try {
const { offset = 0, limit = 20 } = await request.json();
// Fetch batch of songs
const songs = await prisma.song.findMany({
select: { id: true, title: true, artist: true },
orderBy: { id: 'asc' },
skip: offset,
take: limit
});
const totalSongs = await prisma.song.count();
console.log(`Processing batch: offset=${offset}, limit=${limit}, found=${songs.length}`);
let updatedCount = 0;
let failedCount = 0;
let skippedCount = 0;
const results = [];
for (const song of songs) {
try {
// Rate limiting: wait 2000ms between requests to be safe (iTunes can be strict)
await sleep(2000);
const year = await getReleaseYearFromItunes(song.artist, song.title);
if (year) {
await prisma.song.update({
where: { id: song.id },
data: { releaseYear: year }
});
updatedCount++;
results.push({ id: song.id, title: song.title, artist: song.artist, year, status: 'updated' });
} else {
skippedCount++;
results.push({ id: song.id, title: song.title, artist: song.artist, status: 'not_found' });
}
} catch (error) {
console.error(`Failed to update year for ${song.title} - ${song.artist}:`, error);
failedCount++;
results.push({ id: song.id, title: song.title, artist: song.artist, status: 'error' });
}
}
return NextResponse.json({
success: true,
processed: songs.length,
total: totalSongs,
hasMore: offset + songs.length < totalSongs,
nextOffset: offset + songs.length,
updated: updatedCount,
failed: failedCount,
skipped: skippedCount,
results
});
} catch (error) {
console.error('Error refreshing release years:', error);
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}

View File

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

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

@@ -0,0 +1,47 @@
import { NextResponse } from 'next/server';
import { execSync } from 'child_process';
export async function GET() {
try {
// First check if version is set via environment variable (Docker build)
if (process.env.APP_VERSION) {
return NextResponse.json({ version: process.env.APP_VERSION });
}
// Try to get the git tag/version
let version = 'dev';
try {
// First try to get the exact tag if we're on a tagged commit
version = execSync('git describe --tags --exact-match 2>/dev/null', {
encoding: 'utf-8',
cwd: process.cwd()
}).trim();
} catch {
try {
// If not on a tag, get the latest tag with commit info
version = execSync('git describe --tags --always 2>/dev/null', {
encoding: 'utf-8',
cwd: process.cwd()
}).trim();
} catch {
// If git is not available or no tags exist, try to get commit hash
try {
const hash = execSync('git rev-parse --short HEAD 2>/dev/null', {
encoding: 'utf-8',
cwd: process.cwd()
}).trim();
version = `dev-${hash}`;
} catch {
// Fallback to just 'dev' if git is not available
version = 'dev';
}
}
}
return NextResponse.json({ version });
} catch (error) {
console.error('Error getting version:', error);
return NextResponse.json({ version: 'unknown' });
}
}

View File

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

View File

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

34
components/AppFooter.tsx Normal file
View File

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

View File

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

View File

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

View File

@@ -30,6 +30,7 @@ model Genre {
id Int @id @default(autoincrement())
name String @unique
subtitle String?
active Boolean @default(true)
songs Song[]
dailyPuzzles DailyPuzzle[]
}

View File

@@ -1,6 +1,12 @@
#!/bin/sh
set -e
# Export version if available
if [ -f /app/version.txt ]; then
export APP_VERSION=$(cat /app/version.txt)
echo "App version: $APP_VERSION"
fi
echo "Starting deployment..."
# Run migrations