feat(web+android+backend): Konzerte gefolgter Kuenstler als zweiter Reiter

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-09-25 19:23:00 +02:00
1 parent 1366ec3832
commit 17a677a5a5
13 files changed
+682 -14

No files matched your search

+128
View File
@@ -0,0 +1,128 @@
// ─── 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<string>}}
*/
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 };
+26
View File
@@ -256,6 +256,32 @@ app.post('/api/artist-releases', async (req, res) => {
}
});
// POST /api/artist-concerts { artists: [{id, name}], days }
// Gegenstueck zu /api/artist-releases: anstehende Termine aller gefolgten Kuenstler.
// Ohne Ortsfilter — Ticketmaster sucht weltweit. Das ist bewusst so (siehe CHANGELOG-Backlog):
// eine Einschraenkung per .env kommt spaeter, fuers Erste reicht, was die API liefert.
app.post('/api/artist-concerts', async (req, res) => {
const key = CONFIG.ticketmasterKey;
if (!key) return res.json({ konzerte: [], ohneTermine: [], keinSchluessel: true });
const artists = Array.isArray(req.body?.artists) ? req.body.artists : [];
const days = Math.min(Math.max(parseInt(req.body?.days, 10) || 365, 1), 365);
if (!artists.length) return res.json({ konzerte: [], ohneTermine: [] });
// Obergrenze, damit ein kaputter Client nicht hunderte Ticketmaster-Aufrufe ausloest.
if (artists.length > 300) return res.status(400).json({ error: 'Zu viele Künstler (max. 300)' });
const sauber = artists.filter(a => a && a.id && a.name)
.map(a => ({ id: String(a.id), name: String(a.name) }));
try {
const { kommendeKonzerte } = require('./lib/konzerte');
res.json(await kommendeKonzerte(sauber, key, days));
} catch (e) {
console.error('[concerts]', e.message);
res.status(502).json({ error: e.message });
}
});
// DELETE /api/artist-overrides/:ndArtistId — remove a pin
app.delete('/api/artist-overrides/:ndArtistId', (req, res) => {
const id = decodeURIComponent(req.params.ndArtistId);
+117
View File
@@ -0,0 +1,117 @@
const { test, beforeEach } = require('node:test');
const assert = require('node:assert/strict');
const https = require('https');
const { EventEmitter } = require('events');
const { kommendeKonzerte, _cache } = require('../lib/konzerte');
// ─── Ticketmaster-Antwort einschieben ────────────────────────────────────────
// Kein Schluessel, kein Netz: https.get wird ersetzt und liefert eine feste Antwort. Geprueft
// wird damit genau das, was uns gehoert — Ortszeit, Gegenprobe des Namens, Fenster, Sortierung.
let _antworten = {};
let _aufrufe = [];
const echtesGet = https.get;
function stub() {
https.get = (url, _opts, cb) => {
_aufrufe.push(url);
const name = decodeURIComponent((url.match(/keyword=([^&]*)/) || [])[1] || '');
const res = new EventEmitter();
res.statusCode = 200;
process.nextTick(() => {
res.emit('data', JSON.stringify(_antworten[name] ?? { _embedded: { events: [] } }));
res.emit('end');
});
cb(res);
return new EventEmitter(); // das Request-Objekt, auf dem .on('error') gesetzt wird
};
}
stub();
process.on('exit', () => { https.get = echtesGet; });
const inTagen = n => {
const d = new Date(Date.now() + n * 864e5);
return d.toISOString().slice(0, 10);
};
beforeEach(() => { _cache.clear(); _aufrufe = []; _antworten = {}; });
const ereignis = (name, tage, extra = {}) => ({
id: 'e' + tage,
name,
dates: { start: { localDate: inTagen(tage), localTime: '20:00:00', dateTime: `${inTagen(tage)}T18:00:00Z` }, ...extra.dates },
_embedded: { venues: [{ name: 'Halle', city: { name: 'Köln' }, country: { name: 'Germany' } }] },
url: 'https://tm.example/' + tage,
...extra,
});
test('Ortszeit schlaegt UTC — 20:00 bleibt 20:00', async () => {
_antworten['Rammstein'] = { _embedded: { events: [ereignis('Rammstein Live', 30)] } };
const { konzerte } = await kommendeKonzerte([{ id: '1', name: 'Rammstein' }], 'K', 365);
assert.equal(konzerte.length, 1);
assert.ok(konzerte[0].datum.endsWith('T20:00:00'), `unerwartet: ${konzerte[0].datum}`);
});
test('Gegenprobe: fremde Veranstaltung mit demselben Stichwort faellt raus', async () => {
_antworten['MORE'] = { _embedded: { events: [
ereignis('MORE Tour 2026', 10),
ereignis('Sing Something MORE Festival', 20), // enthaelt das Wort → bleibt
ereignis('Ganz was anderes', 30), // enthaelt es nicht → faellt raus
] } };
const { konzerte } = await kommendeKonzerte([{ id: '1', name: 'MORE' }], 'K', 365);
assert.deepEqual(konzerte.map(k => k.titel), ['MORE Tour 2026', 'Sing Something MORE Festival']);
});
test('Name im Attraktionsnamen zaehlt auch', async () => {
_antworten['K/DA'] = { _embedded: { events: [
{ ...ereignis('League Music Night', 15), _embedded: {
venues: [{ name: 'Arena', city: { name: 'Berlin' }, country: { name: 'Germany' } }],
attractions: [{ name: 'K/DA' }],
} },
] } };
const { konzerte } = await kommendeKonzerte([{ id: '1', name: 'K/DA' }], 'K', 365);
assert.equal(konzerte.length, 1, 'ueber die Attraktion erkannt');
});
test('Termine hinter dem Fenster fallen weg', async () => {
_antworten['Coldplay'] = { _embedded: { events: [ereignis('Coldplay Live', 20), ereignis('Coldplay Later', 200)] } };
const { konzerte } = await kommendeKonzerte([{ id: '1', name: 'Coldplay' }], 'K', 30);
assert.equal(konzerte.length, 1);
assert.equal(konzerte[0].titel, 'Coldplay Live');
});
test('Sortierung laeuft NACH VORN — naechstes Konzert zuerst', async () => {
_antworten['A'] = { _embedded: { events: [ereignis('A spaet', 90)] } };
_antworten['B'] = { _embedded: { events: [ereignis('B frueh', 5)] } };
const { konzerte } = await kommendeKonzerte([{ id: '1', name: 'A' }, { id: '2', name: 'B' }], 'K', 365);
assert.deepEqual(konzerte.map(k => k.titel), ['B frueh', 'A spaet']);
});
test('Kuenstler ohne Termine werden gemeldet, nicht verschwiegen', async () => {
_antworten['Leer'] = { _embedded: { events: [] } };
const { konzerte, ohneTermine } = await kommendeKonzerte([{ id: '1', name: 'Leer' }], 'K', 365);
assert.equal(konzerte.length, 0);
assert.deepEqual(ohneTermine, ['Leer']);
});
test('Zweiter Aufruf kommt aus dem Cache — kein weiterer Abruf', async () => {
_antworten['Rammstein'] = { _embedded: { events: [ereignis('Rammstein Live', 30)] } };
await kommendeKonzerte([{ id: '1', name: 'Rammstein' }], 'K', 365);
const nachErstem = _aufrufe.length;
await kommendeKonzerte([{ id: '1', name: 'Rammstein' }], 'K', 365);
assert.equal(_aufrufe.length, nachErstem, 'zweiter Aufruf darf nicht erneut abrufen');
});
test('Abgesagt wird durchgereicht statt stillschweigend gezeigt', async () => {
_antworten['X'] = { _embedded: { events: [
{ ...ereignis('X Tour', 40), dates: { start: { localDate: inTagen(40), localTime: '20:00:00' }, status: { code: 'cancelled' } } },
] } };
const { konzerte } = await kommendeKonzerte([{ id: '1', name: 'X' }], 'K', 365);
assert.equal(konzerte[0].abgesagt, true);
});
test('Ein Kuenstlername ohne Buchstaben loest keinen Abruf aus', async () => {
const { konzerte } = await kommendeKonzerte([{ id: '1', name: '???' }], 'K', 365);
assert.equal(konzerte.length, 0);
assert.equal(_aufrufe.length, 0);
});