feat: Zeitleiste "Neu von deinen Künstlern" (Web) + Backend-Endpunkt
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
19f241e493
commit
908488a337
8 files changed
+345
-2
No files matched your search
@@ -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<string>}}
|
||||
*/
|
||||
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 };
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user