feat: explicit metadata, lyrics embedding, E badge in global search

Backend:
- fetchLyrics() queries LRCLIB for plain + synced lyrics
- Synced lyrics saved as .lrc file (Navidrome reads these)
- Plain lyrics embedded via ffmpeg -metadata lyrics=
- ITUNESADVISORY=1 written for explicit tracks
- explicit flag passed through album/track download chain
- fetchItunesTracks + searchItunesAlbumTracks use country=us&explicit=Yes

Frontend:
- explicit:true sent in download payload from iTunes track data
- "E" badge shown on explicit tracks in global search track list

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Sonnet 4.6 committed 2026-04-26 18:12:53 +02:00
1 parent 3c5048f017
commit 93b859bf0e
4 files changed
+41 -10

No files matched your search

+37 -8
View File
@@ -115,8 +115,23 @@ function findExistingTrack(dir, trackPrefix, safeTitle) {
.find(f => fs.existsSync(f)) || null;
}
// Fetch plain + synced lyrics from LRCLIB
async function fetchLyrics(artist, title, album) {
try {
const p = new URLSearchParams({ artist_name: artist, track_name: title });
if (album) p.set('album_name', album);
const r = await fetch(`https://lrclib.net/api/get?${p}`, {
headers: { 'User-Agent': 'NovaSonic/1.0' },
signal: AbortSignal.timeout(8000),
});
if (!r.ok) return null;
const d = await r.json();
return { plain: d.plainLyrics || null, synced: d.syncedLyrics || null };
} catch { return 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, discNum, discTotal, compilation, genre, artworkUrl, outputDir }) {
async function downloadTrack({ jobId, artist, albumArtist, title, album, year, trackNum, totalTracks, discNum, discTotal, compilation, genre, artworkUrl, explicit, outputDir }) {
const mainArtist = albumArtist || artist;
const safeArtist = sanitize(mainArtist);
const safeAlbum = sanitize(album || 'Singles');
@@ -181,9 +196,20 @@ async function downloadTrack({ jobId, artist, albumArtist, title, album, year, t
return { success: true, dir };
}
// Step 3: download hi-res iTunes artwork
const coverPath = await downloadArtwork(artworkUrl, dir);
const hasCover = !!(coverPath && fs.existsSync(coverPath));
// Step 3: download artwork + fetch lyrics in parallel
const [coverPath, lyricsData] = await Promise.all([
downloadArtwork(artworkUrl, dir),
fetchLyrics(artist, title, album),
]);
const hasCover = !!(coverPath && fs.existsSync(coverPath));
if (lyricsData?.synced) {
const lrcPath = path.join(dir, `${trackPrefix}${safeTitle}.lrc`);
fs.writeFileSync(lrcPath, lyricsData.synced, 'utf8');
log(jobId, '🎵 Synced lyrics gefunden');
} else if (lyricsData?.plain) {
log(jobId, '📝 Lyrics gefunden');
}
log(jobId, `🏷 Metadaten + Cover → ${path.basename(audioFile)}`);
@@ -213,6 +239,8 @@ async function downloadTrack({ jobId, artist, albumArtist, title, album, year, t
if (genre) ffArgs.push('-metadata', `genre=${genre}`);
if (discNum) ffArgs.push('-metadata', `disc=${discTotal ? `${discNum}/${discTotal}` : String(discNum)}`);
if (compilation) ffArgs.push('-metadata', `compilation=1`);
if (explicit) ffArgs.push('-metadata', 'ITUNESADVISORY=1');
if (lyricsData?.plain) ffArgs.push('-metadata', `lyrics=${lyricsData.plain}`);
if (hasCover) {
ffArgs.push(
'-metadata:s:v', 'title=Album cover',
@@ -263,6 +291,7 @@ async function downloadAlbumTracks({ jobId, artist, albumTitle, tracks, artworkU
compilation: track.collectionType === 'Compilation' || undefined,
genre: track.primaryGenreName || undefined,
artworkUrl: artworkUrl || track.artworkUrl100,
explicit: track.trackExplicitness === 'explicit',
outputDir: CONFIG.musicPath,
});
if (result?.skipped) skipped++; else done++;
@@ -278,7 +307,7 @@ async function downloadAlbumTracks({ jobId, artist, albumTitle, tracks, artworkU
async function searchItunesAlbumTracks(artist, album) {
return new Promise(resolve => {
const q = encodeURIComponent(`${artist} ${album}`);
https.get(`https://itunes.apple.com/search?term=${q}&media=music&entity=album&limit=5&country=de`, res => {
https.get(`https://itunes.apple.com/search?term=${q}&media=music&entity=album&limit=5&country=us&explicit=Yes`, res => {
let d = '';
res.on('data', c => d += c);
res.on('end', () => {
@@ -299,7 +328,7 @@ async function searchItunesAlbumTracks(artist, album) {
// Fetch individual tracks for an iTunes collectionId
async function fetchItunesTracks(collectionId) {
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&explicit=Yes`;
https.get(url, res => {
let data = '';
res.on('data', d => data += d);
@@ -336,7 +365,7 @@ app.get('/api/health', (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, explicit } = req.body;
if (!artist || !title) return res.status(400).json({ error: 'artist and title required' });
const jobId = clientJobId || ('job_' + Date.now());
@@ -351,7 +380,7 @@ app.post('/api/download', async (req, res) => {
updateJob(jobId, { status: 'running', progress: 5, message: 'Gestartet…' });
try {
if (type === 'track') {
const result = await downloadTrack({ jobId, artist, title, album, artworkUrl });
const result = await downloadTrack({ jobId, artist, title, album, artworkUrl, explicit: !!explicit });
if (result?.skipped) {
updateJob(jobId, { status: 'done', progress: 100, message: 'Bereits vorhanden – übersprungen', skipped: true });
} else {
+1
View File
@@ -262,6 +262,7 @@ export async function executeDownload() {
artworkUrl: S.downloadTarget.artworkUrl100 || S.downloadTarget.artworkUrl60 || '',
itunesId: S.downloadTarget.trackId || S.downloadTarget.collectionId || null,
collectionId: S.downloadTarget.collectionId || null,
explicit: S.downloadTarget.trackExplicitness === 'explicit',
};
addJob(jobId, payload.title, payload.artist);
try {
+1 -1
View File
@@ -128,7 +128,7 @@ export function makeGlobalTrackItem(t, idx, list) {
el.innerHTML = `
<div class="gtrack-art">${art ? `<img src="${art}" loading="lazy">` : '<div style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:22px">🎵</div>'}</div>
<div class="gtrack-info">
<div class="gtrack-title">${esc(t.trackName||t.collectionName||'—')}</div>
<div class="gtrack-title">${esc(t.trackName||t.collectionName||'—')}${t.trackExplicitness==='explicit'?'<span class="explicit-badge">E</span>':''}</div>
<div class="gtrack-sub">${esc(t.artistName||'—')} · ${esc(t.collectionName||'')} ${t.releaseDate?'· '+new Date(t.releaseDate).getFullYear():''}</div>
</div>
<div class="gtrack-actions">
+2 -1
View File
@@ -123,7 +123,8 @@ nav{flex:1;overflow-y:auto;padding:4px 10px}
.gtrack-item:hover{background:var(--surface)}
.gtrack-art{width:52px;height:52px;border-radius:6px;overflow:hidden;background:var(--surface2);flex-shrink:0}
.gtrack-art img{width:100%;height:100%;object-fit:cover}
.gtrack-info .gtrack-title{font-size:14px;font-weight:600}
.gtrack-info .gtrack-title{font-size:14px;font-weight:600;display:flex;align-items:center;gap:5px}
.explicit-badge{display:inline-flex;align-items:center;justify-content:center;font-size:8px;font-weight:800;width:13px;height:13px;border-radius:2px;background:var(--text3);color:var(--bg);flex-shrink:0;letter-spacing:0}
.gtrack-info .gtrack-sub{font-size:12px;color:var(--text2);margin-top:2px}
.gtrack-actions{display:flex;align-items:center;gap:6px;opacity:0;transition:opacity .15s}
.gtrack-item:hover .gtrack-actions{opacity:1}