From 17a677a5a566ee1235214a8a267efa8f401d7316 Mon Sep 17 00:00:00 2001 From: marc Date: Fri, 25 Sep 2026 19:23:00 +0200 Subject: [PATCH] feat(web+android+backend): Konzerte gefolgter Kuenstler als zweiter Reiter Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 27 +++- android/app/build.gradle.kts | 4 +- .../com/novasonic/app/api/ReleasesClient.kt | 11 ++ .../novasonic/app/api/model/ReleaseModels.kt | 28 ++++ .../app/ui/screen/ArtistNewsScreen.kt | 141 ++++++++++++++++- .../app/viewmodel/ArtistNewsViewModel.kt | 47 +++++- backend/lib/konzerte.js | 128 ++++++++++++++++ backend/novasonic-server.js | 26 ++++ backend/test/konzerte.test.js | 117 +++++++++++++++ index.html | 13 +- src/main.js | 2 + src/views/artistNews.js | 142 +++++++++++++++++- styles.css | 10 ++ 13 files changed, 682 insertions(+), 14 deletions(-) create mode 100644 backend/lib/konzerte.js create mode 100644 backend/test/konzerte.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 8815e6f..4211fe5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,7 @@ Die Android-App zählt eigenständig weiter (mehrere APKs pro Release). Welche d | Besitzer geteilter Playlists | ✅ | ✅ | | Künstler folgen / Album merken | ✅ | ✅ | | Neu von deinen Künstlern | ✅ | ✅ | +| Konzerte deiner Künstler | ✅ | ✅ | | Spotify / YouTube importer | ✅ | ✅ | | Musik-Duell (Anzeigetafel) | ✅ | ✅ | | Wiedergabe fortsetzen | ✅ | ✅ | @@ -80,9 +81,33 @@ Die Android-App zählt eigenständig weiter (mehrere APKs pro Release). Welche d ## [3.1] — 2026-09-22 -**Android:** 1.15 (versionCode 18) → 1.23 (versionCode 26). 1.16 brachte den Rollbalken, 1.17 bis 1.21 machten ihn bedienbar, 1.19 das Bearbeiten der Warteschlange, 1.22 die Wischgeste, den Mischen-Fehler und die zwei neuen Designs, 1.23 den Honigtopf und die Gesamtdauer. +**Android:** 1.15 (versionCode 18) → 1.23 (versionCode 26). 1.16 brachte den Rollbalken, 1.17 bis 1.21 machten ihn bedienbar, 1.19 das Bearbeiten der Warteschlange, 1.22 die Wischgeste, den Mischen-Fehler und die zwei neuen Designs, 1.23 den Honigtopf und die Gesamtdauer, 1.24 die Konzerte. ### Added +- **Konzerte deiner Künstler (Web + Android)** — die Zeitleiste hat einen zweiten Reiter: neben + *Neu erschienen* steht jetzt *Konzerte* und zeigt, wann die Künstler spielen, denen du folgst. + Neuer Endpunkt `POST /api/artist-concerts`, Gegenstück zu `/api/artist-releases`. + - **Zwei Reiter statt zweier Knöpfe** in den Favoriten. Beides ist dasselbe Thema; zwei Knöpfe + drängeln sich, und man müsste sich merken, welcher was zeigt. + - **Bewusst nicht in eine gemeinsame Liste gemischt.** Veröffentlichungen liegen hinter dir, + Konzerte vor dir. Chronologisch gemischt stünde „heute" mittendrin und man wüsste nie, ob man + vorwärts oder rückwärts liest. Deshalb läuft die Konzertliste **nach vorn**: das nächste + Konzert zuerst. Das Zeitfenster (90/30/365) gilt je Reiter getrennt — bei den Konzerten zeigt + es nach vorn, Vorgabe ein Jahr. + - **Gegenprobe gegen die Stichwortsuche.** Ticketmaster wird über den Namen abgefragt + (`keyword=`), nicht über `attractionId` — dieselbe Unschärfe wie bei der Deezer-Zuordnung. + Jeder Treffer wird deshalb gegengeprüft: der Künstlername muss im Veranstaltungs- **oder** + Attraktionsnamen vorkommen. Ohne das liefert „MORE" oder „TPR" fremde Veranstaltungen. + - **Ortszeit, nicht UTC.** Ein Konzert um 20:00 stünde sonst als 08:00 da. Die Zeit wird als + zonenlose Ortszeit durchgereicht und unverändert angezeigt. + - **Kein Ortsfilter** — die Suche läuft weltweit. Eine Einschränkung per `.env` ist im Backlog + notiert; fürs Erste reicht, was die API liefert. + - Der Cache liegt im Backend (6 h, höchstens vier Anfragen gleichzeitig), nicht im Client: bei + 50 gefolgten Künstlern wären das sonst 50 Abfragen pro Gerät und App-Start. Bei einem Fehler + kommt der abgelaufene Stand zurück statt einer leeren Liste. + - **Neun Prüfungen** in `backend/test/konzerte.test.js` — mit eingeschobener + Ticketmaster-Antwort, also ohne Schlüssel und ohne Netz: Ortszeit, Gegenprobe, Fenster, + Sortierung nach vorn, Cache-Treffer, abgesagte Termine. - **Zwei neue Designs: Violet und Crimson (Web + Android)** — ein helles Lila auf tiefem Violettschwarz und ein gedämpftes Rot auf Weinrot-Schwarz. Beide mit demselben Variablensatz wie die vorhandenen, geprüft: kein Design vermisst eine Variable. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index dcd27d0..8e37601 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -14,8 +14,8 @@ android { applicationId = "com.novasonic.app" minSdk = 26 targetSdk = 35 - versionCode = 26 - versionName = "1.23" + versionCode = 27 + versionName = "1.24" // Host der eigenen NovaSonic-Web-App — Ziel der geteilten Links. Einmal in // gradle.properties setzen (novasonicHost=music.example.com); ohne Angabe bleibt der // Platzhalter stehen und der App-Link greift schlicht nicht. diff --git a/android/app/src/main/kotlin/com/novasonic/app/api/ReleasesClient.kt b/android/app/src/main/kotlin/com/novasonic/app/api/ReleasesClient.kt index bb3ace8..9e0980e 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/api/ReleasesClient.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/api/ReleasesClient.kt @@ -2,6 +2,8 @@ package com.novasonic.app.api import com.novasonic.app.api.model.ArtistRef import com.novasonic.app.api.model.ArtistReleasesRequest +import com.novasonic.app.api.model.ArtistKonzerteRequest +import com.novasonic.app.api.model.ArtistKonzerteResponse import com.novasonic.app.api.model.ArtistReleasesResponse import io.ktor.client.* import io.ktor.client.call.* @@ -36,4 +38,13 @@ object ReleasesClient { setBody(ArtistReleasesRequest(artists, days)) }.body() } + + /** Anstehende Konzerte derselben Kuenstler — gleicher Weg, anderer Endpunkt. */ + suspend fun konzerte(backendUrl: String, artists: List, days: Int): ArtistKonzerteResponse { + if (backendUrl.isBlank() || artists.isEmpty()) return ArtistKonzerteResponse() + return http.post("${backendUrl.trimEnd('/')}/api/artist-concerts") { + contentType(ContentType.Application.Json) + setBody(ArtistKonzerteRequest(artists, days)) + }.body() + } } diff --git a/android/app/src/main/kotlin/com/novasonic/app/api/model/ReleaseModels.kt b/android/app/src/main/kotlin/com/novasonic/app/api/model/ReleaseModels.kt index 1ea8194..4fd9e59 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/api/model/ReleaseModels.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/api/model/ReleaseModels.kt @@ -29,3 +29,31 @@ data class ArtistRef(val id: String, val name: String) @Serializable data class ArtistReleasesRequest(val artists: List, val days: Int) + +/** Ein anstehendes Konzert eines gefolgten Künstlers. */ +@Serializable +data class ArtistKonzert( + val artistId: String = "", + val artistName: String = "", + val eventId: String = "", + val titel: String = "", + /** Ortszeit ohne Zone, z. B. "2026-11-04T20:00:00" — bewusst nicht UTC. */ + val datum: String = "", + val ort: String = "", + val stadt: String = "", + val land: String = "", + val url: String = "", + val abgesagt: Boolean = false, +) + +@Serializable +data class ArtistKonzerteResponse( + val konzerte: List = emptyList(), + /** Künstler ohne anstehende Termine — nur zur Erklärung einer leeren Liste. */ + val ohneTermine: List = emptyList(), + /** Im Backend ist kein Ticketmaster-Schlüssel hinterlegt. */ + val keinSchluessel: Boolean = false, +) + +@Serializable +data class ArtistKonzerteRequest(val artists: List, val days: Int) diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/ArtistNewsScreen.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/ArtistNewsScreen.kt index c854e52..d7c843f 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/ArtistNewsScreen.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/ArtistNewsScreen.kt @@ -30,8 +30,13 @@ import coil.compose.AsyncImage import com.novasonic.app.api.model.ArtistRelease import com.novasonic.app.ui.theme.* import com.novasonic.app.viewmodel.ArtistNewsViewModel +import com.novasonic.app.viewmodel.NewsReiter +import com.novasonic.app.api.model.ArtistKonzert +import androidx.compose.ui.platform.LocalUriHandler import java.text.SimpleDateFormat import java.util.Locale +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.text.style.TextDecoration /** * Zeitleiste der jüngsten Veröffentlichungen aller Künstler, denen man folgt. Nach Monat gruppiert, @@ -55,19 +60,48 @@ fun ArtistNewsScreen( Icon(Icons.AutoMirrored.Filled.ArrowBack, "Zurück", tint = MaterialTheme.colorScheme.onSurface) } - Text("Neu von deinen Künstlern", fontFamily = Outfit, fontWeight = FontWeight.Bold, - fontSize = 17.sp, color = MaterialTheme.colorScheme.onSurface) + val konzerte = state.reiter == NewsReiter.KONZERTE + Text( + if (konzerte) "Konzerte deiner Künstler" else "Neu von deinen Künstlern", + fontFamily = Outfit, fontWeight = FontWeight.Bold, + fontSize = 17.sp, color = MaterialTheme.colorScheme.onSurface, + ) + } + + // Zwei Reiter statt zweier Knoepfe in den Favoriten: Neuheiten und Konzerte sind dasselbe + // Thema. Bewusst NICHT in eine gemeinsame Liste gemischt — Veroeffentlichungen liegen + // hinter, Konzerte vor dem heutigen Tag. + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(start = 16.dp, top = 4.dp), + ) { + listOf( + NewsReiter.RELEASES to "Neu erschienen", + NewsReiter.KONZERTE to "Konzerte", + ).forEach { (r, label) -> + FilterChip( + selected = state.reiter == r, + onClick = { vm.zeigeReiter(r) }, + label = { Text(label, fontSize = 12.sp) }, + colors = FilterChipDefaults.filterChipColors( + selectedContainerColor = NsAccent.copy(alpha = 0.2f), + selectedLabelColor = NsAccent, + ), + ) + } } Row( horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.padding(start = 16.dp, top = 4.dp, bottom = 10.dp), + modifier = Modifier.padding(start = 16.dp, top = 8.dp, bottom = 10.dp), ) { // 90 Tage zuerst, weil es die Vorgabe ist — der aktive Chip steht dann vorn. + // Bei den Konzerten zeigt dasselbe Fenster nach VORN, dort sind 1 Jahr die Vorgabe. + val aktuell = if (state.reiter == NewsReiter.KONZERTE) state.tageKonzerte else state.tage listOf(90 to "90 Tage", 30 to "30 Tage", 365 to "1 Jahr").forEach { (tage, label) -> FilterChip( - selected = state.tage == tage, - onClick = { vm.laden(tage) }, + selected = aktuell == tage, + onClick = { vm.setzeTage(tage) }, label = { Text(label, fontSize = 12.sp) }, colors = FilterChipDefaults.filterChipColors( selectedContainerColor = NsAccent.copy(alpha = 0.2f), @@ -87,6 +121,30 @@ fun ArtistNewsScreen( state.keineGefolgten -> Hinweis("❤", "Du folgst noch niemandem", "Auf einer Künstlerseite oben rechts aufs Herz tippen.") state.error != null -> Hinweis("⚠", "Laden fehlgeschlagen", state.error!!) + + state.reiter == NewsReiter.KONZERTE && state.keinSchluessel -> Hinweis("🎟", + "Kein Ticketmaster-Schlüssel hinterlegt", "Im Backend TICKETMASTER_API_KEY setzen.") + state.reiter == NewsReiter.KONZERTE && state.konzerte.isEmpty() -> Hinweis("🎪", + "Keine Termine in den nächsten ${state.tageKonzerte} Tagen", + "Gesucht wird weltweit über den Künstlernamen.") + state.reiter == NewsReiter.KONZERTE -> LazyColumn(contentPadding = PaddingValues(bottom = 90.dp)) { + var letzterMonat = "" + state.konzerte.forEach { k -> + val monat = monatVon(k.datum) + if (monat != letzterMonat) { + letzterMonat = monat + item(key = "km_$monat") { + Text(monat, fontFamily = Outfit, fontWeight = FontWeight.SemiBold, + fontSize = 13.sp, color = NsOnSurface2, + modifier = Modifier.padding(start = 20.dp, top = 16.dp, bottom = 6.dp)) + } + } + item(key = "k_${k.eventId}_${k.artistId}") { + KonzertZeile(k) { onArtistClick(k.artistId) } + } + } + } + state.releases.isEmpty() -> Hinweis("🌙", "Nichts Neues in den letzten ${state.tage} Tagen", "") else -> LazyColumn(contentPadding = PaddingValues(bottom = 90.dp)) { @@ -215,3 +273,76 @@ private val DATUM = SimpleDateFormat("dd.MM.yyyy", Locale.GERMAN) private fun monatVon(s: String) = runCatching { MONAT.format(EIN.parse(s)!!) }.getOrDefault(s) private fun datumVon(s: String) = runCatching { DATUM.format(EIN.parse(s)!!) }.getOrDefault(s) + +// Ticketmaster liefert die ORTSZEIT ohne Zone: "2026-11-04T20:00:00". +private val EIN_ZEIT = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.GERMAN) +private val TAG_KURZ = SimpleDateFormat("d", Locale.GERMAN) +private val MON_KURZ = SimpleDateFormat("MMM", Locale.GERMAN) +private val UHR = SimpleDateFormat("HH:mm", Locale.GERMAN) + +private fun zeitVon(s: String) = runCatching { EIN_ZEIT.parse(s) }.getOrNull() + +/** + * Eine Konzertzeile. Statt eines Covers steht links ein Kalenderblatt — ein Konzert hat kein Bild, + * aber ein Datum, und danach sucht das Auge in einer Liste, die nach vorn läuft. + */ +@Composable +private fun KonzertZeile(k: ArtistKonzert, onArtist: () -> Unit) { + val uri = LocalUriHandler.current + val d = zeitVon(k.datum) + val ort = listOf(k.ort, k.stadt, k.land).filter { it.isNotBlank() }.joinToString(" · ") + val uhr = d?.let { UHR.format(it) }?.takeIf { it != "00:00" } + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 6.dp), + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = Modifier + .size(50.dp) + .clip(RoundedCornerShape(10.dp)) + .background(NsSurface) + .border(1.dp, MaterialTheme.colorScheme.outline, RoundedCornerShape(10.dp)) + .alpha(if (k.abgesagt) 0.45f else 1f), + ) { + Text(d?.let { TAG_KURZ.format(it) } ?: "?", fontFamily = Outfit, + fontWeight = FontWeight.Bold, fontSize = 17.sp, + color = MaterialTheme.colorScheme.onSurface) + Text(d?.let { MON_KURZ.format(it) }?.uppercase() ?: "", fontFamily = Outfit, + fontSize = 10.sp, color = NsOnSurface2) + } + Spacer(Modifier.width(12.dp)) + Column(Modifier.weight(1f)) { + Text( + k.titel.ifBlank { k.artistName }, + fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 14.sp, + color = if (k.abgesagt) NsOnSurface2 else MaterialTheme.colorScheme.onSurface, + maxLines = 1, overflow = TextOverflow.Ellipsis, + textDecoration = if (k.abgesagt) TextDecoration.LineThrough else null, + ) + Spacer(Modifier.height(2.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Text(k.artistName, fontFamily = Outfit, fontSize = 12.sp, color = NsAccent, + maxLines = 1, overflow = TextOverflow.Ellipsis, + modifier = Modifier.clickable(onClick = onArtist)) + val rest = listOfNotNull(ort.ifBlank { null }, uhr?.let { "$it Uhr" }) + if (rest.isNotEmpty()) { + Text(" · " + rest.joinToString(" · "), fontFamily = Outfit, fontSize = 12.sp, + color = NsOnSurface2, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + } + if (k.abgesagt) { + Text("abgesagt", fontFamily = Outfit, fontSize = 11.sp, color = NsOnSurface2) + } else if (k.url.isNotBlank()) { + TextButton(onClick = { uri.openUri(k.url) }) { + Text("Tickets", fontFamily = Outfit, fontWeight = FontWeight.SemiBold, + fontSize = 12.sp, color = NsAccent) + } + } + } +} diff --git a/android/app/src/main/kotlin/com/novasonic/app/viewmodel/ArtistNewsViewModel.kt b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/ArtistNewsViewModel.kt index 7d80145..2c0003a 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/viewmodel/ArtistNewsViewModel.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/ArtistNewsViewModel.kt @@ -6,6 +6,7 @@ import androidx.lifecycle.viewModelScope import com.novasonic.app.api.ImporterClient import com.novasonic.app.api.ReleasesClient import com.novasonic.app.api.model.ArtistRef +import com.novasonic.app.api.model.ArtistKonzert import com.novasonic.app.api.model.ArtistRelease import com.novasonic.app.api.model.DownloadRequest import com.novasonic.app.app @@ -18,8 +19,16 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +/** Welcher Reiter gezeigt wird. Getrennte Listen, weil die Zeit in verschiedene Richtungen läuft. */ +enum class NewsReiter { RELEASES, KONZERTE } + data class ArtistNewsUiState( + val reiter: NewsReiter = NewsReiter.RELEASES, val releases: List = emptyList(), + val konzerte: List = emptyList(), + /** Zeitfenster der Konzerte — nach VORN, deshalb ein eigener Wert. */ + val tageKonzerte: Int = 365, + val keinSchluessel: Boolean = false, /** Schlüssel (Künstler + Album, normalisiert) alles dessen, was schon in der Bibliothek liegt. */ val vorhanden: Set = emptySet(), val ohneZuordnung: List = emptyList(), @@ -44,9 +53,45 @@ class ArtistNewsViewModel(app: Application) : AndroidViewModel(app) { init { laden(90) } + /** Reiter wechseln und, falls noch nichts geladen ist, nachladen. */ + fun zeigeReiter(r: NewsReiter) { + _state.value = _state.value.copy(reiter = r) + if (r == NewsReiter.KONZERTE && _state.value.konzerte.isEmpty()) ladeKonzerte() + } + + /** Zeitfenster setzen — es gilt je Reiter, weil es in verschiedene Richtungen zeigt. */ + fun setzeTage(tage: Int) { + if (_state.value.reiter == NewsReiter.KONZERTE) ladeKonzerte(tage) else laden(tage) + } + + fun ladeKonzerte(tage: Int = _state.value.tageKonzerte) = viewModelScope.launch { + _state.value = _state.value.copy(isLoading = true, tageKonzerte = tage, error = null, + keineGefolgten = false, keinBackend = false, keinSchluessel = false) + try { + val backendUrl = runCatching { repo.getBackendUrl() }.getOrDefault("") + if (backendUrl.isBlank()) { + _state.value = _state.value.copy(isLoading = false, keinBackend = true) + return@launch + } + val kuenstler = repo.getStarred().artist.map { ArtistRef(it.id, it.name) } + if (kuenstler.isEmpty()) { + _state.value = _state.value.copy(isLoading = false, keineGefolgten = true) + return@launch + } + val antwort = ReleasesClient.konzerte(backendUrl, kuenstler, tage) + _state.value = _state.value.copy( + konzerte = antwort.konzerte, + keinSchluessel = antwort.keinSchluessel, + isLoading = false, + ) + } catch (e: Exception) { + _state.value = _state.value.copy(isLoading = false, error = e.message) + } + } + fun laden(tage: Int = _state.value.tage) = viewModelScope.launch { _state.value = _state.value.copy(isLoading = true, tage = tage, error = null, - keineGefolgten = false, keinBackend = false) + reiter = NewsReiter.RELEASES, keineGefolgten = false, keinBackend = false) try { val backendUrl = runCatching { repo.getBackendUrl() }.getOrDefault("") if (backendUrl.isBlank()) { diff --git a/backend/lib/konzerte.js b/backend/lib/konzerte.js new file mode 100644 index 0000000..64c3dd3 --- /dev/null +++ b/backend/lib/konzerte.js @@ -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}} + */ +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 }; diff --git a/backend/novasonic-server.js b/backend/novasonic-server.js index 1bc491c..9be548e 100644 --- a/backend/novasonic-server.js +++ b/backend/novasonic-server.js @@ -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); diff --git a/backend/test/konzerte.test.js b/backend/test/konzerte.test.js new file mode 100644 index 0000000..f28c2af --- /dev/null +++ b/backend/test/konzerte.test.js @@ -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); +}); diff --git a/index.html b/index.html index 3902125..459b808 100644 --- a/index.html +++ b/index.html @@ -427,10 +427,17 @@ + +
+ + +
- - - + + +
diff --git a/src/main.js b/src/main.js index 69c1fb4..fd4a487 100644 --- a/src/main.js +++ b/src/main.js @@ -352,6 +352,8 @@ Object.assign(window, { loadDeezerAlbum, closeEventsModal, // Lazy: die Zeitleiste zieht nur beim Öffnen ihren Code nach. loadArtistNews: (tage) => import('./views/artistNews.js').then(m => m.loadArtistNews(tage)), + showArtistNewsTab: (reiter) => import('./views/artistNews.js').then(m => m.showArtistNewsTab(reiter)), + setArtistNewsDays: (tage) => import('./views/artistNews.js').then(m => m.setArtistNewsDays(tage)), // Player playQueue, togglePlay, prevSong, nextSong, toggleShuffle, toggleRepeat, toggleQueue, setVolume, seekTo, starCurrent, downloadCurrent, playPreview, diff --git a/src/views/artistNews.js b/src/views/artistNews.js index 13d513b..8bf4aec 100644 --- a/src/views/artistNews.js +++ b/src/views/artistNews.js @@ -11,16 +11,44 @@ import { navigate, _navGoBack } from './navigation.js'; // Die Deezer-Auflösung macht das Backend, weil dort auch die Künstler-Pins liegen. let _tage = 90; +let _tageKonzerte = 365; // Konzerte schaut man in die Weite an, nicht auf 90 Tage +let _reiter = 'releases'; let _laeuft = false; +/** Reiter wechseln: "releases" oder "konzerte". */ +export function showArtistNewsTab(reiter) { + _reiter = reiter === 'konzerte' ? 'konzerte' : 'releases'; + document.querySelectorAll('#artist-news-tabs .sort-chip').forEach(c => + c.classList.toggle('active', c.dataset.tab === _reiter)); + const titel = document.querySelector('#page-artist-news .page-title'); + if (titel) { + titel.innerHTML = _reiter === 'konzerte' + ? 'Konzerte deiner Künstler' + : 'Neu von deinen Künstlern'; + } + if (_reiter === 'konzerte') loadKonzerte(); + else loadArtistNews(); +} + +/** Zeitfenster setzen — es gilt je Reiter, weil es in verschiedene Richtungen zeigt. */ +export function setArtistNewsDays(tage) { + if (_reiter === 'konzerte') { _tageKonzerte = tage; loadKonzerte(); } + else { _tage = tage; loadArtistNews(); } +} + +function _markiereTage(tage) { + document.querySelectorAll('#artist-news-sort .sort-chip').forEach(c => + c.classList.toggle('active', Number(c.dataset.days) === tage)); +} + export async function loadArtistNews(tage = _tage) { _tage = tage; + _reiter = 'releases'; // Die Seite haengt an den Favoriten (Knopf "✨ Neu") und nicht mehr an der Seitenleiste, // deshalb braucht sie einen Weg zurueck wie jede andere Unterseite. const zurueck = document.getElementById('artist-news-back-btn'); if (zurueck) zurueck.onclick = () => _navGoBack(); - document.querySelectorAll('#artist-news-sort .sort-chip').forEach(c => - c.classList.toggle('active', Number(c.dataset.days) === tage)); + _markiereTage(tage); const el = document.getElementById('artist-news-list'); if (!el || _laeuft) return; @@ -163,3 +191,113 @@ function _zeile(rel, schonDa) { row.appendChild(aktion); return row; } + +// ─── KONZERTE DEINER KÜNSTLER ─────────────────────────────────────────────── +// Gegenstück zur Release-Zeitleiste. Die Liste läuft NACH VORN: das nächste Konzert zuerst. +// Die Termine kommen über das Backend von Ticketmaster, gecacht für alle Geräte gemeinsam. + +export async function loadKonzerte(tage = _tageKonzerte) { + _tageKonzerte = tage; + _reiter = 'konzerte'; + const zurueck = document.getElementById('artist-news-back-btn'); + if (zurueck) zurueck.onclick = () => _navGoBack(); + _markiereTage(tage); + + const el = document.getElementById('artist-news-list'); + if (!el || _laeuft) return; + + if (!S.cfg?.backendUrl) { + el.innerHTML = '
🔌

Kein Backend konfiguriert

' + + '

Konzerttermine kommen über das Backend von Ticketmaster.

'; + return; + } + + _laeuft = true; + el.innerHTML = '
'; + try { + const r = await api('getStarred2'); + const kuenstler = (r.starred2?.artist || []).map(a => ({ id: a.id, name: a.name })); + if (!kuenstler.length) { + el.innerHTML = '
❤

Du folgst noch niemandem

' + + '

Auf einer Künstlerseite oben rechts aufs Herz tippen.

'; + return; + } + + const res = await fetch(`${S.cfg.backendUrl}/api/artist-concerts`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ artists: kuenstler, days: tage }), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const { konzerte = [], keinSchluessel } = await res.json(); + + el.innerHTML = ''; + if (keinSchluessel) { + el.innerHTML = '
🎟

Kein Ticketmaster-Schlüssel hinterlegt

' + + '

Im Backend TICKETMASTER_API_KEY setzen.

'; + return; + } + if (!konzerte.length) { + const leer = document.createElement('div'); + leer.className = 'empty-state'; + leer.innerHTML = `
🎪

Keine Termine in den nächsten ${tage} Tagen

` + + '

Gesucht wird weltweit über den Künstlernamen.

'; + el.appendChild(leer); + return; + } + _renderKonzerte(el, konzerte); + } catch (e) { + el.innerHTML = `

${esc(e.message)}

`; + } finally { + _laeuft = false; + } +} + +function _renderKonzerte(el, konzerte) { + let letzterMonat = ''; + konzerte.forEach(k => { + const d = new Date(k.datum); + const monat = d.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' }); + if (monat !== letzterMonat) { + letzterMonat = monat; + const h = document.createElement('div'); + h.className = 'search-section-title'; + h.style.margin = '18px 0 8px'; + h.textContent = monat; + el.appendChild(h); + } + + const zeile = document.createElement('div'); + zeile.className = 'artist-news-row konzert-row' + (k.abgesagt ? ' konzert-abgesagt' : ''); + const ort = [k.ort, k.stadt, k.land].filter(Boolean).join(' · '); + const uhr = d.getHours() || d.getMinutes() + ? d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }) : ''; + zeile.innerHTML = + `
${d.getDate()}` + + `${d.toLocaleDateString('de-DE', { month: 'short' })}
` + + `
` + + `
${esc(k.titel || k.artistName)}
` + + `
${esc(k.artistName)}` + + `${ort ? ' · ' + esc(ort) : ''}${uhr ? ' · ' + uhr + ' Uhr' : ''}
`; + + // Künstlername führt zur Künstlerseite, wie in der Release-Liste. + zeile.querySelector('.artist-news-artist').onclick = (e) => { + e.stopPropagation(); + navigate('artist-detail', { id: k.artistId, name: k.artistName }); + }; + + const rechts = document.createElement('div'); + if (k.abgesagt) { + rechts.className = 'artist-news-have'; + rechts.textContent = 'abgesagt'; + } else if (k.url) { + const a = document.createElement('a'); + a.className = 'btn-outline konzert-ticket'; + a.href = k.url; a.target = '_blank'; a.rel = 'noopener noreferrer'; + a.textContent = 'Tickets'; + rechts.appendChild(a); + } + zeile.appendChild(rechts); + el.appendChild(zeile); + }); +} diff --git a/styles.css b/styles.css index d58fe9a..910318f 100644 --- a/styles.css +++ b/styles.css @@ -1254,3 +1254,13 @@ button:focus{outline:none} .q-dragging .q-grip{cursor:grabbing} .q-dragging-aktiv [data-qidx]:not(.q-dragging){transition:transform .16s ease} .q-dragging-aktiv{user-select:none} + +/* ── Konzerte deiner Künstler ── */ +/* Kalenderblatt statt Cover: ein Konzert hat kein Bild, aber ein Datum — und danach sucht das + Auge in einer Liste, die nach vorn läuft. */ +.konzert-datum{flex-shrink:0;width:46px;height:46px;border-radius:10px;background:var(--surface);border:1px solid var(--border2);display:flex;flex-direction:column;align-items:center;justify-content:center;line-height:1} +.kd-tag{font-size:17px;font-weight:700;color:var(--text)} +.kd-monat{font-size:10px;text-transform:uppercase;letter-spacing:.06em;color:var(--text2);margin-top:2px} +.konzert-ticket{font-size:12px;padding:6px 12px;white-space:nowrap;text-decoration:none;display:inline-block} +.konzert-abgesagt .konzert-datum{opacity:.45} +.konzert-abgesagt .artist-news-title{text-decoration:line-through;color:var(--text2)}