// ─── KONZERTE GEFOLGTER KÜNSTLER ────────────────────────────────────────────── // Liefert zu einer Liste von Navidrome-Künstlern die anstehenden Termine, damit die App den // Reiter "Konzerte" in der Zeitleiste füllen kann. Gegenstück zu releases.js. // // Warum serverseitig: bei 50 gefolgten Künstlern wären das 50 Ticketmaster-Aufrufe — pro Gerät und // pro App-Start. Der Cache liegt deshalb hier und gilt für alle Clients gemeinsam. // // Bewusste Einschränkung (25.9.2026): gesucht wird über den NAMEN (`keyword=`), nicht über // Ticketmasters `attractionId`. Das ist dieselbe Unschärfe wie bei der Deezer-Zuordnung — ein // kurzer Künstlername trifft als Stichwort auch Fremdes. Deshalb wird jeder Treffer gegengeprüft: // der Name muss im Veranstaltungs- oder Attraktionsnamen vorkommen. Wer es genauer braucht, löst // später die attractionId einmal pro Künstler auf (siehe Backlog im CHANGELOG). const https = require('https'); const _cache = new Map(); // nameLower → { ts, termine } const TTL = 6 * 60 * 60 * 1000; // 6 h — Termine ändern sich nicht minütlich const GLEICHZEITIG = 4; // höflich gegenüber Ticketmaster, trotzdem zügig const norm = s => (s || '').toLowerCase().normalize('NFD').replace(/\p{M}+/gu, '') .replace(/[^\p{L}\p{N}]+/gu, ' ').trim(); function holen(url) { return new Promise((resolve, reject) => { https.get(url, { headers: { Accept: 'application/json' } }, up => { let body = ''; up.on('data', d => body += d); up.on('end', () => { try { const data = JSON.parse(body); if (up.statusCode !== 200 || data.fault || data.errors) { return reject(new Error(`Ticketmaster ${up.statusCode}`)); } resolve(data); } catch (e) { reject(e); } }); }).on('error', reject); }); } /** Anstehende Termine eines Künstlers, gecacht. Bei einem Fehler kommt der alte Stand zurück. */ async function termineFuer(name, apiKey) { const key = norm(name); if (!key) return []; const c = _cache.get(key); if (c && Date.now() - c.ts < TTL) return c.termine; const ab = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'); const url = 'https://app.ticketmaster.com/discovery/v2/events.json' + `?keyword=${encodeURIComponent(name)}&classificationName=music` + `&startDateTime=${ab}&size=20&sort=date,asc&apikey=${apiKey}`; try { const data = await holen(url); const roh = data?._embedded?.events || []; const termine = roh.map(e => { const ort = e._embedded?.venues?.[0] || {}; const start = e.dates?.start || {}; // Die ORTSZEIT nehmen, nicht die UTC-Zeit: ein Konzert um 20:00 sonst als 08:00Z angezeigt. // Als naive lokale Zeichenkette ohne Zone weitergereicht, die Clients zeigen sie unverändert. const zeit = (start.localDate ? `${start.localDate}T${start.localTime || '00:00:00'}` : start.dateTime) || null; const aus = e.dates?.status?.code === 'offsale' || e.dates?.status?.code === 'cancelled'; const attraktionen = (e._embedded?.attractions || []).map(a => a.name || ''); return { eventId: e.id || '', titel: e.name || '', datum: zeit, ort: ort.name || '', stadt: ort.city?.name || '', land: ort.country?.name || '', url: e.url || '', abgesagt: aus, _namen: [e.name || '', ...attraktionen], }; }).filter(t => t.datum); // Gegenprobe zur Stichwortsuche: der Künstlername muss irgendwo im Veranstaltungs- oder // Attraktionsnamen stehen. Ohne das liefert "MORE" oder "TPR" fremde Veranstaltungen. const gesucht = key; const gefiltert = termine.filter(t => t._namen.some(n => norm(n).includes(gesucht))); const sauber = gefiltert.map(({ _namen, ...rest }) => rest); _cache.set(key, { ts: Date.now(), termine: sauber }); return sauber; } catch (e) { // Bei einem Fehler den abgelaufenen Stand ausliefern statt einer leeren Liste — aber den // Zeitstempel NICHT auffrischen, damit er beim nächsten Mal wieder frisch geholt wird. if (c) return c.termine; throw e; } } async function inHaeppchen(items, groesse, fn) { const raus = []; for (let i = 0; i < items.length; i += groesse) { raus.push(...await Promise.all(items.slice(i, i + groesse).map(fn))); } return raus; } /** * @param {Array<{id,name}>} kuenstler Navidrome-Künstler * @param {string} apiKey Ticketmaster-Schlüssel * @param {number} tage Zeitfenster nach vorn * @returns {{konzerte:Array, ohneTermine:Array}} */ async function kommendeKonzerte(kuenstler, apiKey, tage = 365) { const grenze = Date.now() + tage * 24 * 60 * 60 * 1000; const ohneTermine = []; const proKuenstler = await inHaeppchen(kuenstler, GLEICHZEITIG, async (k) => { let termine = []; try { termine = await termineFuer(k.name, apiKey); } catch { return []; } if (!termine.length) { ohneTermine.push(k.name); return []; } return termine .filter(t => new Date(t.datum).getTime() <= grenze) .map(t => ({ artistId: k.id, artistName: k.name, ...t })); }); // Nach vorn sortiert: das naechste Konzert zuerst — anders als bei den Veroeffentlichungen, // die rueckwaerts laufen. const konzerte = proKuenstler.flat() .sort((a, b) => new Date(a.datum) - new Date(b.datum)); return { konzerte, ohneTermine }; } module.exports = { kommendeKonzerte, termineFuer, _cache };