From b6edfcf14b63c8d1fb386e75fcc799bc1394f25b Mon Sep 17 00:00:00 2001 From: Honey Date: Sat, 25 Apr 2026 16:44:38 +0200 Subject: [PATCH] Add NAVIDROME_URL autofill, remove Navidrome scan trigger - Frontend reads window.NS_SERVER_URL from config.js (injected at container start via docker-entrypoint.sh) and pre-fills the setup screen - Removed triggerNaviScan from frontend and backend (Navidrome auto-scans on file change) - Removed NAVIDROME_USER/PASS from backend and compose Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 8 +- Dockerfile | 3 + app.js | 11 +- backend/novasonic-server.js | 385 ++++++++++++++++++++++++++++++++++++ docker-compose.yml | 5 +- docker-entrypoint.sh | 6 + index.html | 3 +- nginx.conf | 5 + sw.js | 2 +- 9 files changed, 414 insertions(+), 14 deletions(-) create mode 100644 backend/novasonic-server.js create mode 100644 docker-entrypoint.sh diff --git a/.env.example b/.env.example index 82d674a..28cd8d9 100644 --- a/.env.example +++ b/.env.example @@ -1,11 +1,11 @@ # Copy to .env and fill in your values +# Pre-fills the server URL on the setup screen +NAVIDROME_URL=https://music.yourdomain.com + +# Path to your Navidrome music library on the host MUSIC_PATH=/path/to/your/navidrome/music AUDIO_FMT=mp3 # mp3 | flac | opus | best AUDIO_QUALITY=0 # 0 = best quality MAX_PARALLEL=2 - -NAVIDROME_URL=http://navidrome:4533 -NAVIDROME_USER=admin -NAVIDROME_PASS=yourpassword diff --git a/Dockerfile b/Dockerfile index e058366..d99ef9d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,7 @@ FROM nginx:alpine COPY index.html styles.css app.js sw.js /usr/share/nginx/html/ COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY docker-entrypoint.sh /docker-entrypoint.sh +RUN chmod +x /docker-entrypoint.sh EXPOSE 80 +ENTRYPOINT ["/docker-entrypoint.sh"] diff --git a/app.js b/app.js index 21638ea..5bab516 100644 --- a/app.js +++ b/app.js @@ -110,6 +110,11 @@ function disconnect(){ window.addEventListener('load',()=>{ const saved=localStorage.getItem('ns_cfg'); if(saved){cfg=JSON.parse(saved);startApp()} + // Autofill server URL from Docker env (set via config.js) + if(window.NS_SERVER_URL){ + const el=document.getElementById('srv-url'); + if(el&&!el.value)el.value=window.NS_SERVER_URL; + } document.getElementById('srv-pass').addEventListener('keydown',e=>{if(e.key==='Enter')doConnect()}); audio.addEventListener('timeupdate',updateProgress); audio.addEventListener('ended',onEnded); @@ -916,13 +921,9 @@ async function pollJob(jobId){ const d=await res.json(); updateJob(jobId,d.status,d.message,d.progress); if(d.status!=='done'&&d.status!=='error')setTimeout(()=>pollJob(jobId),1500); - else if(d.status==='done'){toast(`✅ ${jobs[jobId]?.title||'Track'} gespeichert!`);triggerNaviScan()} + else if(d.status==='done'){toast(`✅ ${jobs[jobId]?.title||'Track'} gespeichert!`)} }catch{updateJob(jobId,'error','Status-Abfrage fehlgeschlagen')} } -async function triggerNaviScan(){ - try{await api('startScan')}catch{} -} - // ─── LOCAL DOWNLOAD (from Navidrome) ─── function dlSongLocal(s){ const a=document.createElement('a');a.href=downloadUrl(s.id); diff --git a/backend/novasonic-server.js b/backend/novasonic-server.js new file mode 100644 index 0000000..26bac90 --- /dev/null +++ b/backend/novasonic-server.js @@ -0,0 +1,385 @@ +/** + * NovaSonic Backend Server + * Downloads music via yt-dlp and saves directly into your Navidrome music library + * + * Requirements: + * - Node.js 18+ + * - yt-dlp installed (https://github.com/yt-dlp/yt-dlp) + * - ffmpeg installed + * + * Install & run: + * npm install express cors + * node novasonic-server.js + * + * Or with Docker - see docker-compose.yml + */ + +const express = require('express'); +const cors = require('cors'); +const { spawn, execSync } = require('child_process'); +const path = require('path'); +const fs = require('fs'); +const https = require('https'); + +// ─── CONFIG ─────────────────────────────────────────────────────────────────── +const CONFIG = { + port: process.env.PORT || 3700, + musicPath: process.env.MUSIC_PATH || '/music', // Navidrome music library folder + ytdlpBin: process.env.YTDLP_BIN || 'yt-dlp', // Path to yt-dlp binary + audioFmt: process.env.AUDIO_FMT || 'mp3', // mp3 | flac | opus | best + audioQuality: process.env.AUDIO_QUALITY || '0', // 0 = best + maxParallel: parseInt(process.env.MAX_PARALLEL || '2'), +}; + +// ─── INIT ───────────────────────────────────────────────────────────────────── +const app = express(); +app.use(cors()); +app.use(express.json()); + +// In-memory job store +const jobs = new Map(); + +// Check yt-dlp on startup +let ytdlpVersion = 'unknown'; +try { + ytdlpVersion = execSync(`${CONFIG.ytdlpBin} --version`, { timeout: 5000 }).toString().trim(); + console.log(`✅ yt-dlp ${ytdlpVersion} found`); +} catch { + console.warn('⚠️ yt-dlp not found! Install from https://github.com/yt-dlp/yt-dlp'); +} + +// Ensure music dir exists +if (!fs.existsSync(CONFIG.musicPath)) { + console.warn(`⚠️ Music path "${CONFIG.musicPath}" does not exist — creating it`); + fs.mkdirSync(CONFIG.musicPath, { recursive: true }); +} + +// ─── HELPERS ───────────────────────────────────────────────────────────────── +function sanitize(s) { + return (s || 'Unknown').replace(/[\\/:*?"<>|]/g, '_').trim().slice(0, 100); +} + +function updateJob(id, update) { + const job = jobs.get(id); + if (job) jobs.set(id, { ...job, ...update, updatedAt: Date.now() }); +} + +function log(jobId, msg) { + const j = jobs.get(jobId); + if (j) jobs.set(jobId, { ...j, log: [...(j.log || []), msg], message: msg }); + console.log(`[${jobId}] ${msg}`); +} + +// Download a single track via yt-dlp, then stamp metadata via a dedicated ffmpeg pass. +// We do NOT use --postprocessor-args for metadata because yt-dlp splits that string on +// spaces internally, so any metadata value with a space (e.g. album name) gets corrupted. +// Instead: yt-dlp downloads, then we call ffmpeg ourselves with properly separated args. +async function downloadTrack({ jobId, artist, title, album, year, trackNum, artworkUrl, outputDir }) { + const safeArtist = sanitize(artist); + const safeAlbum = sanitize(album || 'Singles'); + const dir = path.join(outputDir || CONFIG.musicPath, safeArtist, safeAlbum); + fs.mkdirSync(dir, { recursive: true }); + + const safeTitle = sanitize(title); + const outputTemplate = path.join(dir, `${safeTitle}.%(ext)s`); + const query = `${artist} - ${title} official audio`; + log(jobId, `\uD83D\uDD0D Suche: "${query}"`); + + // Step 1: yt-dlp – download + extract audio + embed YT thumbnail + await new Promise((resolve, reject) => { + const args = [ + `ytsearch1:${query}`, + '--extract-audio', + '--audio-format', CONFIG.audioFmt, + '--audio-quality', CONFIG.audioQuality, + '-o', outputTemplate, + '--embed-thumbnail', + '--convert-thumbnails', 'jpg', + '--no-playlist', + '--playlist-items', '1', + '--no-warnings', + '--newline', + ]; + const proc = spawn(CONFIG.ytdlpBin, args, { env: { ...process.env, PYTHONUNBUFFERED: '1' } }); + let stderr = '', lastPct = ''; + proc.stdout.on('data', d => { + d.toString().split('\n').filter(l => l.trim()).forEach(line => { + if (line.includes('%') && line === lastPct) return; + if (line.includes('%')) lastPct = line; + log(jobId, line.trim()); + }); + }); + proc.stderr.on('data', d => { + stderr += d.toString(); + d.toString().split('\n') + .filter(l => l.trim() && !l.startsWith('[debug]')) + .forEach(l => log(jobId, l.trim())); + }); + proc.on('error', reject); + proc.on('close', code => + code === 0 ? resolve() : reject(new Error(`yt-dlp code ${code}: ${stderr.slice(-300)}`)) + ); + }); + + // Step 2: find the audio file yt-dlp just wrote (newest audio file in dir) + const audioExts = ['mp3', 'flac', 'opus', 'm4a', 'ogg', 'wav']; + const audioFile = fs.readdirSync(dir) + .filter(f => audioExts.some(e => f.endsWith(`.${e}`))) + .map(f => path.join(dir, f)) + .sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs)[0]; + + if (!audioFile) { + log(jobId, '\u26A0\uFE0F Keine Audio-Datei gefunden – \xFCberspringe Metadaten'); + if (artworkUrl) downloadCoverArt(dir, artworkUrl); + return { success: true, dir }; + } + + log(jobId, `\uD83C\uDFF7 Schreibe Metadaten \u2192 ${path.basename(audioFile)}`); + + // Step 3: ffmpeg – rewrite ID3 tags. Each -metadata is a separate arg so spaces are safe. + const fileExt = path.extname(audioFile); + const tmpFile = audioFile.slice(0, -fileExt.length) + '.metatmp' + fileExt; + await new Promise(resolve => { + const ffArgs = [ + '-i', audioFile, + '-c', 'copy', + '-map_metadata', '0', + '-id3v2_version', '3', + '-metadata', `title=${title}`, + '-metadata', `artist=${artist}`, + '-metadata', `album_artist=${artist}`, + ]; + if (album) ffArgs.push('-metadata', `album=${album}`); + if (year) ffArgs.push('-metadata', `date=${String(year)}`); + if (trackNum) ffArgs.push('-metadata', `track=${String(trackNum)}`); + ffArgs.push(tmpFile, '-y'); + + const ff = spawn('ffmpeg', ffArgs); + let ffErr = ''; + ff.stderr.on('data', d => ffErr += d.toString()); + ff.on('error', () => resolve()); // ffmpeg not installed? skip gracefully + ff.on('close', code => { + if (code === 0 && fs.existsSync(tmpFile)) { + fs.renameSync(tmpFile, audioFile); + log(jobId, '\u2705 Metadaten geschrieben (title, artist, album, year, track)'); + } else { + try { fs.unlinkSync(tmpFile); } catch {} + log(jobId, `\u26A0\uFE0F ffmpeg Metadaten-Schritt: code ${code}`); + if (ffErr) log(jobId, ffErr.slice(-200)); + } + resolve(); + }); + }); + + // Step 4: save cover.jpg for Navidrome folder-art + if (artworkUrl) downloadCoverArt(dir, artworkUrl); + return { success: true, dir }; +} + +// Fallback: embed thumbnail via separate ffmpeg call (for formats where yt-dlp fails) +async function embedThumbnailFallback(dir, safeTitle, artworkUrl, jobId) { + if (!artworkUrl) return; + const coverPath = path.join(dir, 'cover.jpg'); + if (!fs.existsSync(coverPath)) return; + const ext = CONFIG.audioFmt; + const files = fs.readdirSync(dir).filter(f => f.startsWith(safeTitle) && f.endsWith(`.${ext}`)); + if (!files.length) return; + const audioFile = path.join(dir, files[0]); + const tmpFile = audioFile + '.tmp.' + ext; + return new Promise(resolve => { + const proc = spawn('ffmpeg', [ + '-i', audioFile, '-i', coverPath, + '-map', '0', '-map', '1', + '-c', 'copy', '-id3v2_version', '3', + '-metadata:s:v', 'title=Album cover', + '-metadata:s:v', 'comment=Cover (front)', + tmpFile, '-y' + ]); + proc.on('close', code => { + if (code === 0) { + fs.renameSync(tmpFile, audioFile); + log(jobId, '🖼 Cover eingebettet'); + } else { + try { fs.unlinkSync(tmpFile); } catch {} + } + resolve(); + }); + proc.on('error', () => resolve()); + }); +} + +// Download multiple tracks (album/artist) +async function downloadAlbumTracks({ jobId, artist, albumTitle, tracks, artworkUrl }) { + const total = tracks.length; + log(jobId, `📀 Album "${albumTitle}" – ${total} Tracks`); + let done = 0; + + for (const track of tracks) { + try { + await downloadTrack({ + jobId, + artist: track.artistName || artist, + title: track.trackName || track.title, + album: albumTitle, + year: track.releaseDate ? new Date(track.releaseDate).getFullYear() : undefined, + trackNum: track.trackNumber || undefined, + artworkUrl: artworkUrl || track.artworkUrl100, + outputDir: CONFIG.musicPath, + }); + done++; + updateJob(jobId, { progress: Math.round((done / total) * 90) }); + } catch (e) { + log(jobId, `⚠️ Fehler bei "${track.trackName}": ${e.message}`); + } + } +} + +// Fetch iTunes album tracks +async function fetchItunesTracks(collectionId) { + return new Promise((resolve, reject) => { + const url = `https://itunes.apple.com/lookup?id=${collectionId}&entity=song`; + https.get(url, res => { + let data = ''; + res.on('data', d => data += d); + res.on('end', () => { + try { + const parsed = JSON.parse(data); + resolve((parsed.results || []).filter(r => r.wrapperType === 'track')); + } catch { reject(new Error('iTunes API parse error')); } + }); + }).on('error', reject); + }); +} + +// Save cover.jpg into folder for Navidrome folder-art detection +function downloadCoverArt(dir, url) { + if (!url) return; + try { + const dest = path.join(dir, 'cover.jpg'); + if (fs.existsSync(dest)) return; + const file = fs.createWriteStream(dest); + const imgUrl = url.replace('100x100', '600x600').replace('60x60', '600x600'); + https.get(imgUrl, res => res.pipe(file)).on('error', () => {}); + } catch {} +} + +// Active download queue +let activeDownloads = 0; +const downloadQueue = []; + +function processQueue() { + if (activeDownloads >= CONFIG.maxParallel || downloadQueue.length === 0) return; + const task = downloadQueue.shift(); + activeDownloads++; + task().finally(() => { activeDownloads--; processQueue(); }); +} + +function enqueue(task) { + downloadQueue.push(task); + processQueue(); +} + +// ─── ROUTES ────────────────────────────────────────────────────────────────── + +// Health check +app.get('/api/health', (req, res) => { + res.json({ + status: 'ok', + ytdlpVersion, + musicPath: CONFIG.musicPath, + audioFormat: CONFIG.audioFmt, + activeJobs: activeDownloads, + queueLength: downloadQueue.length, + }); +}); + +// Submit download job +app.post('/api/download', async (req, res) => { + const { type, artist, title, album, artworkUrl, collectionId, jobId: clientJobId } = req.body; + + if (!artist || !title) { + return res.status(400).json({ error: 'artist and title required' }); + } + + const jobId = clientJobId || ('job_' + Date.now()); + jobs.set(jobId, { + id: jobId, type, artist, title, album, + status: 'queued', progress: 0, message: 'In Warteschlange…', + log: [], createdAt: Date.now(), updatedAt: Date.now(), + }); + + res.json({ jobId, status: 'queued' }); + + // Enqueue the actual work + enqueue(async () => { + updateJob(jobId, { status: 'running', progress: 5, message: 'Gestartet…' }); + + try { + if (type === 'track') { + await downloadTrack({ jobId, artist, title, album, artworkUrl }); + updateJob(jobId, { status: 'done', progress: 100, message: 'Fertig!' }); + + } else if (type === 'album') { + let tracks = []; + if (collectionId) { + log(jobId, '📋 Lade Track-Liste von iTunes…'); + tracks = await fetchItunesTracks(collectionId); + } + if (tracks.length) { + await downloadAlbumTracks({ jobId, artist, albumTitle: album || title, tracks, artworkUrl }); + } else { + // Fallback: download full album search + await downloadTrack({ jobId, artist, title: `${album || title} full album`, album, artworkUrl }); + } + updateJob(jobId, { status: 'done', progress: 100, message: `Album "${album || title}" fertig!` }); + + } else if (type === 'artist') { + log(jobId, `🎤 Künstler-Download: ${artist}`); + // Search for artist's top albums via iTunes + const artistData = await new Promise((resolve, reject) => { + https.get(`https://itunes.apple.com/search?term=${encodeURIComponent(artist)}&media=music&entity=album&limit=10&country=de`, res => { + let d = '';res.on('data', c=>d+=c);res.on('end',()=>{try{resolve(JSON.parse(d).results||[])}catch{reject(new Error('parse'))}}); + }).on('error',reject); + }); + const artistAlbums = artistData.filter(a => a.artistName?.toLowerCase() === artist.toLowerCase()).slice(0, 10); + const total = artistAlbums.length || 1; + log(jobId, `📀 ${total} Alben gefunden`); + let done = 0; + for (const alb of artistAlbums) { + const tracks = await fetchItunesTracks(alb.collectionId).catch(() => []); + if (tracks.length) { + await downloadAlbumTracks({ jobId, artist, albumTitle: alb.collectionName, tracks, artworkUrl: alb.artworkUrl100 }); + } + done++; + updateJob(jobId, { progress: Math.round((done / total) * 95) }); + } + updateJob(jobId, { status: 'done', progress: 100, message: `Künstler "${artist}" vollständig heruntergeladen!` }); + } + + } catch (err) { + console.error(`[${jobId}] Error:`, err.message); + updateJob(jobId, { status: 'error', message: err.message }); + } + }); +}); + +// Job status +app.get('/api/status/:jobId', (req, res) => { + const job = jobs.get(req.params.jobId); + if (!job) return res.status(404).json({ error: 'Job not found' }); + res.json(job); +}); + +// List all jobs +app.get('/api/jobs', (req, res) => { + res.json(Array.from(jobs.values()).sort((a, b) => b.createdAt - a.createdAt).slice(0, 50)); +}); + +// ─── START ──────────────────────────────────────────────────────────────────── +app.listen(CONFIG.port, () => { + console.log(`\n🎵 NovaSonic Backend running on port ${CONFIG.port}`); + console.log(` Music folder : ${CONFIG.musicPath}`); + console.log(` Audio format : ${CONFIG.audioFmt} (quality ${CONFIG.audioQuality})`); + console.log(` yt-dlp : ${ytdlpVersion}`); + console.log(''); +}); \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 615c985..dcbce6a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,6 +7,8 @@ services: restart: unless-stopped ports: - "8080:80" # route this port through your reverse proxy + environment: + - NAVIDROME_URL=${NAVIDROME_URL} # pre-filled on the setup screen # ── Backend (yt-dlp download server) ───────────────────────────────────────── novasonic-backend: @@ -25,9 +27,6 @@ services: - AUDIO_FMT=${AUDIO_FMT:-mp3} - AUDIO_QUALITY=${AUDIO_QUALITY:-0} - MAX_PARALLEL=${MAX_PARALLEL:-2} - - NAVIDROME_URL=${NAVIDROME_URL:-} - - NAVIDROME_USER=${NAVIDROME_USER:-} - - NAVIDROME_PASS=${NAVIDROME_PASS:-} command: > sh -c " apk add --no-cache yt-dlp ffmpeg python3 && diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100644 index 0000000..1e72bda --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,6 @@ +#!/bin/sh +# Generate runtime config from env vars so the frontend knows the Navidrome URL +cat > /usr/share/nginx/html/config.js < + + @@ -454,7 +456,6 @@ - diff --git a/nginx.conf b/nginx.conf index 8753cdd..4994a9a 100644 --- a/nginx.conf +++ b/nginx.conf @@ -24,6 +24,11 @@ server { add_header Service-Worker-Allowed "/"; } + # Runtime config – never cache so NAVIDROME_URL changes are picked up + location = /config.js { + add_header Cache-Control "no-cache, no-store, must-revalidate"; + } + # SPA fallback location / { try_files $uri $uri/ /index.html; diff --git a/sw.js b/sw.js index 91d9292..4fba904 100644 --- a/sw.js +++ b/sw.js @@ -1,6 +1,6 @@ const CACHE = 'ns-v12'; const PRECACHE = ['/', 'index.html', 'styles.css', 'app.js', 'sw.js']; -const SKIP_URLS = ['/rest/stream', '/rest/download']; +const SKIP_URLS = ['/rest/stream', '/rest/download', '/config.js']; self.addEventListener('install', e => { e.waitUntil(caches.open(CACHE).then(c => c.addAll(PRECACHE)));