fix: hi-res cover art embedded, consistent album_artist for Navidrome grouping
- downloadArtwork(): fetches 1000x1000 iTunes artwork (was 100x100/600x600) and embeds it into the MP3 via ffmpeg — no more blurry YouTube thumbnails - ffmpeg now uses -map_metadata -1 to strip all existing tags (removes the blurry YT thumbnail that was being copied over) then writes clean ID3 tags - albumArtist param added to downloadTrack(); downloadAlbumTracks() always passes the main artist as album_artist regardless of track's feat. artists so Navidrome groups all tracks under one artist consistently - Removed embedThumbnailFallback (superseded by new flow) - Removed old downloadCoverArt (replaced by async downloadArtwork) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
c1f71c1d06
commit
934e4f3b47
1 file changed
+98
-131
+98
-131
@@ -6,12 +6,6 @@
|
|||||||
* - Node.js 18+
|
* - Node.js 18+
|
||||||
* - yt-dlp installed (https://github.com/yt-dlp/yt-dlp)
|
* - yt-dlp installed (https://github.com/yt-dlp/yt-dlp)
|
||||||
* - ffmpeg installed
|
* - ffmpeg installed
|
||||||
*
|
|
||||||
* Install & run:
|
|
||||||
* npm install express cors
|
|
||||||
* node novasonic-server.js
|
|
||||||
*
|
|
||||||
* Or with Docker - see docker-compose.yml
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
@@ -22,45 +16,42 @@ const https = require('https');
|
|||||||
|
|
||||||
// ─── CONFIG ───────────────────────────────────────────────────────────────────
|
// ─── CONFIG ───────────────────────────────────────────────────────────────────
|
||||||
const CONFIG = {
|
const CONFIG = {
|
||||||
port: process.env.PORT || 3700,
|
port: process.env.PORT || 3700,
|
||||||
musicPath: process.env.MUSIC_PATH || '/music', // Navidrome music library folder
|
musicPath: process.env.MUSIC_PATH || '/music',
|
||||||
ytdlpBin: process.env.YTDLP_BIN || 'yt-dlp', // Path to yt-dlp binary
|
ytdlpBin: process.env.YTDLP_BIN || 'yt-dlp',
|
||||||
audioFmt: process.env.AUDIO_FMT || 'mp3', // mp3 | flac | opus | best
|
audioFmt: process.env.AUDIO_FMT || 'mp3',
|
||||||
audioQuality: process.env.AUDIO_QUALITY || '0', // 0 = best
|
audioQuality: process.env.AUDIO_QUALITY || '0',
|
||||||
maxParallel: parseInt(process.env.MAX_PARALLEL || '2'),
|
maxParallel: parseInt(process.env.MAX_PARALLEL || '2'),
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── INIT ─────────────────────────────────────────────────────────────────────
|
// ─── INIT ─────────────────────────────────────────────────────────────────────
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use((req,res,next)=>{
|
app.use((req, res, next) => {
|
||||||
res.setHeader('Access-Control-Allow-Origin','*');
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||||
res.setHeader('Access-Control-Allow-Methods','GET,POST,OPTIONS');
|
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,OPTIONS');
|
||||||
res.setHeader('Access-Control-Allow-Headers','Content-Type,Authorization');
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type,Authorization');
|
||||||
res.setHeader('Access-Control-Allow-Private-Network','true');
|
res.setHeader('Access-Control-Allow-Private-Network', 'true');
|
||||||
if(req.method==='OPTIONS'){res.sendStatus(204);return;}
|
if (req.method === 'OPTIONS') { res.sendStatus(204); return; }
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
|
||||||
// In-memory job store
|
|
||||||
const jobs = new Map();
|
const jobs = new Map();
|
||||||
|
|
||||||
// Check yt-dlp on startup
|
|
||||||
let ytdlpVersion = 'unknown';
|
let ytdlpVersion = 'unknown';
|
||||||
try {
|
try {
|
||||||
ytdlpVersion = execSync(`${CONFIG.ytdlpBin} --version`, { timeout: 5000 }).toString().trim();
|
ytdlpVersion = execSync(`${CONFIG.ytdlpBin} --version`, { timeout: 5000 }).toString().trim();
|
||||||
console.log(`✅ yt-dlp ${ytdlpVersion} found`);
|
console.log(`✅ yt-dlp ${ytdlpVersion} found`);
|
||||||
} catch {
|
} catch {
|
||||||
console.warn('⚠️ yt-dlp not found! Install from https://github.com/yt-dlp/yt-dlp');
|
console.warn('⚠️ yt-dlp not found!');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure music dir exists
|
|
||||||
if (!fs.existsSync(CONFIG.musicPath)) {
|
if (!fs.existsSync(CONFIG.musicPath)) {
|
||||||
console.warn(`⚠️ Music path "${CONFIG.musicPath}" does not exist — creating it`);
|
console.warn(`⚠️ Music path "${CONFIG.musicPath}" does not exist — creating it`);
|
||||||
fs.mkdirSync(CONFIG.musicPath, { recursive: true });
|
fs.mkdirSync(CONFIG.musicPath, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── HELPERS ─────────────────────────────────────────────────────────────────
|
// ─── HELPERS ──────────────────────────────────────────────────────────────────
|
||||||
function sanitize(s) {
|
function sanitize(s) {
|
||||||
return (s || 'Unknown').replace(/[\\/:*?"<>|]/g, '_').trim().slice(0, 100);
|
return (s || 'Unknown').replace(/[\\/:*?"<>|]/g, '_').trim().slice(0, 100);
|
||||||
}
|
}
|
||||||
@@ -76,12 +67,40 @@ function log(jobId, msg) {
|
|||||||
console.log(`[${jobId}] ${msg}`);
|
console.log(`[${jobId}] ${msg}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download a single track via yt-dlp, then stamp metadata via a dedicated ffmpeg pass.
|
// Download hi-res iTunes artwork (1000x1000) to cover.jpg, return path or null
|
||||||
// We do NOT use --postprocessor-args for metadata because yt-dlp splits that string on
|
async function downloadArtwork(url, dir) {
|
||||||
// spaces internally, so any metadata value with a space (e.g. album name) gets corrupted.
|
if (!url) return null;
|
||||||
// Instead: yt-dlp downloads, then we call ffmpeg ourselves with properly separated args.
|
const dest = path.join(dir, 'cover.jpg');
|
||||||
async function downloadTrack({ jobId, artist, title, album, year, trackNum, totalTracks, artworkUrl, outputDir }) {
|
// Upgrade to 1000x1000 — iTunes URL pattern: ...100x100bb.jpg
|
||||||
const safeArtist = sanitize(artist);
|
const hiRes = url.replace(/\d+x\d+bb\./, '1000x1000bb.');
|
||||||
|
return new Promise(resolve => {
|
||||||
|
const tryUrl = (u, fallback) => {
|
||||||
|
const file = fs.createWriteStream(dest);
|
||||||
|
https.get(u, res => {
|
||||||
|
if (res.statusCode !== 200) {
|
||||||
|
res.resume();
|
||||||
|
file.close();
|
||||||
|
try { fs.unlinkSync(dest); } catch {}
|
||||||
|
if (fallback) tryUrl(fallback, null);
|
||||||
|
else resolve(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.pipe(file);
|
||||||
|
file.on('finish', () => resolve(dest));
|
||||||
|
file.on('error', () => resolve(null));
|
||||||
|
}).on('error', () => {
|
||||||
|
if (fallback) tryUrl(fallback, null);
|
||||||
|
else resolve(null);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
tryUrl(hiRes, url !== hiRes ? url : null);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download a single track: yt-dlp audio + ffmpeg clean metadata + iTunes cover embedded
|
||||||
|
async function downloadTrack({ jobId, artist, albumArtist, title, album, year, trackNum, totalTracks, artworkUrl, outputDir }) {
|
||||||
|
const mainArtist = albumArtist || artist;
|
||||||
|
const safeArtist = sanitize(mainArtist);
|
||||||
const safeAlbum = sanitize(album || 'Singles');
|
const safeAlbum = sanitize(album || 'Singles');
|
||||||
const dir = path.join(outputDir || CONFIG.musicPath, safeArtist, safeAlbum);
|
const dir = path.join(outputDir || CONFIG.musicPath, safeArtist, safeAlbum);
|
||||||
fs.mkdirSync(dir, { recursive: true });
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
@@ -89,21 +108,19 @@ async function downloadTrack({ jobId, artist, title, album, year, trackNum, tota
|
|||||||
const safeTitle = sanitize(title);
|
const safeTitle = sanitize(title);
|
||||||
const trackPrefix = trackNum ? String(trackNum).padStart(2, '0') + ' - ' : '';
|
const trackPrefix = trackNum ? String(trackNum).padStart(2, '0') + ' - ' : '';
|
||||||
const outputTemplate = path.join(dir, `${trackPrefix}${safeTitle}.%(ext)s`);
|
const outputTemplate = path.join(dir, `${trackPrefix}${safeTitle}.%(ext)s`);
|
||||||
const query = `${artist} - ${title} official audio`;
|
const query = `${mainArtist} - ${title} official audio`;
|
||||||
log(jobId, `\uD83D\uDD0D Suche: "${query}"`);
|
log(jobId, `🔍 Suche: "${query}"`);
|
||||||
|
|
||||||
// Step 1: yt-dlp – download + extract audio + embed YT thumbnail
|
// Step 1: yt-dlp — audio only, no thumbnail (we embed iTunes artwork ourselves)
|
||||||
await new Promise((resolve, reject) => {
|
await new Promise((resolve, reject) => {
|
||||||
const args = [
|
const args = [
|
||||||
`ytsearch1:${query}`,
|
`ytsearch1:${query}`,
|
||||||
'--extract-audio',
|
'--extract-audio',
|
||||||
'--audio-format', CONFIG.audioFmt,
|
'--audio-format', CONFIG.audioFmt,
|
||||||
'--audio-quality', CONFIG.audioQuality,
|
'--audio-quality', CONFIG.audioQuality,
|
||||||
'-o', outputTemplate,
|
'-o', outputTemplate,
|
||||||
'--embed-thumbnail',
|
|
||||||
'--convert-thumbnails', 'jpg',
|
|
||||||
'--no-playlist',
|
'--no-playlist',
|
||||||
'--playlist-items', '1',
|
'--playlist-items', '1',
|
||||||
'--no-warnings',
|
'--no-warnings',
|
||||||
'--newline',
|
'--newline',
|
||||||
];
|
];
|
||||||
@@ -128,7 +145,7 @@ async function downloadTrack({ jobId, artist, title, album, year, trackNum, tota
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Step 2: find the audio file yt-dlp just wrote (newest audio file in dir)
|
// Step 2: find the audio file yt-dlp wrote (newest audio file in dir)
|
||||||
const audioExts = ['mp3', 'flac', 'opus', 'm4a', 'ogg', 'wav'];
|
const audioExts = ['mp3', 'flac', 'opus', 'm4a', 'ogg', 'wav'];
|
||||||
const audioFile = fs.readdirSync(dir)
|
const audioFile = fs.readdirSync(dir)
|
||||||
.filter(f => audioExts.some(e => f.endsWith(`.${e}`)))
|
.filter(f => audioExts.some(e => f.endsWith(`.${e}`)))
|
||||||
@@ -136,97 +153,79 @@ async function downloadTrack({ jobId, artist, title, album, year, trackNum, tota
|
|||||||
.sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs)[0];
|
.sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs)[0];
|
||||||
|
|
||||||
if (!audioFile) {
|
if (!audioFile) {
|
||||||
log(jobId, '\u26A0\uFE0F Keine Audio-Datei gefunden – \xFCberspringe Metadaten');
|
log(jobId, '⚠️ Keine Audio-Datei gefunden');
|
||||||
if (artworkUrl) downloadCoverArt(dir, artworkUrl);
|
|
||||||
return { success: true, dir };
|
return { success: true, dir };
|
||||||
}
|
}
|
||||||
|
|
||||||
log(jobId, `\uD83C\uDFF7 Schreibe Metadaten \u2192 ${path.basename(audioFile)}`);
|
// Step 3: download hi-res iTunes artwork
|
||||||
|
const coverPath = await downloadArtwork(artworkUrl, dir);
|
||||||
|
const hasCover = !!(coverPath && fs.existsSync(coverPath));
|
||||||
|
|
||||||
// Step 3: ffmpeg – rewrite ID3 tags. Each -metadata is a separate arg so spaces are safe.
|
log(jobId, `🏷 Metadaten + Cover → ${path.basename(audioFile)}`);
|
||||||
|
|
||||||
|
// Step 4: ffmpeg — strip old tags, write clean ID3, embed hi-res artwork
|
||||||
const fileExt = path.extname(audioFile);
|
const fileExt = path.extname(audioFile);
|
||||||
const tmpFile = audioFile.slice(0, -fileExt.length) + '.metatmp' + fileExt;
|
const tmpFile = audioFile.slice(0, -fileExt.length) + '.metatmp' + fileExt;
|
||||||
await new Promise(resolve => {
|
await new Promise(resolve => {
|
||||||
const ffArgs = [
|
const ffArgs = ['-i', audioFile];
|
||||||
'-i', audioFile,
|
if (hasCover) ffArgs.push('-i', coverPath);
|
||||||
|
|
||||||
|
// Map: audio from input 0, artwork from input 1 (if available)
|
||||||
|
ffArgs.push('-map', '0:a');
|
||||||
|
if (hasCover) ffArgs.push('-map', '1:v');
|
||||||
|
|
||||||
|
ffArgs.push(
|
||||||
'-c', 'copy',
|
'-c', 'copy',
|
||||||
'-map_metadata', '0',
|
'-map_metadata', '-1', // strip all existing tags (removes blurry YT thumbnail)
|
||||||
'-id3v2_version', '3',
|
'-id3v2_version', '3',
|
||||||
'-metadata', `title=${title}`,
|
'-metadata', `title=${title}`,
|
||||||
'-metadata', `artist=${artist}`,
|
'-metadata', `artist=${artist}`,
|
||||||
'-metadata', `album_artist=${artist}`,
|
'-metadata', `album_artist=${mainArtist}`,
|
||||||
];
|
);
|
||||||
if (album) ffArgs.push('-metadata', `album=${album}`);
|
if (album) ffArgs.push('-metadata', `album=${album}`);
|
||||||
if (year) ffArgs.push('-metadata', `date=${String(year)}`);
|
if (year) ffArgs.push('-metadata', `date=${String(year)}`);
|
||||||
if (trackNum) ffArgs.push('-metadata', `track=${totalTracks ? `${trackNum}/${totalTracks}` : String(trackNum)}`);
|
if (trackNum) ffArgs.push('-metadata', `track=${totalTracks ? `${trackNum}/${totalTracks}` : String(trackNum)}`);
|
||||||
if (totalTracks) ffArgs.push('-metadata', `totaltracks=${String(totalTracks)}`);
|
if (totalTracks) ffArgs.push('-metadata', `totaltracks=${String(totalTracks)}`);
|
||||||
|
if (hasCover) {
|
||||||
|
ffArgs.push(
|
||||||
|
'-metadata:s:v', 'title=Album cover',
|
||||||
|
'-metadata:s:v', 'comment=Cover (front)',
|
||||||
|
);
|
||||||
|
}
|
||||||
ffArgs.push(tmpFile, '-y');
|
ffArgs.push(tmpFile, '-y');
|
||||||
|
|
||||||
const ff = spawn('ffmpeg', ffArgs);
|
const ff = spawn('ffmpeg', ffArgs);
|
||||||
let ffErr = '';
|
let ffErr = '';
|
||||||
ff.stderr.on('data', d => ffErr += d.toString());
|
ff.stderr.on('data', d => ffErr += d.toString());
|
||||||
ff.on('error', () => resolve()); // ffmpeg not installed? skip gracefully
|
ff.on('error', () => resolve());
|
||||||
ff.on('close', code => {
|
ff.on('close', code => {
|
||||||
if (code === 0 && fs.existsSync(tmpFile)) {
|
if (code === 0 && fs.existsSync(tmpFile)) {
|
||||||
fs.renameSync(tmpFile, audioFile);
|
fs.renameSync(tmpFile, audioFile);
|
||||||
log(jobId, '\u2705 Metadaten geschrieben (title, artist, album, year, track)');
|
log(jobId, `✅ Fertig${hasCover ? ' (Cover 1000x1000 eingebettet)' : ''}`);
|
||||||
} else {
|
} else {
|
||||||
try { fs.unlinkSync(tmpFile); } catch {}
|
try { fs.unlinkSync(tmpFile); } catch {}
|
||||||
log(jobId, `\u26A0\uFE0F ffmpeg Metadaten-Schritt: code ${code}`);
|
log(jobId, `⚠️ ffmpeg code ${code}`);
|
||||||
if (ffErr) log(jobId, ffErr.slice(-200));
|
if (ffErr) log(jobId, ffErr.slice(-200));
|
||||||
}
|
}
|
||||||
resolve();
|
resolve();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Step 4: save cover.jpg for Navidrome folder-art
|
|
||||||
if (artworkUrl) downloadCoverArt(dir, artworkUrl);
|
|
||||||
return { success: true, dir };
|
return { success: true, dir };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: embed thumbnail via separate ffmpeg call (for formats where yt-dlp fails)
|
// Download all tracks of an album sequentially
|
||||||
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 }) {
|
async function downloadAlbumTracks({ jobId, artist, albumTitle, tracks, artworkUrl }) {
|
||||||
const total = tracks.length;
|
const total = tracks.length;
|
||||||
log(jobId, `📀 Album "${albumTitle}" – ${total} Tracks`);
|
log(jobId, `📀 Album "${albumTitle}" — ${total} Tracks`);
|
||||||
let done = 0;
|
let done = 0;
|
||||||
|
|
||||||
for (const track of tracks) {
|
for (const track of tracks) {
|
||||||
try {
|
try {
|
||||||
await downloadTrack({
|
await downloadTrack({
|
||||||
jobId,
|
jobId,
|
||||||
artist: track.artistName || artist,
|
artist: track.artistName || artist, // track artist (may include feat.)
|
||||||
|
albumArtist: artist, // always the main artist → consistent grouping
|
||||||
title: track.trackName || track.title,
|
title: track.trackName || track.title,
|
||||||
album: albumTitle,
|
album: albumTitle,
|
||||||
year: track.releaseDate ? new Date(track.releaseDate).getFullYear() : undefined,
|
year: track.releaseDate ? new Date(track.releaseDate).getFullYear() : undefined,
|
||||||
@@ -238,12 +237,12 @@ async function downloadAlbumTracks({ jobId, artist, albumTitle, tracks, artworkU
|
|||||||
done++;
|
done++;
|
||||||
updateJob(jobId, { progress: Math.round((done / total) * 90) });
|
updateJob(jobId, { progress: Math.round((done / total) * 90) });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log(jobId, `⚠️ Fehler bei "${track.trackName}": ${e.message}`);
|
log(jobId, `⚠️ Fehler bei "${track.trackName || track.title}": ${e.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search iTunes for an album by artist+name and return its tracks
|
// Search iTunes for album tracks by artist + album name (fallback when no collectionId)
|
||||||
async function searchItunesAlbumTracks(artist, album) {
|
async function searchItunesAlbumTracks(artist, album) {
|
||||||
return new Promise(resolve => {
|
return new Promise(resolve => {
|
||||||
const q = encodeURIComponent(`${artist} ${album}`);
|
const q = encodeURIComponent(`${artist} ${album}`);
|
||||||
@@ -265,7 +264,7 @@ async function searchItunesAlbumTracks(artist, album) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch iTunes album tracks
|
// Fetch individual tracks for an iTunes collectionId
|
||||||
async function fetchItunesTracks(collectionId) {
|
async function fetchItunesTracks(collectionId) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const url = `https://itunes.apple.com/lookup?id=${collectionId}&entity=song`;
|
const url = `https://itunes.apple.com/lookup?id=${collectionId}&entity=song`;
|
||||||
@@ -282,19 +281,7 @@ async function fetchItunesTracks(collectionId) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save cover.jpg into folder for Navidrome folder-art detection
|
// ─── QUEUE ────────────────────────────────────────────────────────────────────
|
||||||
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;
|
let activeDownloads = 0;
|
||||||
const downloadQueue = [];
|
const downloadQueue = [];
|
||||||
|
|
||||||
@@ -312,25 +299,13 @@ function enqueue(task) {
|
|||||||
|
|
||||||
// ─── ROUTES ──────────────────────────────────────────────────────────────────
|
// ─── ROUTES ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// Health check
|
|
||||||
app.get('/api/health', (req, res) => {
|
app.get('/api/health', (req, res) => {
|
||||||
res.json({
|
res.json({ status: 'ok', ytdlpVersion, musicPath: CONFIG.musicPath, audioFormat: CONFIG.audioFmt, activeJobs: activeDownloads, queueLength: downloadQueue.length });
|
||||||
status: 'ok',
|
|
||||||
ytdlpVersion,
|
|
||||||
musicPath: CONFIG.musicPath,
|
|
||||||
audioFormat: CONFIG.audioFmt,
|
|
||||||
activeJobs: activeDownloads,
|
|
||||||
queueLength: downloadQueue.length,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Submit download job
|
|
||||||
app.post('/api/download', async (req, res) => {
|
app.post('/api/download', async (req, res) => {
|
||||||
const { type, artist, title, album, artworkUrl, collectionId, jobId: clientJobId } = req.body;
|
const { type, artist, title, album, artworkUrl, collectionId, jobId: clientJobId } = req.body;
|
||||||
|
if (!artist || !title) return res.status(400).json({ error: 'artist and title required' });
|
||||||
if (!artist || !title) {
|
|
||||||
return res.status(400).json({ error: 'artist and title required' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const jobId = clientJobId || ('job_' + Date.now());
|
const jobId = clientJobId || ('job_' + Date.now());
|
||||||
jobs.set(jobId, {
|
jobs.set(jobId, {
|
||||||
@@ -338,13 +313,10 @@ app.post('/api/download', async (req, res) => {
|
|||||||
status: 'queued', progress: 0, message: 'In Warteschlange…',
|
status: 'queued', progress: 0, message: 'In Warteschlange…',
|
||||||
log: [], createdAt: Date.now(), updatedAt: Date.now(),
|
log: [], createdAt: Date.now(), updatedAt: Date.now(),
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({ jobId, status: 'queued' });
|
res.json({ jobId, status: 'queued' });
|
||||||
|
|
||||||
// Enqueue the actual work
|
|
||||||
enqueue(async () => {
|
enqueue(async () => {
|
||||||
updateJob(jobId, { status: 'running', progress: 5, message: 'Gestartet…' });
|
updateJob(jobId, { status: 'running', progress: 5, message: 'Gestartet…' });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (type === 'track') {
|
if (type === 'track') {
|
||||||
await downloadTrack({ jobId, artist, title, album, artworkUrl });
|
await downloadTrack({ jobId, artist, title, album, artworkUrl });
|
||||||
@@ -361,22 +333,20 @@ app.post('/api/download', async (req, res) => {
|
|||||||
tracks = await searchItunesAlbumTracks(artist, album || title);
|
tracks = await searchItunesAlbumTracks(artist, album || title);
|
||||||
}
|
}
|
||||||
if (!tracks.length) {
|
if (!tracks.length) {
|
||||||
throw new Error(`Keine einzelnen Tracks für "${album || title}" gefunden. Bitte über die iTunes Global-Suche herunterladen.`);
|
throw new Error(`Keine einzelnen Tracks für "${album || title}" gefunden. Bitte über iTunes Global-Suche herunterladen.`);
|
||||||
}
|
}
|
||||||
await downloadAlbumTracks({ jobId, artist, albumTitle: album || title, tracks, artworkUrl });
|
await downloadAlbumTracks({ jobId, artist, albumTitle: album || title, tracks, artworkUrl });
|
||||||
updateJob(jobId, { status: 'done', progress: 100, message: `Album "${album || title}" fertig!` });
|
updateJob(jobId, { status: 'done', progress: 100, message: `Album "${album || title}" fertig!` });
|
||||||
|
|
||||||
} else if (type === 'artist') {
|
} else if (type === 'artist') {
|
||||||
log(jobId, `🎤 Künstler-Download: ${artist}`);
|
log(jobId, `🎤 Künstler-Download: ${artist}`);
|
||||||
// Search for artist's top albums via iTunes
|
|
||||||
const artistData = await new Promise((resolve, reject) => {
|
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 => {
|
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'))}});
|
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);
|
}).on('error', reject);
|
||||||
});
|
});
|
||||||
const artistAlbums = artistData.filter(a => a.artistName?.toLowerCase() === artist.toLowerCase()).slice(0, 10);
|
const artistAlbums = artistData.filter(a => a.artistName?.toLowerCase() === artist.toLowerCase()).slice(0, 10);
|
||||||
const total = artistAlbums.length || 1;
|
log(jobId, `📀 ${artistAlbums.length} Alben gefunden`);
|
||||||
log(jobId, `📀 ${total} Alben gefunden`);
|
|
||||||
let done = 0;
|
let done = 0;
|
||||||
for (const alb of artistAlbums) {
|
for (const alb of artistAlbums) {
|
||||||
const tracks = await fetchItunesTracks(alb.collectionId).catch(() => []);
|
const tracks = await fetchItunesTracks(alb.collectionId).catch(() => []);
|
||||||
@@ -384,11 +354,10 @@ app.post('/api/download', async (req, res) => {
|
|||||||
await downloadAlbumTracks({ jobId, artist, albumTitle: alb.collectionName, tracks, artworkUrl: alb.artworkUrl100 });
|
await downloadAlbumTracks({ jobId, artist, albumTitle: alb.collectionName, tracks, artworkUrl: alb.artworkUrl100 });
|
||||||
}
|
}
|
||||||
done++;
|
done++;
|
||||||
updateJob(jobId, { progress: Math.round((done / total) * 95) });
|
updateJob(jobId, { progress: Math.round((done / artistAlbums.length) * 95) });
|
||||||
}
|
}
|
||||||
updateJob(jobId, { status: 'done', progress: 100, message: `Künstler "${artist}" vollständig heruntergeladen!` });
|
updateJob(jobId, { status: 'done', progress: 100, message: `Künstler "${artist}" fertig!` });
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`[${jobId}] Error:`, err.message);
|
console.error(`[${jobId}] Error:`, err.message);
|
||||||
updateJob(jobId, { status: 'error', message: err.message });
|
updateJob(jobId, { status: 'error', message: err.message });
|
||||||
@@ -396,14 +365,12 @@ app.post('/api/download', async (req, res) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Job status
|
|
||||||
app.get('/api/status/:jobId', (req, res) => {
|
app.get('/api/status/:jobId', (req, res) => {
|
||||||
const job = jobs.get(req.params.jobId);
|
const job = jobs.get(req.params.jobId);
|
||||||
if (!job) return res.status(404).json({ error: 'Job not found' });
|
if (!job) return res.status(404).json({ error: 'Job not found' });
|
||||||
res.json(job);
|
res.json(job);
|
||||||
});
|
});
|
||||||
|
|
||||||
// List all jobs
|
|
||||||
app.get('/api/jobs', (req, res) => {
|
app.get('/api/jobs', (req, res) => {
|
||||||
res.json(Array.from(jobs.values()).sort((a, b) => b.createdAt - a.createdAt).slice(0, 50));
|
res.json(Array.from(jobs.values()).sort((a, b) => b.createdAt - a.createdAt).slice(0, 50));
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in new issue
Block a user