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