add tidal biography to artists pages

This commit is contained in:
BlackSigkill 2026-02-19 23:37:18 +01:00
parent 48937ed573
commit acc9d8b5cd
6 changed files with 264 additions and 1 deletions

View file

@ -2605,6 +2605,7 @@
<div class="detail-header-info">
<h1 class="title" id="artist-detail-name"></h1>
<div class="meta" id="artist-detail-meta"></div>
<div id="artist-detail-bio" class="artist-bio"></div>
<div class="detail-header-actions">
<button id="play-artist-radio-btn" class="btn-primary" title="Artist Radio">
<svg

View file

@ -12,6 +12,7 @@ import { addMetadataToAudio } from './metadata.js';
import { DashDownloader } from './dash-downloader.js';
export const DASH_MANIFEST_UNAVAILABLE_CODE = 'DASH_MANIFEST_UNAVAILABLE';
const TIDAL_V2_TOKEN = 'txNoH4kkV41MfH25';
export class LosslessAPI {
constructor(settings) {
@ -793,6 +794,36 @@ export class LosslessAPI {
}
}
async getArtistBiography(artistId) {
const cacheKey = `artist_bio_v1_${artistId}`;
const cached = await this.cache.get('artist', cacheKey);
if (cached) return cached;
try {
const url = `https://api.tidal.com/v1/artists/${artistId}/bio?locale=en_US&countryCode=GB`;
const response = await fetch(url, {
headers: {
'X-Tidal-Token': TIDAL_V2_TOKEN,
},
});
if (response.ok) {
const data = await response.json();
if (data && data.text) {
const bio = {
text: data.text,
source: data.source || 'Tidal',
};
await this.cache.set('artist', cacheKey, bio);
return bio;
}
}
} catch (e) {
console.warn('Failed to fetch Tidal biography:', e);
}
return null;
}
async getSimilarAlbums(albumId) {
const cached = await this.cache.get('similar_albums', albumId);
if (cached) return cached;

View file

@ -76,6 +76,16 @@ export class MusicAPI {
return api.getArtist(cleanId);
}
async getArtistBiography(id, provider = null) {
const p = provider || this.getProviderFromId(id) || this.getCurrentProvider();
const api = this.getAPI(p);
const cleanId = this.stripProviderPrefix(id);
if (typeof api.getArtistBiography === 'function') {
return api.getArtistBiography(cleanId);
}
return null;
}
async getPlaylist(id, _provider = null) {
// Playlists are always Tidal for now
return this.tidalAPI.getPlaylist(id);

View file

@ -179,13 +179,29 @@ export class QobuzAPI {
tracks = artistData.data.top_tracks.map((track) => this.transformTrack(track));
}
return { ...artist, albums, eps, tracks };
// Get biography
const biography = artistData.data.biography || artistInfo.biography || null;
return { ...artist, albums, eps, tracks, biography };
} catch (error) {
console.error('Qobuz getArtist failed:', error);
throw error;
}
}
// Qobuz biography - usually part of getArtist, but adding for API consistency
async getArtistBiography(id) {
try {
const data = await this.getArtist(id);
return {
text: data.biography,
source: 'Qobuz',
};
} catch (e) {
return null;
}
}
// Transform Qobuz track to Tidal-like format
transformTrack(track, albumData = null) {
// Qobuz uses 'performer' for the main artist, not 'artist'

148
js/ui.js
View file

@ -2871,6 +2871,7 @@ export class UIRenderer {
const imageEl = document.getElementById('artist-detail-image');
const nameEl = document.getElementById('artist-detail-name');
const metaEl = document.getElementById('artist-detail-meta');
const bioEl = document.getElementById('artist-detail-bio');
const tracksContainer = document.getElementById('artist-detail-tracks');
const albumsContainer = document.getElementById('artist-detail-albums');
const epsContainer = document.getElementById('artist-detail-eps');
@ -2884,6 +2885,11 @@ export class UIRenderer {
imageEl.style.backgroundColor = 'var(--muted)';
nameEl.innerHTML = '<div class="skeleton" style="height: 48px; width: 300px; max-width: 90%;"></div>';
metaEl.innerHTML = '<div class="skeleton" style="height: 16px; width: 150px;"></div>';
if (bioEl) {
bioEl.style.display = 'none';
bioEl.textContent = '';
bioEl.classList.remove('expanded');
}
tracksContainer.innerHTML = this.createSkeletonTracks(5, true);
albumsContainer.innerHTML = this.createSkeletonCards(6, false);
if (epsContainer) epsContainer.innerHTML = this.createSkeletonCards(6, false);
@ -2896,6 +2902,148 @@ export class UIRenderer {
try {
const artist = await this.api.getArtist(artistId, provider);
// Handle Biography
if (bioEl) {
// Pre-define regex patterns for better performance
const linkTypes = ['artist', 'album', 'track', 'playlist'];
const regexCache = {
wimp: linkTypes.reduce((acc, type) => {
acc[type] = new RegExp(`\\[wimpLink ${type}Id="([a-f\\d-]+)"\\](.*?)\\[\\/wimpLink\\]`, 'g');
return acc;
}, {}),
legacy: linkTypes.reduce((acc, type) => {
acc[type] = new RegExp(`\\[${type}:([a-f\\d-]+)\\](.*?)\\[\\/${type}\\]`, 'g');
return acc;
}, {}),
doubleBracket: /\[\[(.*?)\|(.*?)\]\]/g
};
const parseBio = (text) => {
if (!text) return '';
let parsed = text;
linkTypes.forEach(type => {
parsed = parsed.replace(regexCache.wimp[type], (m, id, name) => `<span class="bio-link" data-type="${type}" data-id="${id}">${name}</span>`);
parsed = parsed.replace(regexCache.legacy[type], (m, id, name) => `<span class="bio-link" data-type="${type}" data-id="${id}">${name}</span>`);
});
parsed = parsed.replace(regexCache.doubleBracket, (m, name, id) => `<span class="bio-link" data-type="artist" data-id="${id}">${name}</span>`);
return parsed.replace(/\n/g, '<br>');
};
// Helper to strip tags for clean preview
const stripBioTags = (text) => {
if (!text) return '';
let clean = text;
linkTypes.forEach(type => {
// [wimpLink artistId="..."]Name[/wimpLink] -> Name
clean = clean.replace(regexCache.wimp[type], (m, id, name) => name);
// [artist:...]Name[/artist] -> Name
clean = clean.replace(regexCache.legacy[type], (m, id, name) => name);
});
// [[Name|ID]] -> Name
clean = clean.replace(regexCache.doubleBracket, (m, name, id) => name);
return clean;
};
const showBioModal = (bio) => {
const text = typeof bio === 'string' ? bio : bio.text;
const source = typeof bio === 'string' ? null : bio.source;
const modal = document.createElement('div');
modal.className = 'modal active bio-modal';
modal.style.zIndex = '9999'; // Ensure it's on top
modal.innerHTML = `
<div class="modal-overlay"></div>
<div class="modal-content extra-wide" style="display: flex; flex-direction: column;">
<div class="modal-header" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem; border-bottom: 1px solid var(--border); padding-bottom: 1rem;">
<h3 style="margin: 0;">Artist Biography</h3>
<button class="btn-close" style="background: none; border: none; font-size: 2rem; cursor: pointer; color: var(--foreground); padding: 0.2rem 0.5rem; line-height: 1;">&times;</button>
</div>
<div class="modal-body" style="max-height: 70vh; overflow-y: auto; line-height: 1.8; font-size: 1.1rem; padding-right: 1rem; color: var(--foreground); cursor: default;">
${parseBio(text)}
${source ? `<div class="bio-source">Source: ${source}</div>` : ''}
</div>
</div>
`;
document.body.appendChild(modal);
const close = (e) => {
if (e) {
e.preventDefault();
e.stopPropagation();
}
modal.remove();
};
modal.querySelector('.modal-overlay').onclick = close;
modal.querySelector('.btn-close').onclick = close;
// Ensure links are clickable by attaching the listener to the modal body
const modalBody = modal.querySelector('.modal-body');
modalBody.addEventListener('click', (e) => {
const link = e.target.closest('.bio-link');
if (link) {
e.preventDefault();
e.stopPropagation();
const { type, id } = link.dataset;
if (type && id) {
modal.remove();
navigate(`/${type}/t/${id}`);
}
}
}, true); // Use capture phase to ensure it's hit
};
const renderBioPreview = (bio) => {
const text = typeof bio === 'string' ? bio : bio.text;
if (text) {
// Use stripped text for preview to avoid broken tags/links
const cleanText = stripBioTags(text);
const isLong = cleanText.length > 200;
const previewText = isLong ? cleanText.substring(0, 200).trim() + '...' : cleanText;
bioEl.innerHTML = previewText.replace(/\n/g, '<br>');
bioEl.style.display = 'block';
bioEl.style.webkitLineClamp = 'unset';
bioEl.style.cursor = 'default';
bioEl.onclick = null;
if (isLong) {
bioEl.appendChild(document.createElement('br'));
const readMore = document.createElement('span');
readMore.className = 'bio-read-more';
readMore.textContent = 'Read More';
readMore.onclick = (e) => {
e.preventDefault();
e.stopPropagation();
showBioModal(bio);
};
bioEl.appendChild(readMore);
}
} else {
bioEl.style.display = 'none';
}
};
if (artist.biography) {
renderBioPreview(artist.biography);
} else {
// Try to fetch biography asynchronously
this.api
.getArtistBiography(artistId, provider)
.then((bio) => {
if (bio) renderBioPreview(bio);
})
.catch(() => {
/* ignore */
});
}
}
// Handle Artist Mix Button
const mixBtn = document.getElementById('artist-mix-btn');
if (mixBtn) {

View file

@ -2360,6 +2360,59 @@ input[type='search']::-webkit-search-cancel-button {
color: var(--highlight);
}
/* Artist Biography Styles */
.artist-bio {
color: var(--muted-foreground);
margin-top: 1rem;
font-size: 0.95rem;
line-height: 1.5;
max-width: 600px;
display: block;
overflow: hidden;
transition: color var(--transition);
}
.artist-bio:hover {
color: var(--foreground);
}
.bio-link {
color: var(--highlight) !important;
text-decoration: underline !important;
cursor: pointer !important;
font-weight: 500 !important;
pointer-events: auto !important;
}
.bio-link:hover {
color: var(--primary) !important;
text-decoration: none !important;
}
.bio-read-more {
display: block;
color: var(--highlight);
text-decoration: underline;
cursor: pointer;
font-weight: bold;
font-size: 0.95rem;
margin-top: 0.5rem;
}
.bio-read-more:hover {
color: var(--primary);
}
.bio-source {
display: block;
margin-top: 1.5rem;
font-size: 0.9rem;
opacity: 0.6;
font-style: italic;
border-top: 1px solid var(--border);
padding-top: 1rem;
}
.detail-header-actions {
display: flex;
gap: 0.5rem;
@ -4999,6 +5052,10 @@ img[src=''] {
max-width: 600px;
}
.modal-content.extra-wide {
max-width: 1000px;
}
.modal-content.medium {
max-width: 500px;
}