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
@@ -40,6 +40,7 @@ Die Android-App zählt eigenständig weiter (mehrere APKs pro Release). Welche d
|
|||||||
| Playlist cover upload | ✅ | ✅ |
|
| Playlist cover upload | ✅ | ✅ |
|
||||||
| Besitzer geteilter Playlists | ✅ | ✅ |
|
| Besitzer geteilter Playlists | ✅ | ✅ |
|
||||||
| Künstler folgen / Album merken | ✅ | ✅ |
|
| Künstler folgen / Album merken | ✅ | ✅ |
|
||||||
|
| Neu von deinen Künstlern | ✅ | ❌ |
|
||||||
| Spotify / YouTube importer | ✅ | ✅ |
|
| Spotify / YouTube importer | ✅ | ✅ |
|
||||||
| Musik-Duell (Anzeigetafel) | ✅ | ✅ |
|
| Musik-Duell (Anzeigetafel) | ✅ | ✅ |
|
||||||
| Wiedergabe fortsetzen | ✅ | ✅ |
|
| 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
|
- 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.
|
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
|
### Changed
|
||||||
- **Top Songs fragen jetzt über die Künstler-ID (Web + Android)** — Navidrome 0.64 nimmt bei
|
- **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
|
`getTopSongs` zusätzlich `id`. Bisher lief die Abfrage nur über den **Namen**, und der Namensweg
|
||||||
|
|||||||
@@ -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 });
|
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
|
// DELETE /api/artist-overrides/:ndArtistId — remove a pin
|
||||||
app.delete('/api/artist-overrides/:ndArtistId', (req, res) => {
|
app.delete('/api/artist-overrides/:ndArtistId', (req, res) => {
|
||||||
const id = decodeURIComponent(req.params.ndArtistId);
|
const id = decodeURIComponent(req.params.ndArtistId);
|
||||||
|
|||||||
+15
@@ -76,6 +76,9 @@
|
|||||||
<div class="nav-item" onclick="navigate('discover')" data-page="discover">
|
<div class="nav-item" onclick="navigate('discover')" data-page="discover">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polygon points="10 8 16 12 10 16 10 8"/></svg>Global Suche
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polygon points="10 8 16 12 10 16 10 8"/></svg>Global Suche
|
||||||
</div>
|
</div>
|
||||||
|
<div class="nav-item" onclick="navigate('artist-news')" data-page="artist-news">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>Von deinen Künstlern
|
||||||
|
</div>
|
||||||
<div class="nav-item" onclick="navigate('new-releases')" data-page="new-releases">
|
<div class="nav-item" onclick="navigate('new-releases')" data-page="new-releases">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>Neue Releases
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>Neue Releases
|
||||||
</div>
|
</div>
|
||||||
@@ -406,6 +409,18 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- NEW RELEASES -->
|
<!-- NEW RELEASES -->
|
||||||
|
<div class="page" id="page-artist-news">
|
||||||
|
<div class="page-header">
|
||||||
|
<div class="page-title">Neu von <em>deinen Künstlern</em></div>
|
||||||
|
</div>
|
||||||
|
<div id="artist-news-sort" class="sort-chips">
|
||||||
|
<button class="sort-chip active" data-days="90" onclick="loadArtistNews(90)">90 Tage</button>
|
||||||
|
<button class="sort-chip" data-days="30" onclick="loadArtistNews(30)">30 Tage</button>
|
||||||
|
<button class="sort-chip" data-days="365" onclick="loadArtistNews(365)">1 Jahr</button>
|
||||||
|
</div>
|
||||||
|
<div id="artist-news-list"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="page" id="page-new-releases">
|
<div class="page" id="page-new-releases">
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<div class="page-title">Neue <em>Releases</em></div>
|
<div class="page-title">Neue <em>Releases</em></div>
|
||||||
|
|||||||
@@ -350,6 +350,8 @@ Object.assign(window, {
|
|||||||
navigate, onHeaderSearch, closeHeaderSearch, onGlobalSearch, s_goArtist, s_goAlbum,
|
navigate, onHeaderSearch, closeHeaderSearch, onGlobalSearch, s_goArtist, s_goAlbum,
|
||||||
playerBarGoArtist, playerBarGoAlbum, searchSimilarArtist, searchAndPreview,
|
playerBarGoArtist, playerBarGoAlbum, searchSimilarArtist, searchAndPreview,
|
||||||
loadDeezerAlbum, closeEventsModal,
|
loadDeezerAlbum, closeEventsModal,
|
||||||
|
// Lazy: die Zeitleiste zieht nur beim Öffnen ihren Code nach.
|
||||||
|
loadArtistNews: (tage) => import('./views/artistNews.js').then(m => m.loadArtistNews(tage)),
|
||||||
// Player
|
// Player
|
||||||
playQueue, togglePlay, prevSong, nextSong, toggleShuffle, toggleRepeat,
|
playQueue, togglePlay, prevSong, nextSong, toggleShuffle, toggleRepeat,
|
||||||
toggleQueue, setVolume, seekTo, starCurrent, downloadCurrent, playPreview,
|
toggleQueue, setVolume, seekTo, starCurrent, downloadCurrent, playPreview,
|
||||||
|
|||||||
@@ -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('.page').forEach(p => p.classList.remove('active'));
|
||||||
document.querySelectorAll('.nav-item,.bnav-item').forEach(n => n.classList.remove('active','active-blue'));
|
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));
|
document.querySelectorAll(`[data-page="${page}"]`).forEach(el => el.classList.add(cls));
|
||||||
const pageEl = document.getElementById(`page-${page}`);
|
const pageEl = document.getElementById(`page-${page}`);
|
||||||
if(pageEl) {
|
if(pageEl) {
|
||||||
@@ -109,7 +109,7 @@ export function navigate(page, data=null) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
closeSidebar();
|
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');
|
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
|
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==='genre-detail' && data) loadGenreDetail(data);
|
||||||
if(page==='deezer-album-detail' && data) loadDeezerAlbumDetail(data);
|
if(page==='deezer-album-detail' && data) loadDeezerAlbumDetail(data);
|
||||||
if(page==='partial-album-detail' && data) loadPartialAlbumDetail(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==='new-releases') loadNewReleases();
|
||||||
if(page==='playlists') loadPlaylists();
|
if(page==='playlists') loadPlaylists();
|
||||||
if(page==='playlist-detail' && data) loadPlaylistDetail(data);
|
if(page==='playlist-detail' && data) loadPlaylistDetail(data);
|
||||||
|
|||||||
+20
@@ -1178,3 +1178,23 @@ button:focus{outline:none}
|
|||||||
.cmp-bar{height:6px;border-radius:3px;background:var(--accent)}
|
.cmp-bar{height:6px;border-radius:3px;background:var(--accent)}
|
||||||
.cmp-track{display:flex;align-items:center;gap:10px;padding:6px 0;min-width:0}
|
.cmp-track{display:flex;align-items:center;gap:10px;padding:6px 0;min-width:0}
|
||||||
.cmp-track img{width:40px;height:40px;border-radius:6px;object-fit:cover;flex-shrink:0}
|
.cmp-track img{width:40px;height:40px;border-radius:6px;object-fit:cover;flex-shrink:0}
|
||||||
|
|
||||||
|
/* ─── NEU VON DEINEN KÜNSTLERN ─────────────────────────────────────────── */
|
||||||
|
.artist-news-hint{background:rgba(232,160,32,.08);border:1px solid rgba(232,160,32,.28);border-radius:10px;padding:10px 14px;font-size:12px;color:var(--text2);line-height:1.5;margin-bottom:14px}
|
||||||
|
.artist-news-hint strong{color:var(--text)}
|
||||||
|
.artist-news-row{display:flex;align-items:center;gap:14px;padding:10px 12px;border-radius:12px;transition:background .15s}
|
||||||
|
.artist-news-row:hover{background:var(--surface)}
|
||||||
|
.artist-news-art{width:56px;height:56px;flex-shrink:0;border-radius:8px;overflow:hidden;background:var(--surface2);display:flex;align-items:center;justify-content:center}
|
||||||
|
.artist-news-art img{width:100%;height:100%;object-fit:cover}
|
||||||
|
.artist-news-info{flex:1;min-width:0}
|
||||||
|
.artist-news-title{font-size:14px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||||
|
.artist-news-sub{font-size:12px;color:var(--text2);margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||||
|
.artist-news-artist{cursor:pointer;transition:color .15s}
|
||||||
|
.artist-news-artist:hover{color:var(--accent);text-decoration:underline}
|
||||||
|
.artist-news-date{font-size:11px;color:var(--text3);margin-top:3px}
|
||||||
|
.artist-news-action{flex-shrink:0}
|
||||||
|
.artist-news-have{font-size:12px;color:var(--text3);white-space:nowrap}
|
||||||
|
@media(max-width:600px){
|
||||||
|
.artist-news-art{width:46px;height:46px}
|
||||||
|
.artist-news-row{gap:10px;padding:8px 6px}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user