Files
NovaSonic/backend/lib/metadata.js
T
HoneyandClaude Sonnet 4.6 7ba5b8ce9a feat: artist in filename, hide album=title subtitle, remove title hover
Naming convention (download + edit-metadata):
  Before: {01 - }Title.ext
  After:  {01 - }Artist1, Artist2 - Title.ext
- ytdlp.js: builds fileBase with artist prefix; findExistingTrack checks
  both new and old format so existing files aren't re-downloaded
- metadata.js: findExistingTrack accepts optional safeArtist param
- novasonic-server.js: file rename on title edit now includes artist
  (falls back to Navidrome's stored artist if not provided in body)

Song list subtitle: hide album name when album === title (singles where
the album is named after the track appear to duplicate the title)

Player bar: removed .player-title:hover underline/colour effect

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 22:33:35 +02:00

168 lines
6.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const https = require('https');
const path = require('path');
const fs = require('fs');
function sanitize(s) {
return (s || 'Unknown').replace(/[\\/:*?"<>|]/g, '_').trim().slice(0, 100);
}
function navidromeArtist(name) {
if (!name) return name;
return name
.replace(/\s+feat\.?\s+/gi, ';')
.replace(/\s+ft\.?\s+/gi, ';')
.replace(/\s+featuring\s+/gi, ';')
.replace(/\s+&\s+/g, ';')
.replace(/,\s+/g, ';');
}
// Download hi-res iTunes artwork (1000x1000) to cover.jpg, return path or null
async function downloadArtwork(url, dir) {
if (!url) return null;
const dest = path.join(dir, 'cover.jpg');
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);
});
}
const AUDIO_EXTS = ['mp3', 'flac', 'opus', 'm4a', 'ogg', 'wav'];
function findExistingTrack(dir, trackPrefix, safeTitle, safeArtist) {
// Check new format "{prefix}{artist} - {title}.ext" first, then old format "{prefix}{title}.ext"
const names = safeArtist
? [`${trackPrefix}${safeArtist} - ${safeTitle}`, `${trackPrefix}${safeTitle}`]
: [`${trackPrefix}${safeTitle}`];
return AUDIO_EXTS.flatMap(ext => names.map(n => path.join(dir, `${n}.${ext}`)))
.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; }
}
// Normalize a track title for comparison: unify variant suffixes so that
// "don't rush - slowed" and "don't rush (slowed)" are treated as equal.
function normalizeTitle(s) {
return (s || '')
.toLowerCase()
.trim()
// convert "title - variant" → "title (variant)" so both forms compare equal
.replace(/\s+-\s+([^-]+)$/, ' ($1)')
// strip all remaining parens/brackets content for the base comparison
.replace(/[\[((][^\]))]*[\]))]/g, '')
.trim();
}
// iTunes track lookup — fills in album, year, trackNum, totalTracks, genre when missing
async function lookupItunesTrackMeta(artist, title) {
try {
// Search with base title (strip variant suffix) for better iTunes hit rate —
// e.g. "don't rush - slowed" → search "don't rush" so iTunes finds the single
const baseTitle = title.replace(/\s*[-–]\s*\S[^-]*$/, '').trim() || title;
const searchTitle = baseTitle !== title ? baseTitle : title;
const url = `https://itunes.apple.com/search?term=${encodeURIComponent(artist + ' ' + searchTitle)}&media=music&entity=musicTrack&limit=10&country=us&explicit=Yes`;
const results = await new Promise((resolve, reject) => {
https.get(url, 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 normT = normalizeTitle(title);
const normA = (artist || '').toLowerCase().trim();
// Split by space OR comma/semicolon/slash so "VXLLAIN, iGRES" → "vxllain" not "vxllain,"
const firstWord = s => (s || '').toLowerCase().split(/[\s,;/]/)[0];
const match = results.find(t =>
normalizeTitle(t.trackName) === normT &&
firstWord(t.artistName) === firstWord(normA)
);
if (!match) return {};
return {
album: match.collectionName || '',
year: match.releaseDate ? new Date(match.releaseDate).getFullYear() : undefined,
trackNum: match.trackNumber || undefined,
totalTracks: match.trackCount || undefined,
discNum: match.discNumber || undefined,
discTotal: match.discCount || undefined,
genre: match.primaryGenreName || undefined,
artworkUrl: (match.artworkUrl100 || '').replace('100x100bb', '1000x1000bb'),
durationMs: match.trackTimeMillis || undefined,
};
} catch { return {}; }
}
// 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&explicit=Yes`;
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);
});
}
// Search iTunes for album tracks by artist + album name (fallback when no collectionId)
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=us&explicit=Yes`, res => {
let d = '';
res.on('data', c => d += c);
res.on('end', () => {
try {
const results = JSON.parse(d).results || [];
const match = results.find(r =>
r.collectionName?.toLowerCase().includes(album.toLowerCase()) ||
r.artistName?.toLowerCase().includes(artist.toLowerCase())
);
if (match) fetchItunesTracks(match.collectionId).then(resolve).catch(() => resolve([]));
else resolve([]);
} catch { resolve([]); }
});
}).on('error', () => resolve([]));
});
}
module.exports = {
sanitize, navidromeArtist,
downloadArtwork, findExistingTrack,
lookupItunesTrackMeta, fetchItunesTracks, searchItunesAlbumTracks,
};