Auto-Commit: 2026-10-03 16:52:16

This commit is contained in:
Jannik committed 2026-10-03 16:52:17 +02:00
1 parent 6dd4a45e1c
commit f0a1a40d49
7 files changed
+496

No files matched your search

+20
View File
@@ -0,0 +1,20 @@
const fs = require('fs');
// Fix route.ts
let route = fs.readFileSync('src/app/api/media/[...path]/route.ts', 'utf8');
route = route.replace(
"fileStream.on('data', (chunk) => controller.enqueue(new Uint8Array(chunk)));",
"fileStream.on('data', (chunk) => controller.enqueue(new Uint8Array(chunk as Buffer)));"
);
fs.writeFileSync('src/app/api/media/[...path]/route.ts', route, 'utf8');
// Fix MediaTile.tsx
let tile = fs.readFileSync('src/components/MediaTile.tsx', 'utf8');
tile = tile.replace(
"const target = e.target;",
"const target = e.target as HTMLVideoElement;"
).replace(
"const target = e.target;",
"const target = e.target as HTMLImageElement;"
);
fs.writeFileSync('src/components/MediaTile.tsx', tile, 'utf8');
+73
View File
@@ -0,0 +1,73 @@
const fs = require('fs');
let route = fs.readFileSync('src/app/api/media/[...path]/route.ts', 'utf8');
const rangeLogic = `
// Get file stats for Content-Length
const stat = await fs.promises.stat(filePath);
const fileSize = stat.size;
// Handle Range Requests (CRITICAL for video streaming in Safari/Chrome)
const rangeHeader = request.headers.get('range');
let start = 0;
let end = fileSize - 1;
let isPartial = false;
if (rangeHeader) {
const parts = rangeHeader.replace(/bytes=/, '').split('-');
const partialStart = parts[0];
const partialEnd = parts[1];
start = parseInt(partialStart, 10);
end = partialEnd ? parseInt(partialEnd, 10) : fileSize - 1;
if (start >= fileSize) {
return new NextResponse('Requested range not satisfiable', { status: 416 });
}
isPartial = true;
}
const contentLength = (end - start) + 1;
// Build response headers
const contentType = getSafeContentType(contentTypeExt);
const headers = new Headers();
headers.set('Content-Type', contentType);
headers.set('Content-Length', contentLength.toString());
headers.set('Cache-Control', 'public, max-age=3600, stale-while-revalidate=86400');
headers.set('Accept-Ranges', 'bytes');
if (isPartial) {
headers.set('Content-Range', \`bytes \${start}-\${end}/\${fileSize}\`);
}
// SVG: force download to prevent XSS
if (contentType === 'image/svg+xml') {
headers.set('Content-Disposition', \`attachment; filename="\${path.basename(relativePath)}"\`);
}
// Stream the file chunk
const fileStream = fs.createReadStream(filePath, { start, end });
const webStream = new ReadableStream({
start(controller) {
fileStream.on('data', (chunk) => controller.enqueue(new Uint8Array(chunk)));
fileStream.on('end', () => controller.close());
fileStream.on('error', (err) => controller.error(err));
},
cancel() {
fileStream.destroy();
},
});
return new NextResponse(webStream, {
status: isPartial ? 206 : 200,
headers
});
`;
route = route.replace(
/\/\/ Get file stats for Content-Length[\s\S]+return new NextResponse\(webStream, \{ headers \}\);/,
rangeLogic.trim()
);
fs.writeFileSync('src/app/api/media/[...path]/route.ts', route, 'utf8');
+50
View File
@@ -0,0 +1,50 @@
const fs = require('fs');
let tile = fs.readFileSync('src/components/MediaTile.tsx', 'utf8');
const oldRender = `{isVisible && (
<>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={isVideo ? \`/api/media/\${item.relativePath}?type=poster\` : \`/api/media/\${item.relativePath}?type=thumb\`}
alt={item.filename}
loading="lazy"
onLoad={(e) => {
const target = e.target as HTMLImageElement;
target.parentElement?.classList.remove('loading-skeleton');
}}
/>
{isVideo && (
<div className="play-icon">`;
const newRender = `{isVisible && (
<>
{isVideo ? (
<video
src={\`/api/media/\${item.relativePath}?type=preview\`}
poster={\`/api/media/\${item.relativePath}?type=poster\`}
autoPlay
muted
loop
playsInline
onLoadedData={(e) => {
const target = e.target as HTMLVideoElement;
target.parentElement?.classList.remove('loading-skeleton');
}}
/>
) : (
/* eslint-disable-next-line @next/next/no-img-element */
<img
src={\`/api/media/\${item.relativePath}?type=thumb\`}
alt={item.filename}
loading="lazy"
onLoad={(e) => {
const target = e.target as HTMLImageElement;
target.parentElement?.classList.remove('loading-skeleton');
}}
/>
)}
{isVideo && (
<div className="play-icon">`;
tile = tile.replace(oldRender, newRender);
fs.writeFileSync('src/components/MediaTile.tsx', tile, 'utf8');
+76
View File
@@ -0,0 +1,76 @@
const fs = require('fs');
let tile = fs.readFileSync('src/components/MediaTile.tsx', 'utf8');
const regex = /return\s*\(\s*<div[\s\S]*?<\/div>\s*\);/;
const newReturn = `return (
<div
className="media-tile loading-skeleton"
ref={tileRef}
style={style}
onClick={onClick}
data-index={index}
title={item.filename}
>
{isVisible && (
<>
{isAudio ? (
<div className="audio-tile-placeholder" onLoad={() => {
if (tileRef.current) tileRef.current.classList.remove('loading-skeleton');
}}>
<span className="audio-icon">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 18V5l12-2v13"></path>
<circle cx="6" cy="18" r="3"></circle>
<circle cx="18" cy="16" r="3"></circle>
</svg>
</span>
</div>
) : isVideo ? (
<video
src={\`/api/media/\${item.relativePath}?type=preview\`}
poster={\`/api/media/\${item.relativePath}?type=poster\`}
autoPlay
muted
loop
playsInline
onLoadedData={(e) => {
const target = e.target;
target.parentElement?.classList.remove('loading-skeleton');
}}
/>
) : (
/* eslint-disable-next-line @next/next/no-img-element */
<img
src={\`/api/media/\${item.relativePath}?type=thumb\`}
alt={item.filename}
loading="lazy"
onLoad={(e) => {
const target = e.target;
target.parentElement?.classList.remove('loading-skeleton');
}}
/>
)}
{(isVideo || isAudio) && (
<div className="play-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" stroke="none">
<polygon points="5 3 19 12 5 21 5 3"></polygon>
</svg>
</div>
)}
{votingOpen && likesCount > 0 && (
<div className="badge">
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" stroke="none">
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"></path>
</svg>
<span>{likesCount}</span>
</div>
)}
</>
)}
</div>
);`;
tile = tile.replace(regex, newReturn);
fs.writeFileSync('src/components/MediaTile.tsx', tile, 'utf8');
+42
View File
@@ -0,0 +1,42 @@
import { NextRequest, NextResponse } from 'next/server';
import fs from 'fs';
import path from 'path';
export const dynamic = 'force-dynamic';
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
const mediaPath = searchParams.get('path');
if (!mediaPath) {
return NextResponse.json({ error: 'Missing path parameter' }, { status: 400 });
}
// Protect against directory traversal
const normalizedPath = path.normalize(mediaPath).replace(/^(\.\.[\/\\])+/, '');
if (normalizedPath.includes('..')) {
return NextResponse.json({ error: 'Invalid path parameter' }, { status: 400 });
}
const title = path.basename(normalizedPath).replace(/\.[^/.]+$/, "");
const dir = path.dirname(normalizedPath);
const mediaRoot = process.env.MEDIA_ROOT || path.join(process.cwd(), 'media');
const txtPath = path.join(mediaRoot, dir, title + '.txt');
if (!fs.existsSync(txtPath)) {
return NextResponse.json({ error: 'Lyrics not found' }, { status: 404 });
}
const lyricsContent = await fs.promises.readFile(txtPath, 'utf-8');
return NextResponse.json({
title,
lyrics: lyricsContent,
});
} catch (error) {
console.error('Error fetching lyrics:', error);
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}
+42
View File
@@ -0,0 +1,42 @@
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/db';
import fs from 'fs';
import path from 'path';
export const dynamic = 'force-dynamic';
export async function GET() {
try {
const songs = await prisma.mediaItem.findMany({
where: {
mediaType: 'AUDIO',
published: true,
},
orderBy: {
filename: 'asc', // Or sortOrder
},
});
const mediaRoot = process.env.MEDIA_ROOT || path.join(process.cwd(), 'media');
const result = songs.map((song) => {
const title = song.filename.replace(/\.[^/.]+$/, "");
const txtPath = path.join(mediaRoot, song.directory, title + '.txt');
const hasLyrics = fs.existsSync(txtPath);
return {
id: song.id,
title,
relativePath: song.relativePath,
duration: song.duration,
hasLyrics
};
});
return NextResponse.json(result);
} catch (error) {
console.error('Error fetching music:', error);
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}
+193
View File
@@ -0,0 +1,193 @@
"use client";
import { useState, useEffect, useRef } from 'react';
type Song = {
id: number;
title: string;
relativePath: string;
duration: number | null;
hasLyrics: boolean;
};
export default function MusicPlayer() {
const [songs, setSongs] = useState<Song[]>([]);
const [currentIndex, setCurrentIndex] = useState(0);
const [isPlaying, setIsPlaying] = useState(false);
const [isMinimized, setIsMinimized] = useState(true);
const [showLyrics, setShowLyrics] = useState(false);
const [lyrics, setLyrics] = useState<string | null>(null);
const [isLoadingLyrics, setIsLoadingLyrics] = useState(false);
const audioRef = useRef<HTMLAudioElement | null>(null);
useEffect(() => {
fetch('/api/music')
.then(res => res.json())
.then(data => {
if (Array.isArray(data) && data.length > 0) {
setSongs(data);
}
})
.catch(err => console.error("Error fetching music:", err));
}, []);
useEffect(() => {
const handlePauseEvent = () => {
if (audioRef.current) {
audioRef.current.pause();
setIsPlaying(false);
}
};
window.addEventListener('pause-background-music', handlePauseEvent);
return () => window.removeEventListener('pause-background-music', handlePauseEvent);
}, []);
useEffect(() => {
const audio = audioRef.current;
if (audio) {
if (isPlaying) {
audio.play().catch(e => console.error("Playback prevented", e));
} else {
audio.pause();
}
}
}, [isPlaying, currentIndex]);
const loadLyrics = async (song: Song) => {
setIsLoadingLyrics(true);
try {
const res = await fetch(`/api/music/lyrics?path=${encodeURIComponent(song.relativePath)}`);
if (res.ok) {
const data = await res.json();
setLyrics(data.lyrics);
} else {
setLyrics(null);
}
} catch (e) {
setLyrics(null);
}
setIsLoadingLyrics(false);
};
useEffect(() => {
const currentSong = songs[currentIndex];
if (currentSong && currentSong.hasLyrics && showLyrics) {
loadLyrics(currentSong);
} else {
setLyrics(null);
}
}, [currentIndex, showLyrics, songs]);
const nextSong = () => {
setCurrentIndex((prev) => (prev + 1) % songs.length);
};
const prevSong = () => {
setCurrentIndex((prev) => (prev - 1 + songs.length) % songs.length);
};
if (songs.length === 0) return null;
const currentSong = songs[currentIndex];
return (
<div className="music-player-container">
<audio
ref={audioRef}
src={`/media/${currentSong.relativePath}`}
onEnded={nextSong}
/>
{isMinimized ? (
<button
className="music-player-fab"
onClick={() => setIsMinimized(false)}
title="Hochzeitsmusik"
>
{/* Note Icon */}
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 18V5l12-2v13"></path>
<circle cx="6" cy="18" r="3"></circle>
<circle cx="18" cy="16" r="3"></circle>
</svg>
</button>
) : (
<div className="music-player-card">
<div className="music-player-header">
<div className="music-player-info">
<span className="music-player-title">{currentSong.title}</span>
</div>
<button className="icon-btn" onClick={() => setIsMinimized(true)}>
{/* Close / Minimize Icon */}
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<div className="music-player-controls">
<button className="icon-btn" onClick={prevSong}>
{/* Prev Icon */}
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polygon points="19 20 9 12 19 4 19 20"></polygon>
<line x1="5" y1="19" x2="5" y2="5"></line>
</svg>
</button>
<button className="icon-btn play-btn" onClick={() => setIsPlaying(!isPlaying)}>
{isPlaying ? (
/* Pause Icon */
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="6" y="4" width="4" height="16"></rect>
<rect x="14" y="4" width="4" height="16"></rect>
</svg>
) : (
/* Play Icon */
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polygon points="5 3 19 12 5 21 5 3"></polygon>
</svg>
)}
</button>
<button className="icon-btn" onClick={nextSong}>
{/* Next Icon */}
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polygon points="5 4 15 12 5 20 5 4"></polygon>
<line x1="19" y1="5" x2="19" y2="19"></line>
</svg>
</button>
{currentSong.hasLyrics && (
<button
className={`icon-btn lyrics-btn ${showLyrics ? 'active' : ''}`}
onClick={() => setShowLyrics(!showLyrics)}
title="Songtext anzeigen"
>
{/* Lyrics / Text Icon */}
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
<line x1="16" y1="13" x2="8" y2="13"></line>
<line x1="16" y1="17" x2="8" y2="17"></line>
<polyline points="10 9 9 9 8 9"></polyline>
</svg>
</button>
)}
</div>
{showLyrics && (
<div className="music-player-lyrics">
{isLoadingLyrics ? (
<div className="lyrics-loading">Lade Text...</div>
) : lyrics ? (
<pre>{lyrics}</pre>
) : (
<div className="lyrics-error">Kein Text verfügbar</div>
)}
</div>
)}
</div>
)}
</div>
);
}