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 <noreply@anthropic.com>
This commit is contained in:
1 parent
c789672793
commit
b6edfcf14b
9 files changed
+414
-14
No files matched your search
+4
-4
@@ -1,11 +1,11 @@
|
|||||||
# Copy to .env and fill in your values
|
# 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
|
MUSIC_PATH=/path/to/your/navidrome/music
|
||||||
|
|
||||||
AUDIO_FMT=mp3 # mp3 | flac | opus | best
|
AUDIO_FMT=mp3 # mp3 | flac | opus | best
|
||||||
AUDIO_QUALITY=0 # 0 = best quality
|
AUDIO_QUALITY=0 # 0 = best quality
|
||||||
MAX_PARALLEL=2
|
MAX_PARALLEL=2
|
||||||
|
|
||||||
NAVIDROME_URL=http://navidrome:4533
|
|
||||||
NAVIDROME_USER=admin
|
|
||||||
NAVIDROME_PASS=yourpassword
|
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
FROM nginx:alpine
|
FROM nginx:alpine
|
||||||
COPY index.html styles.css app.js sw.js /usr/share/nginx/html/
|
COPY index.html styles.css app.js sw.js /usr/share/nginx/html/
|
||||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY docker-entrypoint.sh /docker-entrypoint.sh
|
||||||
|
RUN chmod +x /docker-entrypoint.sh
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
ENTRYPOINT ["/docker-entrypoint.sh"]
|
||||||
@@ -110,6 +110,11 @@ function disconnect(){
|
|||||||
window.addEventListener('load',()=>{
|
window.addEventListener('load',()=>{
|
||||||
const saved=localStorage.getItem('ns_cfg');
|
const saved=localStorage.getItem('ns_cfg');
|
||||||
if(saved){cfg=JSON.parse(saved);startApp()}
|
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()});
|
document.getElementById('srv-pass').addEventListener('keydown',e=>{if(e.key==='Enter')doConnect()});
|
||||||
audio.addEventListener('timeupdate',updateProgress);
|
audio.addEventListener('timeupdate',updateProgress);
|
||||||
audio.addEventListener('ended',onEnded);
|
audio.addEventListener('ended',onEnded);
|
||||||
@@ -916,13 +921,9 @@ async function pollJob(jobId){
|
|||||||
const d=await res.json();
|
const d=await res.json();
|
||||||
updateJob(jobId,d.status,d.message,d.progress);
|
updateJob(jobId,d.status,d.message,d.progress);
|
||||||
if(d.status!=='done'&&d.status!=='error')setTimeout(()=>pollJob(jobId),1500);
|
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')}
|
}catch{updateJob(jobId,'error','Status-Abfrage fehlgeschlagen')}
|
||||||
}
|
}
|
||||||
async function triggerNaviScan(){
|
|
||||||
try{await api('startScan')}catch{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── LOCAL DOWNLOAD (from Navidrome) ───
|
// ─── LOCAL DOWNLOAD (from Navidrome) ───
|
||||||
function dlSongLocal(s){
|
function dlSongLocal(s){
|
||||||
const a=document.createElement('a');a.href=downloadUrl(s.id);
|
const a=document.createElement('a');a.href=downloadUrl(s.id);
|
||||||
|
|||||||
@@ -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('');
|
||||||
|
});
|
||||||
+2
-3
@@ -7,6 +7,8 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "8080:80" # route this port through your reverse proxy
|
- "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) ─────────────────────────────────────────
|
# ── Backend (yt-dlp download server) ─────────────────────────────────────────
|
||||||
novasonic-backend:
|
novasonic-backend:
|
||||||
@@ -25,9 +27,6 @@ services:
|
|||||||
- AUDIO_FMT=${AUDIO_FMT:-mp3}
|
- AUDIO_FMT=${AUDIO_FMT:-mp3}
|
||||||
- AUDIO_QUALITY=${AUDIO_QUALITY:-0}
|
- AUDIO_QUALITY=${AUDIO_QUALITY:-0}
|
||||||
- MAX_PARALLEL=${MAX_PARALLEL:-2}
|
- MAX_PARALLEL=${MAX_PARALLEL:-2}
|
||||||
- NAVIDROME_URL=${NAVIDROME_URL:-}
|
|
||||||
- NAVIDROME_USER=${NAVIDROME_USER:-}
|
|
||||||
- NAVIDROME_PASS=${NAVIDROME_PASS:-}
|
|
||||||
command: >
|
command: >
|
||||||
sh -c "
|
sh -c "
|
||||||
apk add --no-cache yt-dlp ffmpeg python3 &&
|
apk add --no-cache yt-dlp ffmpeg python3 &&
|
||||||
|
|||||||
@@ -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 <<EOF
|
||||||
|
window.NS_SERVER_URL = "${NAVIDROME_URL:-}";
|
||||||
|
EOF
|
||||||
|
exec nginx -g "daemon off;"
|
||||||
+2
-1
@@ -11,6 +11,8 @@
|
|||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@300;400;500&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@300;400;500&display=swap" rel="stylesheet">
|
||||||
<link rel="stylesheet" href="styles.css">
|
<link rel="stylesheet" href="styles.css">
|
||||||
|
<script src="config.js"></script>
|
||||||
|
<script src="app.js" defer></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
@@ -454,7 +456,6 @@
|
|||||||
<audio id="audio-element"></audio>
|
<audio id="audio-element"></audio>
|
||||||
<audio id="preview-audio"></audio>
|
<audio id="preview-audio"></audio>
|
||||||
|
|
||||||
<script src="app.js" defer></script>
|
|
||||||
<!-- CONTEXT MENU -->
|
<!-- CONTEXT MENU -->
|
||||||
<div class="ctx-menu" id="ctx-menu" style="display:none"></div>
|
<div class="ctx-menu" id="ctx-menu" style="display:none"></div>
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ server {
|
|||||||
add_header Service-Worker-Allowed "/";
|
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
|
# SPA fallback
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
const CACHE = 'ns-v12';
|
const CACHE = 'ns-v12';
|
||||||
const PRECACHE = ['/', 'index.html', 'styles.css', 'app.js', 'sw.js'];
|
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 => {
|
self.addEventListener('install', e => {
|
||||||
e.waitUntil(caches.open(CACHE).then(c => c.addAll(PRECACHE)));
|
e.waitUntil(caches.open(CACHE).then(c => c.addAll(PRECACHE)));
|
||||||
|
|||||||
Reference in new issue
Block a user