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:
marcandClaude Opus 5 committed 2026-09-18 13:50:33 +02:00
1 parent 19f241e493
commit 908488a337
8 files changed
+345 -2

No files matched your search

+161
View File
@@ -0,0 +1,161 @@
import { S } from '../state.js';
import { esc } from '../utils.js';
import { api } from '../api.js';
import { openDlModal } from '../ui/ui.js';
// Zirkulär — sicher, weil nur in Ereignisbehandlern aufgerufen
import { navigate } from './navigation.js';
// ─── NEU VON DEINEN KÜNSTLERN ───────────────────────────────────────────────
// Zeitleiste der jüngsten Veröffentlichungen aller Künstler, denen man folgt. "Folgen" ist
// Navidromes Stern, die Liste kommt also aus getStarred2 — kein eigener Speicher.
// Die Deezer-Auflösung macht das Backend, weil dort auch die Künstler-Pins liegen.
let _tage = 90;
let _laeuft = false;
export async function loadArtistNews(tage = _tage) {
_tage = tage;
document.querySelectorAll('#artist-news-sort .sort-chip').forEach(c =>
c.classList.toggle('active', Number(c.dataset.days) === tage));
const el = document.getElementById('artist-news-list');
if (!el || _laeuft) return;
if (!S.cfg?.backendUrl) {
el.innerHTML = '<div class="empty-state"><div class="icon">🔌</div><p>Kein Backend konfiguriert</p>'
+ '<p style="font-size:12px;color:var(--text3);margin-top:6px">Neue Veröffentlichungen kommen über das Backend von Deezer.</p></div>';
return;
}
_laeuft = true;
el.innerHTML = '<div class="spinner"></div>';
try {
const r = await api('getStarred2');
const kuenstler = (r.starred2?.artist || []).map(a => ({ id: a.id, name: a.name }));
if (!kuenstler.length) {
el.innerHTML = '<div class="empty-state"><div class="icon">❤</div><p>Du folgst noch niemandem</p>'
+ '<p style="font-size:12px;color:var(--text3);margin-top:6px">Auf einer Künstlerseite oben rechts aufs Herz tippen.</p></div>';
return;
}
const res = await fetch(`${S.cfg.backendUrl}/api/artist-releases`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ artists: kuenstler, days: tage }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const { releases = [], ohneZuordnung = [] } = await res.json();
// Was hat man schon? Verglichen wird über Künstler + Albumname — Deezers Album-Id hat mit
// Navidromes nichts zu tun.
const vorhanden = new Set();
(r.starred2?.album || []).forEach(a => vorhanden.add(_schluessel(a.artist, a.name)));
await _ergaenzeVorhandene(kuenstler, vorhanden);
el.innerHTML = '';
if (ohneZuordnung.length) el.appendChild(_hinweisOhneZuordnung(ohneZuordnung));
if (!releases.length) {
const leer = document.createElement('div');
leer.className = 'empty-state';
leer.innerHTML = `<div class="icon">🌙</div><p>Nichts Neues in den letzten ${tage} Tagen</p>`;
el.appendChild(leer);
return;
}
_renderZeitleiste(el, releases, vorhanden);
} catch (e) {
el.innerHTML = `<p style="color:var(--danger)">${esc(e.message)}</p>`;
} finally {
_laeuft = false;
}
}
const _norm = s => (s || '').toLowerCase().normalize('NFD').replace(/\p{M}+/gu, '')
.replace(/[^a-z0-9]/g, '');
const _schluessel = (kuenstler, album) => `${_norm(kuenstler)}~~${_norm(album)}`;
/** Alben der gefolgten Künstler aus der eigenen Bibliothek nachtragen (parallel, begrenzt). */
async function _ergaenzeVorhandene(kuenstler, vorhanden) {
const haeppchen = 6;
for (let i = 0; i < kuenstler.length; i += haeppchen) {
await Promise.all(kuenstler.slice(i, i + haeppchen).map(async k => {
try {
const a = await api('getArtist', `&id=${encodeURIComponent(k.id)}`);
(a.artist?.album || []).forEach(al => vorhanden.add(_schluessel(k.name, al.name)));
} catch {}
}));
}
}
function _hinweisOhneZuordnung(namen) {
const d = document.createElement('div');
d.className = 'artist-news-hint';
d.innerHTML = `⚠ Für ${namen.length} Künstler wurde kein Deezer-Profil gefunden: `
+ `<strong>${namen.slice(0, 4).map(esc).join(', ')}</strong>${namen.length > 4 ? ' …' : ''}. `
+ `Auf der Künstlerseite über ⚙ das richtige Profil festpinnen.`;
return d;
}
function _renderZeitleiste(el, releases, vorhanden) {
let letzterMonat = '';
releases.forEach(rel => {
const monat = new Date(rel.releaseDate)
.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' });
if (monat !== letzterMonat) {
letzterMonat = monat;
const h = document.createElement('div');
h.className = 'search-section-title';
h.style.cssText = 'margin:18px 0 8px';
h.textContent = monat;
el.appendChild(h);
}
el.appendChild(_zeile(rel, vorhanden.has(_schluessel(rel.artistName, rel.title))));
});
}
const _TYP = { album: 'Album', single: 'Single', ep: 'EP', compile: 'Compilation' };
function _zeile(rel, schonDa) {
const row = document.createElement('div');
row.className = 'artist-news-row';
const datum = new Date(rel.releaseDate).toLocaleDateString('de-DE',
{ day: '2-digit', month: '2-digit', year: 'numeric' });
const typ = _TYP[rel.recordType] || rel.recordType;
row.innerHTML = `
<div class="artist-news-art">${rel.cover ? `<img src="${esc(rel.cover)}" loading="lazy" alt="">` : '<div class="no-art">💿</div>'}</div>
<div class="artist-news-info">
<div class="artist-news-title">${esc(rel.title)}</div>
<div class="artist-news-sub"><span class="artist-news-artist">${esc(rel.artistName)}</span> · ${typ}${rel.trackCount ? ` · ${rel.trackCount} Titel` : ''}</div>
<div class="artist-news-date">${datum}</div>
</div>`;
row.querySelector('.artist-news-artist').onclick = (e) => {
e.stopPropagation();
navigate('artist-detail', { id: rel.artistId, name: rel.artistName });
};
const aktion = document.createElement('div');
aktion.className = 'artist-news-action';
if (schonDa) {
aktion.innerHTML = '<span class="artist-news-have" title="In deiner Bibliothek">✓ Hast du</span>';
} else {
const btn = document.createElement('button');
btn.className = 'btn-outline blue';
btn.style.cssText = 'font-size:12px;padding:7px 12px';
btn.textContent = '⬇ Laden';
btn.onclick = () => openDlModal({
artistName: rel.artistName,
trackName: rel.title,
collectionName: rel.title,
artworkUrl100: rel.cover || '',
isAlbum: true,
collectionId: rel.albumId,
deezerData: {
collectionId: rel.albumId, collectionName: rel.title,
artistName: rel.artistName, artworkUrl100: rel.cover || '',
releaseDate: rel.releaseDate, trackCount: rel.trackCount,
},
});
aktion.appendChild(btn);
}
row.appendChild(aktion);
return row;
}
+3 -2
View File
@@ -92,7 +92,7 @@ export function navigate(page, data=null) {
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
document.querySelectorAll('.nav-item,.bnav-item').forEach(n => n.classList.remove('active','active-blue'));
const cls = ['discover','new-releases','recommendations'].includes(page) ? 'active-blue' : 'active';
const cls = ['discover','new-releases','recommendations','artist-news'].includes(page) ? 'active-blue' : 'active';
document.querySelectorAll(`[data-page="${page}"]`).forEach(el => el.classList.add(cls));
const pageEl = document.getElementById(`page-${page}`);
if(pageEl) {
@@ -109,7 +109,7 @@ export function navigate(page, data=null) {
}
}
closeSidebar();
if(['discover','new-releases','recommendations','random'].includes(page)) switchNavMode('discover');
if(['discover','new-releases','recommendations','random','artist-news'].includes(page)) switchNavMode('discover');
else if(['home','artists','albums','starred','playlists'].includes(page)) switchNavMode('library');
if(page==='home') loadHomeData(!S._navJumping); // explicit Home click refreshes; back/forward keeps state
@@ -123,6 +123,7 @@ export function navigate(page, data=null) {
if(page==='genre-detail' && data) loadGenreDetail(data);
if(page==='deezer-album-detail' && data) loadDeezerAlbumDetail(data);
if(page==='partial-album-detail' && data) loadPartialAlbumDetail(data);
if(page==='artist-news') import('./artistNews.js').then(m => m.loadArtistNews());
if(page==='new-releases') loadNewReleases();
if(page==='playlists') loadPlaylists();
if(page==='playlist-detail' && data) loadPlaylistDetail(data);