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,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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user