diff --git a/.forgejo/workflows/build.yml b/.forgejo/workflows/build.yml deleted file mode 100644 index cead178..0000000 --- a/.forgejo/workflows/build.yml +++ /dev/null @@ -1,57 +0,0 @@ -name: Build Docker Image - -on: - push: - branches: [main, nightly, feature/nightly] - -env: - REGISTRY: forgejo.adriahub.de - IMAGE: root/jukebox-vibe - -jobs: - build: - runs-on: ubuntu-latest - container: - image: docker:latest - volumes: - - /var/run/docker.sock:/var/run/docker.sock - steps: - - name: Checkout - run: | - apk add --no-cache git - git clone --branch "${GITHUB_REF_NAME}" --depth 1 \ - "http://root:${{ secrets.REGISTRY_PASSWORD }}@192.168.1.100:3000/${GITHUB_REPOSITORY}.git" . - - - name: Determine version and tag - id: vars - run: | - BRANCH="${GITHUB_REF_NAME}" - if [ "$BRANCH" = "main" ]; then - TAG="main"; VERSION="2.0.0"; CHANNEL="stable" - elif [ "$BRANCH" = "nightly" ] || [ "$BRANCH" = "feature/nightly" ]; then - TAG="nightly"; VERSION="2.0.0-nightly"; CHANNEL="nightly" - else - TAG=$(echo "$BRANCH" | sed 's/\//-/g'); VERSION="2.0.0-dev"; CHANNEL="dev" - fi - echo "tag=$TAG" >> "$GITHUB_OUTPUT" - echo "version=$VERSION" >> "$GITHUB_OUTPUT" - echo "channel=$CHANNEL" >> "$GITHUB_OUTPUT" - - - name: Build and push - run: | - docker build \ - --build-arg "VITE_BUILD_CHANNEL=${{ steps.vars.outputs.channel }}" \ - --build-arg "VITE_APP_VERSION=${{ steps.vars.outputs.version }}" \ - -t ${{ env.REGISTRY }}/${{ env.IMAGE }}:${{ steps.vars.outputs.tag }} \ - -t ${{ env.REGISTRY }}/${{ env.IMAGE }}:${{ github.sha }} \ - . - if [ "${GITHUB_REF_NAME}" = "main" ]; then - docker tag ${{ env.REGISTRY }}/${{ env.IMAGE }}:${{ steps.vars.outputs.tag }} \ - ${{ env.REGISTRY }}/${{ env.IMAGE }}:latest - fi - echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login ${{ env.REGISTRY }} -u root --password-stdin - docker push ${{ env.REGISTRY }}/${{ env.IMAGE }}:${{ steps.vars.outputs.tag }} - docker push ${{ env.REGISTRY }}/${{ env.IMAGE }}:${{ github.sha }} - if [ "${GITHUB_REF_NAME}" = "main" ]; then - docker push ${{ env.REGISTRY }}/${{ env.IMAGE }}:latest - fi diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index c5f06d3..8da7939 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,63 +1,63 @@ -stages: - - build - -variables: - INTERNAL_REGISTRY: "192.168.1.100:9080" - IMAGE_NAME: "$INTERNAL_REGISTRY/$CI_PROJECT_PATH" - CI_SERVER_URL: "http://192.168.1.100:9080" - GITLAB_FEATURES: "" - -docker-build: - stage: build - image: - name: gcr.io/kaniko-project/executor:v1.23.2-debug - entrypoint: [""] - rules: - - if: $CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH - before_script: - - mkdir -p /kaniko/.docker - - | - cat > /kaniko/.docker/config.json < /kaniko/.docker/config.json < host === h || host.endsWith('.' + h)); - } catch { return false; } -} - -function isDirectMp3Url(url: string): boolean { - try { - return new URL(url).pathname.toLowerCase().endsWith('.mp3'); - } catch { return false; } -} - -function isSupportedUrl(url: string): boolean { - return isYtDlpUrl(url) || isDirectMp3Url(url); -} - -/** Download audio via yt-dlp → MP3 file in SOUNDS_DIR */ -function downloadWithYtDlp(url: string): Promise<{ filename: string; filepath: string }> { - return new Promise((resolve, reject) => { - const outputTemplate = path.join(SOUNDS_DIR, '%(title)s.%(ext)s'); - const args = [ - '-x', // extract audio only - '--audio-format', 'mp3', // convert to MP3 - '--audio-quality', '0', // best quality - '-o', outputTemplate, // output path template - '--no-playlist', // single video only - '--no-overwrites', // don't overwrite existing - '--restrict-filenames', // safe filenames (ASCII, no spaces) - '--max-filesize', '50m', // same limit as file upload - '--socket-timeout', '30', // timeout for slow connections - '--verbose', // verbose output for logging - url, - ]; - - const startTime = Date.now(); - console.log(`[Jukebox] [yt-dlp] ▶ START url=${url}`); - console.log(`[Jukebox] [yt-dlp] args: yt-dlp ${args.join(' ')}`); - - const proc = child_process.spawn('yt-dlp', args, { stdio: ['ignore', 'pipe', 'pipe'] }); - - let stdout = ''; - let stderr = ''; - proc.stdout?.on('data', (d: Buffer) => { - const line = d.toString(); - stdout += line; - for (const l of line.split('\n').filter((s: string) => s.trim())) { - console.log(`[Jukebox] [yt-dlp:out] ${l.trim()}`); - } - }); - proc.stderr?.on('data', (d: Buffer) => { - const line = d.toString(); - stderr += line; - for (const l of line.split('\n').filter((s: string) => s.trim())) { - console.error(`[Jukebox] [yt-dlp:err] ${l.trim()}`); - } - }); - - proc.on('error', (err) => { - const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); - console.error(`[Jukebox] [yt-dlp] ✗ SPAWN ERROR after ${elapsed}s: ${err.message}`); - reject(new Error('yt-dlp nicht verfügbar')); - }); - - proc.on('close', (code) => { - const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); - - if (code !== 0) { - console.error(`[Jukebox] [yt-dlp] ✗ FAILED exit=${code} after ${elapsed}s`); - console.error(`[Jukebox] [yt-dlp] stderr (last 1000 chars): ${stderr.slice(-1000)}`); - console.error(`[Jukebox] [yt-dlp] stdout (last 500 chars): ${stdout.slice(-500)}`); - - if (stderr.includes('Video unavailable') || stderr.includes('is not available')) - reject(new Error('Video nicht verfügbar')); - else if (stderr.includes('Private video')) - reject(new Error('Privates Video')); - else if (stderr.includes('Sign in') || stderr.includes('login')) - reject(new Error('Login erforderlich')); - else if (stderr.includes('exceeds maximum')) - reject(new Error('Datei zu groß (max 50 MB)')); - else if (stderr.includes('Unsupported URL')) - reject(new Error('URL nicht unterstützt')); - else if (stderr.includes('HTTP Error 404')) - reject(new Error('Video nicht gefunden (404)')); - else if (stderr.includes('HTTP Error 403')) - reject(new Error('Zugriff verweigert (403)')); - else - reject(new Error(`yt-dlp Fehler (exit ${code})`)); - return; - } - - console.log(`[Jukebox] [yt-dlp] ✓ DONE exit=0 after ${elapsed}s`); - - const destMatch = stdout.match(/\[ExtractAudio\] Destination: (.+\.mp3)/i) - ?? stdout.match(/\[download\] (.+\.mp3) has already been downloaded/i) - ?? stdout.match(/Destination: (.+\.mp3)/i); - - if (destMatch) { - const filepath = destMatch[1].trim(); - const filename = path.basename(filepath); - console.log(`[Jukebox] [yt-dlp] saved: ${filename} (regex match)`); - resolve({ filename, filepath }); - return; - } - - // Fallback: scan SOUNDS_DIR for newest MP3 (within last 60s) - const now = Date.now(); - const mp3s = fs.readdirSync(SOUNDS_DIR) - .filter(f => f.endsWith('.mp3')) - .map(f => ({ name: f, mtime: fs.statSync(path.join(SOUNDS_DIR, f)).mtimeMs })) - .filter(f => now - f.mtime < 60000) - .sort((a, b) => b.mtime - a.mtime); - - if (mp3s.length > 0) { - const filename = mp3s[0].name; - console.log(`[Jukebox] [yt-dlp] saved: ${filename} (fallback scan, ${mp3s.length} recent files)`); - resolve({ filename, filepath: path.join(SOUNDS_DIR, filename) }); - return; - } - - console.error(`[Jukebox] [yt-dlp] ✗ OUTPUT FILE NOT FOUND`); - console.error(`[Jukebox] [yt-dlp] full stdout:\n${stdout}`); - console.error(`[Jukebox] [yt-dlp] full stderr:\n${stderr}`); - reject(new Error('Download abgeschlossen, aber Datei nicht gefunden')); - }); - }); -} - -/** Shared download logic for play-url and download-url */ -async function handleUrlDownload(url: string, customFilename?: string): Promise<{ savedFile: string; savedPath: string }> { - let savedFile: string; - let savedPath: string; - - if (isYtDlpUrl(url)) { - console.log(`[Jukebox] [url-dl] → yt-dlp...`); - const result = await downloadWithYtDlp(url); - savedFile = result.filename; - savedPath = result.filepath; - } else { - const parsed = new URL(url); - savedFile = path.basename(parsed.pathname); - savedPath = path.join(SOUNDS_DIR, savedFile); - console.log(`[Jukebox] [url-dl] → direct MP3: ${savedFile}`); - const r = await fetch(url); - if (!r.ok) throw new Error(`Download fehlgeschlagen (HTTP ${r.status})`); - const buf = Buffer.from(await r.arrayBuffer()); - fs.writeFileSync(savedPath, buf); - console.log(`[Jukebox] [url-dl] saved ${(buf.length / 1024).toFixed(0)} KB → ${savedFile}`); - } - - // Rename if custom filename provided - if (customFilename) { - const safeName = customFilename.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_').trim(); - if (safeName) { - const ext = path.extname(savedFile).toLowerCase() || '.mp3'; - const newName = safeName.endsWith(ext) ? safeName : safeName + ext; - const newPath = path.join(SOUNDS_DIR, newName); - if (newPath !== savedPath && !fs.existsSync(newPath)) { - fs.renameSync(savedPath, newPath); - console.log(`[Jukebox] [url-dl] renamed: ${savedFile} → ${newName}`); - savedFile = newName; - savedPath = newPath; - } - } - } - - if (NORMALIZE_ENABLE) { - try { await normalizeToCache(savedPath); console.log(`[Jukebox] [url-dl] normalized`); } - catch (e: any) { console.error(`[Jukebox] [url-dl] normalize failed: ${e?.message}`); } - } - - return { savedFile, savedPath }; -} - // Persistenter Zustand: Lautstärke/Plays + Kategorien type Category = { id: string; name: string; color?: string; sort?: number }; type PersistedState = { @@ -1471,17 +1291,14 @@ app.get('/api/channels', (_req: Request, res: Response) => { if (!client.isReady()) return res.status(503).json({ error: 'Bot noch nicht bereit' }); const allowed = new Set(ALLOWED_GUILD_IDS); - const result: Array<{ guildId: string; guildName: string; channelId: string; channelName: string; members: number; selected?: boolean }> = []; + const result: Array<{ guildId: string; guildName: string; channelId: string; channelName: string; selected?: boolean }> = []; for (const [, guild] of client.guilds.cache) { if (allowed.size > 0 && !allowed.has(guild.id)) continue; const channels = guild.channels.cache; for (const [, ch] of channels) { if (ch?.type === ChannelType.GuildVoice || ch?.type === ChannelType.GuildStageVoice) { const sel = getSelectedChannelForGuild(guild.id); - const members = ('members' in ch) - ? (ch as VoiceBasedChannel).members.filter(m => !m.user.bot).size - : 0; - result.push({ guildId: guild.id, guildName: guild.name, channelId: ch.id, channelName: ch.name, members, selected: sel === ch.id }); + result.push({ guildId: guild.id, guildName: guild.name, channelId: ch.id, channelName: ch.name, selected: sel === ch.id }); } } } @@ -1737,57 +1554,45 @@ app.get('/api/events', (req: Request, res: Response) => { }); }); -// --- Medien-URL abspielen (YouTube / Instagram / MP3) --- +// --- Medien-URL abspielen --- +// Unterstützt: direkte MP3-URL (Download und Ablage) app.post('/api/play-url', async (req: Request, res: Response) => { - const startTime = Date.now(); try { - const { url, guildId, channelId, volume, filename } = req.body as { url?: string; guildId?: string; channelId?: string; volume?: number; filename?: string }; - const urlType = isYtDlpUrl(url || '') ? 'yt-dlp' : isDirectMp3Url(url || '') ? 'mp3' : 'unknown'; - console.log(`[Jukebox] [play-url] ▶ type=${urlType} url=${url} filename=${filename || '(auto)'} guild=${guildId}`); - + const { url, guildId, channelId, volume } = req.body as { url?: string; guildId?: string; channelId?: string; volume?: number }; if (!url || !guildId || !channelId) return res.status(400).json({ error: 'url, guildId, channelId erforderlich' }); - try { new URL(url); } catch { return res.status(400).json({ error: 'Ungültige URL' }); } - if (!isSupportedUrl(url)) return res.status(400).json({ error: 'Nur YouTube, Instagram oder direkte MP3-Links' }); - const { savedFile, savedPath } = await handleUrlDownload(url, filename); - - try { await playFilePath(guildId, channelId, savedPath, volume, savedFile); console.log(`[Jukebox] [play-url] playing`); } - catch (e: any) { console.error(`[Jukebox] [play-url] play failed (file saved): ${e?.message}`); } - - const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); - console.log(`[Jukebox] [play-url] ✓ DONE in ${elapsed}s → ${savedFile}`); - return res.json({ ok: true, saved: savedFile }); + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return res.status(400).json({ error: 'Ungültige URL' }); + } + const pathname = parsed.pathname.toLowerCase(); + if (!pathname.endsWith('.mp3')) { + return res.status(400).json({ error: 'Nur direkte MP3-Links werden unterstützt.' }); + } + const fileName = path.basename(parsed.pathname); + const dest = path.join(SOUNDS_DIR, fileName); + const r = await fetch(url); + if (!r.ok) return res.status(400).json({ error: 'Download fehlgeschlagen' }); + const buf = Buffer.from(await r.arrayBuffer()); + fs.writeFileSync(dest, buf); + // Vor dem Abspielen normalisieren → sofort aus Cache + if (NORMALIZE_ENABLE) { + try { await normalizeToCache(dest); } catch (e) { console.warn('Norm after URL import failed:', e); } + } + try { + await playFilePath(guildId, channelId, dest, volume, path.basename(dest)); + } catch { + return res.status(500).json({ error: 'Abspielen fehlgeschlagen' }); + } + return res.json({ ok: true, saved: path.basename(dest) }); } catch (e: any) { - const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); - console.error(`[Jukebox] [play-url] ✗ ERROR after ${elapsed}s: ${e?.message ?? e}`); + console.error('play-url error:', e); return res.status(500).json({ error: e?.message ?? 'Unbekannter Fehler' }); } }); -// --- URL nur herunterladen (ohne Abspielen) --- -app.post('/api/download-url', requireAdmin, async (req: Request, res: Response) => { - const startTime = Date.now(); - try { - const { url, filename } = req.body as { url?: string; filename?: string }; - const urlType = isYtDlpUrl(url || '') ? 'yt-dlp' : isDirectMp3Url(url || '') ? 'mp3' : 'unknown'; - console.log(`[Jukebox] [download-url] ▶ type=${urlType} url=${url} filename=${filename || '(auto)'}`); - - if (!url) return res.status(400).json({ error: 'URL erforderlich' }); - try { new URL(url); } catch { return res.status(400).json({ error: 'Ungültige URL' }); } - if (!isSupportedUrl(url)) return res.status(400).json({ error: 'Nur YouTube, Instagram oder direkte MP3-Links' }); - - const { savedFile } = await handleUrlDownload(url, filename); - - const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); - console.log(`[Jukebox] [download-url] ✓ DONE in ${elapsed}s → ${savedFile}`); - return res.json({ ok: true, saved: savedFile }); - } catch (e: any) { - const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); - console.error(`[Jukebox] [download-url] ✗ ERROR after ${elapsed}s: ${e?.message ?? e}`); - return res.status(500).json({ error: e?.message ?? 'Fehler' }); - } -}); - // Static Frontend ausliefern (Vite build) const webDistPath = path.resolve(__dirname, '../../web/dist'); if (fs.existsSync(webDistPath)) { diff --git a/web/src/App.tsx b/web/src/App.tsx index f67f287..77f8e1d 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useMemo, useRef, useState, useCallback } from 'react'; import { - fetchChannels, fetchSounds, fetchAnalytics, playSound, playUrl, downloadUrl, setVolumeLive, getVolume, + fetchChannels, fetchSounds, fetchAnalytics, playSound, playUrl, setVolumeLive, getVolume, adminStatus, adminLogin, adminLogout, adminDelete, adminRename, fetchCategories, partyStart, partyStop, subscribeEvents, getSelectedChannels, setSelectedChannel, uploadFile, @@ -52,13 +52,6 @@ export default function App() { const [importUrl, setImportUrl] = useState(''); const [importBusy, setImportBusy] = useState(false); - // Download modal state - const [dlModal, setDlModal] = useState<{ - url: string; type: 'youtube' | 'instagram' | 'mp3' | null; - filename: string; phase: 'input' | 'downloading' | 'done' | 'error'; - savedName?: string; error?: string; - } | null>(null); - /* ── Channels ── */ const [channels, setChannels] = useState([]); const [selected, setSelected] = useState(''); @@ -160,35 +153,14 @@ export default function App() { setTimeout(() => setNotification(null), 3000); }, []); const soundKey = useCallback((s: Sound) => s.relativePath ?? s.fileName, []); - const YTDLP_HOSTS = ['youtube.com', 'www.youtube.com', 'm.youtube.com', 'youtu.be', 'music.youtube.com', 'instagram.com', 'www.instagram.com']; - /** Auto-prepend https:// if missing */ - const normalizeUrl = useCallback((value: string): string => { - const v = value.trim(); - if (!v) return v; - if (/^https?:\/\//i.test(v)) return v; - return 'https://' + v; - }, []); - const isSupportedUrl = useCallback((value: string) => { + const isMp3Url = useCallback((value: string) => { try { - const parsed = new URL(normalizeUrl(value)); - const host = parsed.hostname.toLowerCase(); - if (parsed.pathname.toLowerCase().endsWith('.mp3')) return true; - if (YTDLP_HOSTS.some(h => host === h || host.endsWith('.' + h))) return true; - return false; + const parsed = new URL(value.trim()); + return parsed.pathname.toLowerCase().endsWith('.mp3'); } catch { return false; } - }, [normalizeUrl]); - const getUrlType = useCallback((value: string): 'youtube' | 'instagram' | 'mp3' | null => { - try { - const parsed = new URL(normalizeUrl(value)); - const host = parsed.hostname.toLowerCase(); - if (host.includes('youtube') || host === 'youtu.be') return 'youtube'; - if (host.includes('instagram')) return 'instagram'; - if (parsed.pathname.toLowerCase().endsWith('.mp3')) return 'mp3'; - return null; - } catch { return null; } - }, [normalizeUrl]); + }, []); const guildId = selected ? selected.split(':')[0] : ''; const channelId = selected ? selected.split(':')[1] : ''; @@ -374,42 +346,22 @@ export default function App() { } catch (e: any) { notify(e?.message || 'Play fehlgeschlagen', 'error'); } } - // Open download modal instead of downloading directly - function handleUrlImport() { - const trimmed = normalizeUrl(importUrl); - if (!trimmed) return notify('Bitte einen Link eingeben', 'error'); - if (!isSupportedUrl(trimmed)) return notify('Nur YouTube, Instagram oder direkte MP3-Links', 'error'); - const urlType = getUrlType(trimmed); - // Pre-fill filename for MP3 links (basename without .mp3), empty for YT/IG - let defaultName = ''; - if (urlType === 'mp3') { - try { defaultName = new URL(trimmed).pathname.split('/').pop()?.replace(/\.mp3$/i, '') ?? ''; } catch {} - } - setDlModal({ url: trimmed, type: urlType, filename: defaultName, phase: 'input' }); - } - - // Actual download triggered from modal - async function handleModalDownload() { - if (!dlModal) return; - setDlModal(prev => prev ? { ...prev, phase: 'downloading' } : null); + async function handleUrlImport() { + const trimmed = importUrl.trim(); + if (!trimmed) return notify('Bitte einen MP3-Link eingeben', 'error'); + if (!selected) return notify('Bitte einen Voice-Channel auswählen', 'error'); + if (!isMp3Url(trimmed)) return notify('Nur direkte MP3-Links sind erlaubt', 'error'); + setImportBusy(true); try { - let savedName: string | undefined; - const fn = dlModal.filename.trim() || undefined; - if (selected && guildId && channelId) { - const result = await playUrl(dlModal.url, guildId, channelId, volume, fn); - savedName = result.saved; - } else { - const result = await downloadUrl(dlModal.url, fn); - savedName = result.saved; - } - setDlModal(prev => prev ? { ...prev, phase: 'done', savedName } : null); + await playUrl(trimmed, guildId, channelId, volume); setImportUrl(''); + notify('MP3 importiert und abgespielt'); setRefreshKey(k => k + 1); - void loadAnalytics(); - // Auto-close after 2.5s - setTimeout(() => setDlModal(null), 2500); + await loadAnalytics(); } catch (e: any) { - setDlModal(prev => prev ? { ...prev, phase: 'error', error: e?.message || 'Fehler' } : null); + notify(e?.message || 'URL-Import fehlgeschlagen', 'error'); + } finally { + setImportBusy(false); } } @@ -658,7 +610,7 @@ export default function App() { > headset {selected && } - {selectedChannel ? `${selectedChannel.channelName}${selectedChannel.members ? ` (${selectedChannel.members})` : ''}` : 'Channel...'} + {selectedChannel?.channelName || 'Channel...'} expand_more {channelOpen && ( @@ -673,7 +625,7 @@ export default function App() { onClick={() => handleChannelSelect(ch)} > volume_up - {ch.channelName}{ch.members ? ` (${ch.members})` : ''} + {ch.channelName} ))} @@ -763,32 +715,20 @@ export default function App() {
- - {getUrlType(importUrl) === 'youtube' ? 'smart_display' - : getUrlType(importUrl) === 'instagram' ? 'photo_camera' - : 'link'} - + link setImportUrl(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') void handleUrlImport(); }} /> - {importUrl && ( - - {getUrlType(importUrl) === 'youtube' ? 'YT' - : getUrlType(importUrl) === 'instagram' ? 'IG' - : getUrlType(importUrl) === 'mp3' ? 'MP3' - : '?'} - - )} @@ -1312,110 +1252,6 @@ export default function App() {
)} - - {/* ── Download Modal ── */} - {dlModal && ( -
dlModal.phase !== 'downloading' && setDlModal(null)}> -
e.stopPropagation()}> -
- - {dlModal.type === 'youtube' ? 'smart_display' : dlModal.type === 'instagram' ? 'photo_camera' : 'audio_file'} - - - {dlModal.phase === 'input' ? 'Sound herunterladen' - : dlModal.phase === 'downloading' ? 'Wird heruntergeladen...' - : dlModal.phase === 'done' ? 'Fertig!' - : 'Fehler'} - - {dlModal.phase !== 'downloading' && ( - - )} -
- -
- {/* URL badge */} -
- - {dlModal.type === 'youtube' ? 'YouTube' : dlModal.type === 'instagram' ? 'Instagram' : 'MP3'} - - - {dlModal.url.length > 60 ? dlModal.url.slice(0, 57) + '...' : dlModal.url} - -
- - {/* Filename input (input phase only) */} - {dlModal.phase === 'input' && ( -
- -
- setDlModal(prev => prev ? { ...prev, filename: e.target.value } : null)} - onKeyDown={e => { if (e.key === 'Enter') void handleModalDownload(); }} - autoFocus - /> - .mp3 -
- Leer lassen = automatischer Name -
- )} - - {/* Progress (downloading phase) */} - {dlModal.phase === 'downloading' && ( -
-
- - {dlModal.type === 'youtube' || dlModal.type === 'instagram' - ? 'Audio wird extrahiert...' - : 'MP3 wird heruntergeladen...'} - -
- )} - - {/* Success */} - {dlModal.phase === 'done' && ( -
- check_circle - Gespeichert als {dlModal.savedName} -
- )} - - {/* Error */} - {dlModal.phase === 'error' && ( -
- error - {dlModal.error} -
- )} -
- - {/* Actions */} - {dlModal.phase === 'input' && ( -
- - -
- )} - {dlModal.phase === 'error' && ( -
- - -
- )} -
-
- )}
); } diff --git a/web/src/api.ts b/web/src/api.ts index 74f7523..4cf8736 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -195,24 +195,15 @@ export async function adminRename(from: string, to: string): Promise { return data?.to as string; } -export async function playUrl(url: string, guildId: string, channelId: string, volume: number, filename?: string): Promise<{ saved?: string }> { +export async function playUrl(url: string, guildId: string, channelId: string, volume: number): Promise { const res = await fetch(`${API_BASE}/play-url`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ url, guildId, channelId, volume, filename }) + body: JSON.stringify({ url, guildId, channelId, volume }) }); - const data = await res.json().catch(() => ({})); - if (!res.ok) throw new Error(data?.error || 'Play-URL fehlgeschlagen'); - return data; -} - -export async function downloadUrl(url: string, filename?: string): Promise<{ saved?: string }> { - const res = await fetch(`${API_BASE}/download-url`, { - method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ url, filename }) - }); - const data = await res.json().catch(() => ({})); - if (!res.ok) throw new Error(data?.error || 'Download fehlgeschlagen'); - return data; + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data?.error || 'Play-URL fehlgeschlagen'); + } } /** Lädt eine einzelne MP3/WAV-Datei hoch. Gibt den gespeicherten Dateinamen zurück. */ diff --git a/web/src/styles.css b/web/src/styles.css index e0efd56..400fc21 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -641,24 +641,6 @@ input, select { pointer-events: none; } -.url-import-tag { - flex-shrink: 0; - padding: 1px 6px; - border-radius: 8px; - font-size: 10px; - font-weight: 800; - letter-spacing: .5px; - text-transform: uppercase; -} -.url-import-tag.valid { - background: rgba(46, 204, 113, .18); - color: #2ecc71; -} -.url-import-tag.invalid { - background: rgba(231, 76, 60, .18); - color: #e74c3c; -} - /* ── Toolbar Buttons ── */ .tb-btn { display: flex; @@ -2081,141 +2063,6 @@ input, select { margin-top: 2px; } -/* ──────────────────────────────────────────── - Download Modal - ──────────────────────────────────────────── */ -.dl-modal-overlay { - position: fixed; inset: 0; - background: rgba(0, 0, 0, .55); - display: flex; align-items: center; justify-content: center; - z-index: 300; - animation: fade-in 150ms ease; -} -@keyframes fade-in { from { opacity: 0; } to { opacity: 1; } } - -.dl-modal { - width: 420px; max-width: 92vw; - background: var(--bg-secondary); - border: 1px solid rgba(255, 255, 255, .1); - border-radius: 16px; - box-shadow: 0 12px 60px rgba(0, 0, 0, .5); - animation: scale-in 200ms cubic-bezier(.16, 1, .3, 1); -} -@keyframes scale-in { from { opacity: 0; transform: scale(.95); } to { opacity: 1; transform: scale(1); } } - -.dl-modal-header { - display: flex; align-items: center; gap: 8px; - padding: 14px 16px; - border-bottom: 1px solid rgba(255, 255, 255, .06); - font-size: 14px; font-weight: 700; - color: var(--text-normal); -} -.dl-modal-header .material-icons { color: var(--accent); } - -.dl-modal-close { - margin-left: auto; - display: flex; align-items: center; justify-content: center; - width: 26px; height: 26px; border-radius: 50%; - border: none; background: rgba(255,255,255,.06); - color: var(--text-muted); cursor: pointer; - transition: background var(--transition); -} -.dl-modal-close:hover { background: rgba(255,255,255,.14); color: var(--text-normal); } - -.dl-modal-body { padding: 16px; display: flex; flex-direction: column; gap: 14px; } - -/* URL display */ -.dl-modal-url { - display: flex; align-items: center; gap: 8px; - padding: 8px 10px; border-radius: 8px; - background: rgba(0, 0, 0, .2); - overflow: hidden; -} -.dl-modal-tag { - flex-shrink: 0; padding: 2px 8px; border-radius: 6px; - font-size: 10px; font-weight: 800; letter-spacing: .5px; text-transform: uppercase; -} -.dl-modal-tag.youtube { background: rgba(255, 0, 0, .18); color: #ff4444; } -.dl-modal-tag.instagram { background: rgba(225, 48, 108, .18); color: #e1306c; } -.dl-modal-tag.mp3 { background: rgba(46, 204, 113, .18); color: #2ecc71; } -.dl-modal-url-text { - font-size: 11px; color: var(--text-faint); - white-space: nowrap; overflow: hidden; text-overflow: ellipsis; -} - -/* Filename field */ -.dl-modal-field { display: flex; flex-direction: column; gap: 5px; } -.dl-modal-label { font-size: 11px; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: .5px; } -.dl-modal-input-wrap { - display: flex; align-items: center; - border: 1px solid rgba(255, 255, 255, .1); border-radius: 8px; - background: rgba(0, 0, 0, .15); - overflow: hidden; - transition: border-color var(--transition); -} -.dl-modal-input-wrap:focus-within { border-color: var(--accent); } -.dl-modal-input { - flex: 1; border: none; background: transparent; - padding: 8px 10px; color: var(--text-normal); - font-size: 13px; font-family: var(--font); outline: none; -} -.dl-modal-input::placeholder { color: var(--text-faint); } -.dl-modal-ext { - padding: 0 10px; font-size: 12px; font-weight: 600; - color: var(--text-faint); background: rgba(255, 255, 255, .04); - align-self: stretch; display: flex; align-items: center; -} -.dl-modal-hint { font-size: 10px; color: var(--text-faint); } - -/* Progress spinner */ -.dl-modal-progress { - display: flex; align-items: center; gap: 12px; - padding: 20px 0; justify-content: center; - font-size: 13px; color: var(--text-muted); -} -.dl-modal-spinner { - width: 24px; height: 24px; border-radius: 50%; - border: 3px solid rgba(var(--accent-rgb), .2); - border-top-color: var(--accent); - animation: spin 800ms linear infinite; -} - -/* Success */ -.dl-modal-success { - display: flex; align-items: center; gap: 10px; - padding: 16px 0; justify-content: center; - font-size: 13px; color: var(--text-normal); -} -.dl-modal-check { color: #2ecc71; font-size: 28px; } - -/* Error */ -.dl-modal-error { - display: flex; align-items: center; gap: 10px; - padding: 12px 0; justify-content: center; - font-size: 13px; color: #e74c3c; -} - -/* Actions */ -.dl-modal-actions { - display: flex; justify-content: flex-end; gap: 8px; - padding: 0 16px 14px; -} -.dl-modal-cancel { - padding: 7px 14px; border-radius: 8px; - border: 1px solid rgba(255, 255, 255, .1); background: transparent; - color: var(--text-muted); font-size: 12px; font-weight: 600; - cursor: pointer; transition: all var(--transition); -} -.dl-modal-cancel:hover { background: rgba(255,255,255,.06); color: var(--text-normal); } -.dl-modal-submit { - display: flex; align-items: center; gap: 5px; - padding: 7px 16px; border-radius: 8px; - border: none; background: var(--accent); - color: #fff; font-size: 12px; font-weight: 700; - cursor: pointer; transition: filter var(--transition); -} -.dl-modal-submit:hover { filter: brightness(1.15); } - /* ──────────────────────────────────────────── Utility ──────────────────────────────────────────── */ diff --git a/web/src/types.ts b/web/src/types.ts index 6222add..919e8fe 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -20,7 +20,6 @@ export type VoiceChannelInfo = { guildName: string; channelId: string; channelName: string; - members?: number; selected?: boolean; };