170 lines
5.5 KiB
JavaScript
170 lines
5.5 KiB
JavaScript
const settingsService = require('./settingsService');
|
|
const logger = require('./logger').child('MUSICBRAINZ');
|
|
|
|
const MB_BASE = 'https://musicbrainz.org/ws/2';
|
|
const MB_USER_AGENT = 'Ripster/1.0 (https://github.com/ripster)';
|
|
const MB_TIMEOUT_MS = 10000;
|
|
|
|
async function mbFetch(url) {
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), MB_TIMEOUT_MS);
|
|
try {
|
|
const response = await fetch(url, {
|
|
headers: {
|
|
'Accept': 'application/json',
|
|
'User-Agent': MB_USER_AGENT
|
|
},
|
|
signal: controller.signal
|
|
});
|
|
clearTimeout(timer);
|
|
if (!response.ok) {
|
|
throw new Error(`MusicBrainz Anfrage fehlgeschlagen (${response.status})`);
|
|
}
|
|
return response.json();
|
|
} catch (error) {
|
|
clearTimeout(timer);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function normalizeRelease(release) {
|
|
if (!release) {
|
|
return null;
|
|
}
|
|
const artistCredit = Array.isArray(release['artist-credit'])
|
|
? release['artist-credit'].map((ac) => ac?.artist?.name || ac?.name || '').filter(Boolean).join(', ')
|
|
: null;
|
|
const date = String(release.date || '').trim();
|
|
const yearMatch = date.match(/\b(\d{4})\b/);
|
|
const year = yearMatch ? Number(yearMatch[1]) : null;
|
|
|
|
const media = Array.isArray(release.media) ? release.media : [];
|
|
const normalizedTracks = media.flatMap((medium, mediumIdx) => {
|
|
const mediumTracks = Array.isArray(medium.tracks) ? medium.tracks : [];
|
|
return mediumTracks.map((track, trackIdx) => {
|
|
const rawPosition = String(track.position || track.number || '').trim();
|
|
const parsedPosition = Number.parseInt(rawPosition, 10);
|
|
const fallbackPosition = mediumIdx * 100 + trackIdx + 1;
|
|
const position = Number.isFinite(parsedPosition) && parsedPosition > 0
|
|
? parsedPosition
|
|
: fallbackPosition;
|
|
return {
|
|
position,
|
|
number: String(track.number || track.position || ''),
|
|
title: String(track.title || ''),
|
|
durationMs: Number(track.length || 0) || null,
|
|
rawTrackArtistCredit: Array.isArray(track['artist-credit']) ? track['artist-credit'] : [],
|
|
rawRecordingArtistCredit: Array.isArray(track?.recording?.['artist-credit']) ? track.recording['artist-credit'] : []
|
|
};
|
|
});
|
|
}).map((track) => {
|
|
const trackArtistCredit = Array.isArray(track?.rawTrackArtistCredit)
|
|
? track.rawTrackArtistCredit
|
|
: [];
|
|
const recordingArtistCredit = Array.isArray(track?.rawRecordingArtistCredit)
|
|
? track.rawRecordingArtistCredit
|
|
: [];
|
|
const artistFromTrack = trackArtistCredit.map((ac) => ac?.artist?.name || ac?.name || '').filter(Boolean).join(', ');
|
|
const artistFromRecording = recordingArtistCredit.map((ac) => ac?.artist?.name || ac?.name || '').filter(Boolean).join(', ');
|
|
return {
|
|
position: track.position,
|
|
number: track.number,
|
|
title: track.title,
|
|
durationMs: track.durationMs,
|
|
artist: artistFromTrack || artistFromRecording || artistCredit || null
|
|
};
|
|
});
|
|
|
|
// Always generate the CAA URL when an id is present; the browser/onError
|
|
// handles 404s for releases that have no front cover.
|
|
const coverArtUrl = release.id
|
|
? `https://coverartarchive.org/release/${release.id}/front-250`
|
|
: null;
|
|
|
|
return {
|
|
mbId: String(release.id || ''),
|
|
title: String(release.title || ''),
|
|
artist: artistCredit || null,
|
|
year,
|
|
date,
|
|
country: String(release.country || '').trim() || null,
|
|
label: Array.isArray(release['label-info'])
|
|
? release['label-info'].map((li) => li?.label?.name).filter(Boolean).join(', ') || null
|
|
: null,
|
|
coverArtUrl,
|
|
tracks: normalizedTracks
|
|
};
|
|
}
|
|
|
|
class MusicBrainzService {
|
|
async isEnabled() {
|
|
const settings = await settingsService.getSettingsMap();
|
|
return settings.musicbrainz_enabled !== 'false';
|
|
}
|
|
|
|
async searchByTitle(query) {
|
|
const q = String(query || '').trim();
|
|
if (!q) {
|
|
return [];
|
|
}
|
|
|
|
const enabled = await this.isEnabled();
|
|
if (!enabled) {
|
|
return [];
|
|
}
|
|
|
|
logger.info('search:start', { query: q });
|
|
|
|
const url = new URL(`${MB_BASE}/release`);
|
|
url.searchParams.set('query', q);
|
|
url.searchParams.set('fmt', 'json');
|
|
url.searchParams.set('limit', '10');
|
|
url.searchParams.set('inc', 'artist-credits+labels+recordings+media');
|
|
|
|
try {
|
|
const data = await mbFetch(url.toString());
|
|
const releases = Array.isArray(data.releases) ? data.releases : [];
|
|
const results = releases.map(normalizeRelease).filter(Boolean);
|
|
logger.info('search:done', { query: q, count: results.length });
|
|
return results;
|
|
} catch (error) {
|
|
logger.warn('search:failed', { query: q, error: String(error?.message || error) });
|
|
return [];
|
|
}
|
|
}
|
|
|
|
async searchByDiscLabel(discLabel) {
|
|
return this.searchByTitle(discLabel);
|
|
}
|
|
|
|
async getReleaseById(mbId) {
|
|
const id = String(mbId || '').trim();
|
|
if (!id) {
|
|
return null;
|
|
}
|
|
|
|
const enabled = await this.isEnabled();
|
|
if (!enabled) {
|
|
return null;
|
|
}
|
|
|
|
logger.info('getById:start', { mbId: id });
|
|
|
|
const url = new URL(`${MB_BASE}/release/${id}`);
|
|
url.searchParams.set('fmt', 'json');
|
|
url.searchParams.set('inc', 'artist-credits+labels+recordings+media');
|
|
|
|
try {
|
|
const data = await mbFetch(url.toString());
|
|
const result = normalizeRelease(data);
|
|
logger.info('getById:done', { mbId: id, title: result?.title });
|
|
return result;
|
|
} catch (error) {
|
|
logger.warn('getById:failed', { mbId: id, error: String(error?.message || error) });
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
|
|
module.exports = new MusicBrainzService();
|