131 lines
5.8 KiB
JavaScript
131 lines
5.8 KiB
JavaScript
// ─── 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; 0 = kein Fenster (Vorgabe)
|
|
* @returns {{konzerte:Array, ohneTermine:Array<string>}}
|
|
*/
|
|
async function kommendeKonzerte(kuenstler, apiKey, tage = 0) {
|
|
// Kein Fenster als Vorgabe: Ticketmaster liefert ohnehin nur Anstehendes, und bei Konzerten
|
|
// will man ALLES sehen, was angekuendigt ist — eine Tagesgrenze schneidet nur willkuerlich ab.
|
|
const grenze = tage > 0 ? Date.now() + tage * 24 * 60 * 60 * 1000 : Infinity;
|
|
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 };
|