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:
1 parent
3c5048f017
commit
93b859bf0e
4 files changed
+41
-10
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user