Files
ripster/backend/src/services/musicBrainzService.js
2026-03-12 10:15:50 +00:00

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();