From 908488a3377a9276ce0a2c6439deb6356e0252d5 Mon Sep 17 00:00:00 2001 From: marc Date: Fri, 18 Sep 2026 13:50:33 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Zeitleiste=20"Neu=20von=20deinen=20K?= =?UTF-8?q?=C3=BCnstlern"=20(Web)=20+=20Backend-Endpunkt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eigener Punkt in der Seitenleiste: jüngste Veröffentlichungen aller gefolgten Künstler, nach Monat gruppiert, neueste zuerst, umschaltbar auf 30/90/365 Tage. Was schon in der Bibliothek liegt, ist markiert (Vergleich über Künstler + Albumname, akzent- und zeichenunabhängig); alles andere bekommt einen Laden-Knopf zum vertrauten Download-Dialog. Neuer Endpunkt POST /api/artist-releases (backend/lib/releases.js): die Deezer-Zuordnung passiert serverseitig, weil die Künstler-Pins dort ohnehin liegen. Gepinnte nutzen die feste ID, ungepinnte werden über den Namen gesucht und nach Fan-Zahl sortiert; ohne Treffer kommt der Name als ohneZuordnung zurück und die Oberfläche rät zum Pinnen. Cache im Backend statt im Client — bei 30 gefolgten Künstlern wären das sonst 30 Deezer-Aufrufe pro Gerät und App-Start. 6 h für Alben, 24 h für die Namenszuordnung, max. 4 Anfragen parallel, bei Deezer-Fehlern wird der abgelaufene Stand ausgeliefert statt einer leeren Liste. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 19 +++++ backend/lib/releases.js | 95 +++++++++++++++++++++ backend/novasonic-server.js | 30 +++++++ index.html | 15 ++++ src/main.js | 2 + src/views/artistNews.js | 161 ++++++++++++++++++++++++++++++++++++ src/views/navigation.js | 5 +- styles.css | 20 +++++ 8 files changed, 345 insertions(+), 2 deletions(-) create mode 100644 backend/lib/releases.js create mode 100644 src/views/artistNews.js diff --git a/CHANGELOG.md b/CHANGELOG.md index c33e45b..7877bc3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,7 @@ Die Android-App zählt eigenständig weiter (mehrere APKs pro Release). Welche d | Playlist cover upload | ✅ | ✅ | | Besitzer geteilter Playlists | ✅ | ✅ | | Künstler folgen / Album merken | ✅ | ✅ | +| Neu von deinen Künstlern | ✅ | ❌ | | Spotify / YouTube importer | ✅ | ✅ | | Musik-Duell (Anzeigetafel) | ✅ | ✅ | | Wiedergabe fortsetzen | ✅ | ✅ | @@ -100,6 +101,24 @@ Die Android-App zählt eigenständig weiter (mehrere APKs pro Release). Welche d - Wer ohne Künstler-Pin folgt, bekommt einen Hinweis: ohne Pinnung läuft die Deezer-Zuordnung über den Namen, und bei gleichnamigen Künstlern kämen dauerhaft die Neuheiten des Falschen. +- **Zeitleiste „Neu von deinen Künstlern" (Web)** — eigener Punkt in der Seitenleiste. Zeigt die + jüngsten Veröffentlichungen aller Künstler, denen du folgst: nach Monat gruppiert, neueste zuerst, + pro Eintrag Cover, Künstler, Art (Album/Single/EP), Titelzahl und Datum. Umschaltbar auf 30 Tage, + 90 Tage oder ein Jahr. + - **Was du schon hast, ist markiert.** Verglichen wird über Künstler + Albumname (akzent- und + zeichenunabhängig) — Deezers Album-Id hat mit Navidromes nichts zu tun. Alles andere bekommt + direkt einen Laden-Knopf, der den vertrauten Download-Dialog öffnet. + - **Neuer Backend-Endpunkt `POST /api/artist-releases`** (`backend/lib/releases.js`). Die + Deezer-Zuordnung passiert dort, weil die Künstler-Pins ohnehin serverseitig liegen: der Client + schickt nur seine Navidrome-Künstler und muss über Deezer nichts wissen. Gepinnte Künstler nutzen + die feste ID, ungepinnte werden über den Namen gesucht — sortiert nach Fan-Zahl, weil Deezers + Suche nicht nach Bekanntheit ordnet. Künstler ohne Treffer kommen als `ohneZuordnung` zurück und + die Oberfläche rät zum Pinnen, statt still die Neuheiten des Falschen anzuzeigen. + - **Der Cache liegt im Backend, nicht im Client.** Bei 30 gefolgten Künstlern wären das sonst 30 + Deezer-Aufrufe pro Gerät und App-Start. 6 h für Alben, 24 h für die Namenszuordnung, höchstens + vier Anfragen gleichzeitig; bei einem Deezer-Fehler wird der abgelaufene Stand ausgeliefert + statt einer leeren Liste. + ### Changed - **Top Songs fragen jetzt über die Künstler-ID (Web + Android)** — Navidrome 0.64 nimmt bei `getTopSongs` zusätzlich `id`. Bisher lief die Abfrage nur über den **Namen**, und der Namensweg diff --git a/backend/lib/releases.js b/backend/lib/releases.js new file mode 100644 index 0000000..71b58af --- /dev/null +++ b/backend/lib/releases.js @@ -0,0 +1,95 @@ +// ─── NEUE VERÖFFENTLICHUNGEN GEFOLGTER KÜNSTLER ─────────────────────────────── +// Liefert zu einer Liste von Navidrome-Künstlern die jüngsten Deezer-Releases, damit die App eine +// Zeitleiste "Neu von deinen Künstlern" zeigen kann. +// +// Warum serverseitig: bei 30 gefolgten Künstlern wären das 30 Deezer-Aufrufe — pro Gerät und pro +// App-Start. Der Cache liegt deshalb hier und gilt für alle Clients gemeinsam. +const { dzGet } = require('./deezer'); + +const _cache = new Map(); // deezerArtistId → { ts, alben } +const _idCache = new Map(); // nameLower → { ts, id } +const TTL = 6 * 60 * 60 * 1000; // 6 h — Alben erscheinen nicht minütlich +const ID_TTL = 24 * 60 * 60 * 1000; // 24 h — die Zuordnung Name → Deezer ändert sich kaum +const GLEICHZEITIG = 4; // höflich gegenüber Deezer, trotzdem zügig + +const coverXl = a => a?.cover_xl || a?.cover_big || a?.cover_medium || ''; +// Künstler stilisieren ihren Namen mit führenden Zeichen (".diedlonely", "†…") — ohne das Abschneiden +// findet Deezers Suche sie nicht. Gleiche Regel wie in deezer.js. +const stripLeadSym = s => (s || '').replace(/^[^\p{L}\p{N}]+/u, '').trim(); + +/** Deezer-ID zu einem Künstler: erst die gepinnte, sonst über den Namen gesucht (und gemerkt). */ +async function deezerIdFuer(name, pinId) { + if (pinId) return String(pinId); + const key = stripLeadSym(name).toLowerCase(); + if (!key) return null; + const c = _idCache.get(key); + if (c && Date.now() - c.ts < ID_TTL) return c.id; + try { + const treffer = (await dzGet(`search/artist?q=${encodeURIComponent(stripLeadSym(name))}&limit=5`)).data || []; + // Deezer sortiert nicht nach Bekanntheit — die Fan-Zahl trifft den gemeinten Künstler deutlich + // öfter als der erste Treffer. Ein Pin schlägt das trotzdem, siehe oben. + const best = treffer.slice().sort((a, b) => (b.nb_fan || 0) - (a.nb_fan || 0))[0]; + const id = best ? String(best.id) : null; + _idCache.set(key, { ts: Date.now(), id }); + return id; + } catch { return null; } +} + +/** Albumliste eines Deezer-Künstlers, gecacht. */ +async function albenFuer(deezerId) { + const c = _cache.get(deezerId); + if (c && Date.now() - c.ts < TTL) return c.alben; + try { + const alben = (await dzGet(`artist/${deezerId}/albums?limit=50`)).data || []; + _cache.set(deezerId, { ts: Date.now(), alben }); + return alben; + } catch { + // Bei einem Fehler lieber den alten Stand als gar nichts — abgelaufen, aber nicht falsch. + return c ? c.alben : []; + } +} + +/** Führt eine Aufgabenliste mit begrenzter Gleichzeitigkeit aus. */ +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,deezerArtistId?}>} kuenstler Navidrome-Künstler (id + name) + * @param {number} tage Zeitfenster + * @returns {{releases:Array, ohneZuordnung:Array}} + */ +async function neueReleases(kuenstler, tage = 90) { + const grenze = Date.now() - tage * 24 * 60 * 60 * 1000; + const ohneZuordnung = []; + + const proKuenstler = await inHaeppchen(kuenstler, GLEICHZEITIG, async (k) => { + const dzId = await deezerIdFuer(k.name, k.deezerArtistId); + if (!dzId) { ohneZuordnung.push(k.name); return []; } + const alben = await albenFuer(dzId); + return alben + .filter(a => a.release_date && new Date(a.release_date).getTime() >= grenze) + .map(a => ({ + artistId: k.id, + artistName: k.name, + deezerArtistId: dzId, + albumId: String(a.id), + title: a.title || '', + cover: coverXl(a), + releaseDate: a.release_date, + recordType: a.record_type || 'album', + trackCount: a.nb_tracks ?? null, + explicit: !!a.explicit_lyrics, + })); + }); + + const releases = proKuenstler.flat() + .sort((a, b) => new Date(b.releaseDate) - new Date(a.releaseDate)); + return { releases, ohneZuordnung }; +} + +module.exports = { neueReleases, deezerIdFuer, albenFuer, _cache, _idCache }; diff --git a/backend/novasonic-server.js b/backend/novasonic-server.js index 4515d19..c95e4fe 100644 --- a/backend/novasonic-server.js +++ b/backend/novasonic-server.js @@ -266,6 +266,36 @@ app.post('/api/artist-overrides', (req, res) => { res.json({ ok: true }); }); +// ─── NEUE VERÖFFENTLICHUNGEN GEFOLGTER KÜNSTLER ───────────────────────────── +// POST /api/artist-releases { artists: [{id, name}], days } +// Die Deezer-Zuordnung passiert HIER, weil die Pins ohnehin serverseitig liegen — der Client +// schickt also nur seine Navidrome-Künstler und muss nichts über Deezer wissen. +app.post('/api/artist-releases', async (req, res) => { + const artists = Array.isArray(req.body?.artists) ? req.body.artists : []; + const days = Math.min(Math.max(parseInt(req.body?.days, 10) || 90, 1), 365); + if (!artists.length) return res.json({ releases: [], ohneZuordnung: [] }); + // Obergrenze, damit ein kaputter Client nicht hunderte Deezer-Aufrufe auslöst. + if (artists.length > 300) return res.status(400).json({ error: 'Zu viele Künstler (max. 300)' }); + + const pins = _loadArtistOverrides(); + const mitPin = artists + .filter(a => a && a.id && a.name) + .map(a => ({ + id: String(a.id), + name: String(a.name), + deezerArtistId: pins[String(a.id)]?.deezerArtistId || null, + })); + + try { + const { neueReleases } = require('./lib/releases'); + const ergebnis = await neueReleases(mitPin, days); + res.json(ergebnis); + } catch (e) { + console.error('[releases]', 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/index.html b/index.html index 35346eb..7b3bd0b 100644 --- a/index.html +++ b/index.html @@ -76,6 +76,9 @@ + @@ -406,6 +409,18 @@ +
+ +
+ + + +
+
+
+