diff --git a/.forgejo/workflows/build.yml b/.forgejo/workflows/build.yml new file mode 100644 index 0000000..cead178 --- /dev/null +++ b/.forgejo/workflows/build.yml @@ -0,0 +1,57 @@ +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 8da7939..c5f06d3 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,63 +1,63 @@ -stages: - - build - -variables: - INTERNAL_REGISTRY: "10.10.10.10:9080" - IMAGE_NAME: "$INTERNAL_REGISTRY/$CI_PROJECT_PATH" - CI_SERVER_URL: "http://10.10.10.10: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 = { @@ -1291,14 +1471,17 @@ 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; selected?: boolean }> = []; + const result: Array<{ guildId: string; guildName: string; channelId: string; channelName: string; members: number; 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); - result.push({ guildId: guild.id, guildName: guild.name, channelId: ch.id, channelName: ch.name, selected: sel === ch.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 }); } } } @@ -1554,45 +1737,57 @@ app.get('/api/events', (req: Request, res: Response) => { }); }); -// --- Medien-URL abspielen --- -// Unterstützt: direkte MP3-URL (Download und Ablage) +// --- Medien-URL abspielen (YouTube / Instagram / MP3) --- app.post('/api/play-url', async (req: Request, res: Response) => { + const startTime = Date.now(); try { - 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' }); + 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}`); - 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) }); + 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 }); } catch (e: any) { - console.error('play-url error:', e); + const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); + console.error(`[Jukebox] [play-url] ✗ ERROR after ${elapsed}s: ${e?.message ?? 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 77f8e1d..f67f287 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, setVolumeLive, getVolume, + fetchChannels, fetchSounds, fetchAnalytics, playSound, playUrl, downloadUrl, setVolumeLive, getVolume, adminStatus, adminLogin, adminLogout, adminDelete, adminRename, fetchCategories, partyStart, partyStop, subscribeEvents, getSelectedChannels, setSelectedChannel, uploadFile, @@ -52,6 +52,13 @@ 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(''); @@ -153,14 +160,35 @@ export default function App() { setTimeout(() => setNotification(null), 3000); }, []); const soundKey = useCallback((s: Sound) => s.relativePath ?? s.fileName, []); - const isMp3Url = useCallback((value: string) => { + 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) => { try { - const parsed = new URL(value.trim()); - return parsed.pathname.toLowerCase().endsWith('.mp3'); + 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; } 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] : ''; @@ -346,22 +374,42 @@ export default function App() { } catch (e: any) { notify(e?.message || 'Play fehlgeschlagen', 'error'); } } - 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); + // 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); try { - await playUrl(trimmed, guildId, channelId, volume); + 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); setImportUrl(''); - notify('MP3 importiert und abgespielt'); setRefreshKey(k => k + 1); - await loadAnalytics(); + void loadAnalytics(); + // Auto-close after 2.5s + setTimeout(() => setDlModal(null), 2500); } catch (e: any) { - notify(e?.message || 'URL-Import fehlgeschlagen', 'error'); - } finally { - setImportBusy(false); + setDlModal(prev => prev ? { ...prev, phase: 'error', error: e?.message || 'Fehler' } : null); } } @@ -610,7 +658,7 @@ export default function App() { > headset {selected && } - {selectedChannel?.channelName || 'Channel...'} + {selectedChannel ? `${selectedChannel.channelName}${selectedChannel.members ? ` (${selectedChannel.members})` : ''}` : 'Channel...'} expand_more {channelOpen && ( @@ -625,7 +673,7 @@ export default function App() { onClick={() => handleChannelSelect(ch)} > volume_up - {ch.channelName} + {ch.channelName}{ch.members ? ` (${ch.members})` : ''} ))} @@ -715,20 +763,32 @@ export default function App() {
- link + + {getUrlType(importUrl) === 'youtube' ? 'smart_display' + : getUrlType(importUrl) === 'instagram' ? 'photo_camera' + : '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' + : '?'} + + )} @@ -1252,6 +1312,110 @@ 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 4cf8736..74f7523 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -195,15 +195,24 @@ 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): Promise { +export async function playUrl(url: string, guildId: string, channelId: string, volume: number, filename?: string): Promise<{ saved?: string }> { const res = await fetch(`${API_BASE}/play-url`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ url, guildId, channelId, volume }) + body: JSON.stringify({ url, guildId, channelId, volume, filename }) }); - if (!res.ok) { - const data = await res.json().catch(() => ({})); - throw new Error(data?.error || 'Play-URL fehlgeschlagen'); - } + 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; } /** 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 400fc21..e0efd56 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -641,6 +641,24 @@ 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; @@ -2063,6 +2081,141 @@ 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 919e8fe..6222add 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -20,6 +20,7 @@ export type VoiceChannelInfo = { guildName: string; channelId: string; channelName: string; + members?: number; selected?: boolean; };