refactor(now-playing): G.71 — extract helpers + cache + NpCardWrap + NpColumnEl + RadioView (cluster) (#638)

Five-cut cluster opening the NowPlaying refactor. 1384 → 1119 LOC
(−265).

nowPlayingHelpers — eight pure helpers + ContributorRow type:
formatTime, formatCompact, formatTotalDuration, sanitizeHtml
(strip dangerous attributes + trailing Last.fm "Read more" link),
isoToParts (date formatting for Bandsintown), buildContributorRows
(dedupes contributor list, hides redundant "Artist = main artist"
row), isRealArtistImage (filter the Last.fm "2a96…" placeholder MD5
that aggregating Subsonic backends still emit).

nowPlayingCache — module-level TTL cache used by all subcomponents:
CACHE_TTL_MS (5 min), CacheEntry type, makeCache() factory.
NowPlaying still instantiates eight `makeCache<…>()` typed caches
inline at module scope; only the factory + TTL constant move.

NpCardWrap — drag-source wrapper around each dashboard card,
participates in the psyDnD drag stream via useDragSource.

NpColumnEl — drop-target column. Owns the document mousemove
listener that determines which wrapper the dragged card would
land before (x-axis decides column, y-axis bisects wrapper rects
to compute insert index). No-op when no card is being dragged.

RadioView — full radio-playing layout: hero card with stream
name + current artist/title/album + AzuraCast progress bar +
listeners badge, "Up Next" card, recently-played list. Subscribes
to nothing on its own; takes the radioMeta tuple + currentRadio +
resolvedCover from the parent. NonNullStoreField type alias moves
with it.

NowPlaying drops the inline definitions; renderStars + the eight
typed cache instances stay in the page for now (renderStars is used
by Hero + TopSongsCard, both of which still live inline; the
typed caches are consumed by NowPlaying's load effects). Pure code
move otherwise.
This commit is contained in:
Frank Stellmacher
2026-05-13 14:09:37 +02:00
committed by GitHub
parent e260669537
commit 1c34cc04c7
6 changed files with 297 additions and 274 deletions
+27
View File
@@ -0,0 +1,27 @@
import React from 'react';
import { useDragSource } from '../../contexts/DragDropContext';
import type { NpCardId } from '../../store/nowPlayingLayoutStore';
interface NpCardWrapProps {
id: NpCardId;
label: string;
isDraggingThis: boolean;
children: React.ReactNode;
}
export default function NpCardWrap({ id, label, isDraggingThis, children }: NpCardWrapProps) {
const dragProps = useDragSource(() => ({
data: JSON.stringify({ kind: 'np-card', id }),
label,
}));
return (
<div
data-np-wrapper
data-np-card-id={id}
className={`np-dash-card-wrap${isDraggingThis ? ' is-dragging' : ''}`}
{...dragProps}
>
{children}
</div>
);
}
+52
View File
@@ -0,0 +1,52 @@
import React, { useEffect, useRef } from 'react';
import type { NpCardId, NpColumn } from '../../store/nowPlayingLayoutStore';
interface NpColumnProps {
col: NpColumn;
children: React.ReactNode;
empty: boolean;
emptyLabel: string;
isDndActive: boolean;
draggingCardId: NpCardId | null;
onHover: (col: NpColumn, idx: number) => void;
isOverHere: boolean;
}
export default function NpColumnEl({ col, children, empty, emptyLabel, isDndActive, draggingCardId, onHover, isOverHere }: NpColumnProps) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!draggingCardId) return;
const el = ref.current;
if (!el) return;
const onMove = (e: MouseEvent) => {
const rect = el.getBoundingClientRect();
// Use only the x-axis to decide "which column". This keeps the whole
// vertical strip above / below the last card part of the drop zone,
// so the user can drop "at the very bottom" of either column.
if (e.clientX < rect.left || e.clientX > rect.right) return;
const wrappers = Array.from(el.querySelectorAll<HTMLElement>('[data-np-wrapper]'))
.filter(w => w.getAttribute('data-np-card-id') !== draggingCardId);
let idx = wrappers.length;
for (let i = 0; i < wrappers.length; i++) {
const r = wrappers[i].getBoundingClientRect();
if (e.clientY < r.top + r.height / 2) { idx = i; break; }
}
onHover(col, idx);
};
document.addEventListener('mousemove', onMove);
return () => document.removeEventListener('mousemove', onMove);
}, [draggingCardId, col, onHover]);
return (
<div
ref={ref}
className={`np-dash-col${isOverHere ? ' is-drop-target' : ''}${isDndActive ? ' is-dnd-active' : ''}`}
>
{children}
{empty && <div className="np-dash-col-empty">{emptyLabel}</div>}
</div>
);
}
+102
View File
@@ -0,0 +1,102 @@
import React, { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { Cast, Clock, Radio, SkipForward, Users } from 'lucide-react';
import type { useRadioMetadata } from '../../hooks/useRadioMetadata';
import { usePlayerStore } from '../../store/playerStore';
import { formatTime } from '../../utils/nowPlayingHelpers';
type NonNullStoreField<K extends keyof ReturnType<typeof usePlayerStore.getState>> =
NonNullable<ReturnType<typeof usePlayerStore.getState>[K]>;
interface RadioViewProps {
radioMeta: ReturnType<typeof useRadioMetadata>;
currentRadio: NonNullStoreField<'currentRadio'>;
resolvedCover: string;
}
const RadioView = memo(function RadioView({ radioMeta, currentRadio, resolvedCover }: RadioViewProps) {
const { t } = useTranslation();
return (
<div className="np-radio-section">
<div className="np-hero-card">
<div className="np-hero-left">
<div className="np-hero-info">
<div className="np-title" style={{ color: 'var(--accent)' }}>{currentRadio.name}</div>
{radioMeta.currentTitle && (
<div className="np-artist-album">
{radioMeta.currentArtist && (<><span className="np-link">{radioMeta.currentArtist}</span><span className="np-sep">·</span></>)}
<span>{radioMeta.currentTitle}</span>
{radioMeta.currentAlbum && (<><span className="np-sep">·</span><span style={{ opacity: 0.6 }}>{radioMeta.currentAlbum}</span></>)}
</div>
)}
<div className="np-tech-row">
<span className="np-badge np-badge-live"><Radio size={10} style={{ marginRight: 3 }} />{t('radio.live')}</span>
{radioMeta.source === 'azuracast' && <span className="np-badge np-badge-azuracast">AzuraCast</span>}
{radioMeta.listeners != null && (
<span className="np-badge"><Users size={10} style={{ marginRight: 3 }} />{t('radio.listenerCount', { count: radioMeta.listeners })}</span>
)}
</div>
{radioMeta.source === 'azuracast' && radioMeta.elapsed != null && radioMeta.duration != null && radioMeta.duration > 0 && (
<div className="np-radio-progress-wrap">
<span className="np-radio-time">{formatTime(radioMeta.elapsed)}</span>
<div className="np-radio-progress-bar">
<div className="np-radio-progress-fill" style={{ width: `${Math.min(100, (radioMeta.elapsed / radioMeta.duration) * 100)}%` }} />
</div>
<span className="np-radio-time">{formatTime(radioMeta.duration)}</span>
</div>
)}
</div>
</div>
<div className="np-hero-cover-wrap">
{resolvedCover
? <img src={resolvedCover} alt={currentRadio.name} className="np-cover" />
: radioMeta.currentArt
? <img src={radioMeta.currentArt} alt="" className="np-cover" onError={e => { (e.target as HTMLImageElement).style.display = 'none'; }} />
: <div className="np-cover np-cover-fallback"><Cast size={52} /></div>}
</div>
<div style={{ flex: 1 }} />
</div>
{radioMeta.nextSong && (
<div className="np-info-card">
<div className="np-card-header">
<h3 className="np-card-title"><SkipForward size={13} style={{ marginRight: 5 }} />{t('radio.upNext')}</h3>
</div>
<div className="np-radio-next-track">
{radioMeta.nextSong.art && (
<img src={radioMeta.nextSong.art} alt="" className="np-radio-track-art"
onError={e => { (e.target as HTMLImageElement).style.display = 'none'; }} />
)}
<div className="np-radio-track-info">
<span className="np-radio-track-title">{radioMeta.nextSong.title}</span>
{radioMeta.nextSong.artist && <span className="np-radio-track-artist">{radioMeta.nextSong.artist}</span>}
</div>
</div>
</div>
)}
{radioMeta.history.length > 0 && (
<div className="np-info-card">
<div className="np-card-header">
<h3 className="np-card-title"><Clock size={13} style={{ marginRight: 5 }} />{t('radio.recentlyPlayed')}</h3>
</div>
<div className="np-album-tracklist">
{radioMeta.history.map((item, idx) => (
<div key={idx} className="np-album-track">
{item.song.art && (
<img src={item.song.art} alt="" className="np-radio-track-art np-radio-track-art--sm"
onError={e => { (e.target as HTMLImageElement).style.display = 'none'; }} />
)}
<span className="np-album-track-title truncate">
{item.song.artist ? `${item.song.artist}${item.song.title}` : item.song.title}
</span>
</div>
))}
</div>
</div>
)}
</div>
);
});
export default RadioView;
+9 -274
View File
@@ -32,91 +32,15 @@ import {
// ─── Helpers ──────────────────────────────────────────────────────────────────
function formatTime(s: number): string {
if (!s || isNaN(s)) return '0:00';
const m = Math.floor(s / 60);
return `${m}:${Math.floor(s % 60).toString().padStart(2, '0')}`;
}
function formatCompact(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n >= 10_000_000 ? 0 : 1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(n >= 10_000 ? 0 : 1)}K`;
return String(n);
}
function formatTotalDuration(s: number): string {
if (!s || isNaN(s)) return '—';
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = Math.floor(s % 60);
if (h > 0) return `${h}h ${m}m`;
if (m > 0) return `${m}m ${sec}s`;
return `${sec}s`;
}
function sanitizeHtml(html: string): string {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
doc.querySelectorAll('script, style, iframe, object, embed, form, input, button, select, base, meta, link').forEach(el => el.remove());
doc.querySelectorAll('*').forEach(el => {
Array.from(el.attributes).forEach(attr => {
const name = attr.name.toLowerCase();
const val = attr.value.toLowerCase().trim();
if (name.startsWith('on') || (name === 'href' && (val.startsWith('javascript:') || val.startsWith('data:'))) || (name === 'src' && (val.startsWith('javascript:') || val.startsWith('data:')))) {
el.removeAttribute(attr.name);
}
});
});
// Strip trailing "Read more on Last.fm" style links for cleaner clamped bios.
return doc.body.innerHTML.replace(/<a [^>]*>.*?<\/a>\.?\s*$/i, '').trim();
}
function isoToParts(iso: string): { month: string; day: string; weekday: string; time: string } | null {
if (!iso) return null;
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return null;
return {
month: d.toLocaleString(undefined, { month: 'short' }),
day: String(d.getDate()),
weekday: d.toLocaleString(undefined, { weekday: 'short' }),
time: d.toLocaleString(undefined, { hour: '2-digit', minute: '2-digit' }),
};
}
interface ContributorRow { role: string; names: string[]; }
function buildContributorRows(song: SubsonicSong | null | undefined, mainArtistName: string): ContributorRow[] {
if (!song?.contributors || song.contributors.length === 0) return [];
const mainLower = mainArtistName.trim().toLowerCase();
const rows = new Map<string, Set<string>>();
for (const c of song.contributors) {
const role = c.role?.trim();
const name = c.artist?.name?.trim();
if (!role || !name) continue;
const label = c.subRole ? `${role}${c.subRole}` : role;
let bucket = rows.get(label);
if (!bucket) { bucket = new Set(); rows.set(label, bucket); }
bucket.add(name);
}
const out: ContributorRow[] = [];
for (const [role, names] of rows.entries()) {
const list = Array.from(names);
if (role.toLowerCase().startsWith('artist') && list.length === 1 && list[0].toLowerCase() === mainLower) continue;
out.push({ role, names: list });
}
return out;
}
/**
* Filter out the well-known Last.fm "no image" placeholder that Subsonic
* backends aggregate into `largeImageUrl`/`mediumImageUrl` when no real
* artist image exists. The placeholder MD5 is fixed and documented.
*/
function isRealArtistImage(url?: string): boolean {
if (!url) return false;
if (url.includes('2a96cbd8b46e442fc41c2b86b821562f')) return false;
return true;
}
import {
formatTime, formatCompact, formatTotalDuration, sanitizeHtml, isoToParts,
buildContributorRows, isRealArtistImage,
type ContributorRow,
} from '../utils/nowPlayingHelpers';
import { makeCache } from '../utils/nowPlayingCache';
import NpCardWrap from '../components/nowPlaying/NpCardWrap';
import NpColumnEl from '../components/nowPlaying/NpColumnEl';
import RadioView from '../components/nowPlaying/RadioView';
function renderStars(rating?: number) {
if (!rating) return null;
@@ -134,23 +58,6 @@ function renderStars(rating?: number) {
// ─── Module-level TTL caches (shared across mounts) ───────────────────────────
const CACHE_TTL_MS = 5 * 60 * 1000;
interface CacheEntry<T> { value: T; ts: number; }
function makeCache<T>() {
const map = new Map<string, CacheEntry<T>>();
return {
get(key: string): T | undefined {
const e = map.get(key);
if (!e) return undefined;
if (Date.now() - e.ts > CACHE_TTL_MS) { map.delete(key); return undefined; }
return e.value;
},
set(key: string, value: T) { map.set(key, { value, ts: Date.now() }); },
};
}
const songMetaCache = makeCache<SubsonicSong | null>();
const artistInfoCache = makeCache<SubsonicArtistInfo | null>();
const albumCache = makeCache<{ album: SubsonicAlbum; songs: SubsonicSong[] } | null>();
@@ -723,178 +630,6 @@ const DiscographyCard = memo(function DiscographyCard({ artistId, albums, curren
);
});
// ─── Widget wrapper (drag source via psyDnD) ─────────────────────────────────
interface NpCardWrapProps {
id: NpCardId;
label: string;
isDraggingThis: boolean;
children: React.ReactNode;
}
function NpCardWrap({ id, label, isDraggingThis, children }: NpCardWrapProps) {
const dragProps = useDragSource(() => ({
data: JSON.stringify({ kind: 'np-card', id }),
label,
}));
return (
<div
data-np-wrapper
data-np-card-id={id}
className={`np-dash-card-wrap${isDraggingThis ? ' is-dragging' : ''}`}
{...dragProps}
>
{children}
</div>
);
}
// ─── Column (drop target via psy-drop + global mousemove) ────────────────────
interface NpColumnProps {
col: NpColumn;
children: React.ReactNode;
empty: boolean;
emptyLabel: string;
isDndActive: boolean;
draggingCardId: NpCardId | null;
onHover: (col: NpColumn, idx: number) => void;
isOverHere: boolean;
}
function NpColumnEl({ col, children, empty, emptyLabel, isDndActive, draggingCardId, onHover, isOverHere }: NpColumnProps) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!draggingCardId) return;
const el = ref.current;
if (!el) return;
const onMove = (e: MouseEvent) => {
const rect = el.getBoundingClientRect();
// Use only the x-axis to decide "which column". This keeps the whole
// vertical strip above / below the last card part of the drop zone,
// so the user can drop "at the very bottom" of either column.
if (e.clientX < rect.left || e.clientX > rect.right) return;
const wrappers = Array.from(el.querySelectorAll<HTMLElement>('[data-np-wrapper]'))
.filter(w => w.getAttribute('data-np-card-id') !== draggingCardId);
let idx = wrappers.length;
for (let i = 0; i < wrappers.length; i++) {
const r = wrappers[i].getBoundingClientRect();
if (e.clientY < r.top + r.height / 2) { idx = i; break; }
}
onHover(col, idx);
};
document.addEventListener('mousemove', onMove);
return () => document.removeEventListener('mousemove', onMove);
}, [draggingCardId, col, onHover]);
return (
<div
ref={ref}
className={`np-dash-col${isOverHere ? ' is-drop-target' : ''}${isDndActive ? ' is-dnd-active' : ''}`}
>
{children}
{empty && <div className="np-dash-col-empty">{emptyLabel}</div>}
</div>
);
}
type NonNullStoreField<K extends keyof ReturnType<typeof usePlayerStore.getState>> =
NonNullable<ReturnType<typeof usePlayerStore.getState>[K]>;
interface RadioViewProps {
radioMeta: ReturnType<typeof useRadioMetadata>;
currentRadio: NonNullStoreField<'currentRadio'>;
resolvedCover: string;
}
const RadioView = memo(function RadioView({ radioMeta, currentRadio, resolvedCover }: RadioViewProps) {
const { t } = useTranslation();
return (
<div className="np-radio-section">
<div className="np-hero-card">
<div className="np-hero-left">
<div className="np-hero-info">
<div className="np-title" style={{ color: 'var(--accent)' }}>{currentRadio.name}</div>
{radioMeta.currentTitle && (
<div className="np-artist-album">
{radioMeta.currentArtist && (<><span className="np-link">{radioMeta.currentArtist}</span><span className="np-sep">·</span></>)}
<span>{radioMeta.currentTitle}</span>
{radioMeta.currentAlbum && (<><span className="np-sep">·</span><span style={{ opacity: 0.6 }}>{radioMeta.currentAlbum}</span></>)}
</div>
)}
<div className="np-tech-row">
<span className="np-badge np-badge-live"><Radio size={10} style={{ marginRight: 3 }} />{t('radio.live')}</span>
{radioMeta.source === 'azuracast' && <span className="np-badge np-badge-azuracast">AzuraCast</span>}
{radioMeta.listeners != null && (
<span className="np-badge"><Users size={10} style={{ marginRight: 3 }} />{t('radio.listenerCount', { count: radioMeta.listeners })}</span>
)}
</div>
{radioMeta.source === 'azuracast' && radioMeta.elapsed != null && radioMeta.duration != null && radioMeta.duration > 0 && (
<div className="np-radio-progress-wrap">
<span className="np-radio-time">{formatTime(radioMeta.elapsed)}</span>
<div className="np-radio-progress-bar">
<div className="np-radio-progress-fill" style={{ width: `${Math.min(100, (radioMeta.elapsed / radioMeta.duration) * 100)}%` }} />
</div>
<span className="np-radio-time">{formatTime(radioMeta.duration)}</span>
</div>
)}
</div>
</div>
<div className="np-hero-cover-wrap">
{resolvedCover
? <img src={resolvedCover} alt={currentRadio.name} className="np-cover" />
: radioMeta.currentArt
? <img src={radioMeta.currentArt} alt="" className="np-cover" onError={e => { (e.target as HTMLImageElement).style.display = 'none'; }} />
: <div className="np-cover np-cover-fallback"><Cast size={52} /></div>}
</div>
<div style={{ flex: 1 }} />
</div>
{radioMeta.nextSong && (
<div className="np-info-card">
<div className="np-card-header">
<h3 className="np-card-title"><SkipForward size={13} style={{ marginRight: 5 }} />{t('radio.upNext')}</h3>
</div>
<div className="np-radio-next-track">
{radioMeta.nextSong.art && (
<img src={radioMeta.nextSong.art} alt="" className="np-radio-track-art"
onError={e => { (e.target as HTMLImageElement).style.display = 'none'; }} />
)}
<div className="np-radio-track-info">
<span className="np-radio-track-title">{radioMeta.nextSong.title}</span>
{radioMeta.nextSong.artist && <span className="np-radio-track-artist">{radioMeta.nextSong.artist}</span>}
</div>
</div>
</div>
)}
{radioMeta.history.length > 0 && (
<div className="np-info-card">
<div className="np-card-header">
<h3 className="np-card-title"><Clock size={13} style={{ marginRight: 5 }} />{t('radio.recentlyPlayed')}</h3>
</div>
<div className="np-album-tracklist">
{radioMeta.history.map((item, idx) => (
<div key={idx} className="np-album-track">
{item.song.art && (
<img src={item.song.art} alt="" className="np-radio-track-art np-radio-track-art--sm"
onError={e => { (e.target as HTMLImageElement).style.display = 'none'; }} />
)}
<span className="np-album-track-title truncate">
{item.song.artist ? `${item.song.artist}${item.song.title}` : item.song.title}
</span>
</div>
))}
</div>
</div>
)}
</div>
);
});
// ─── Main Page ────────────────────────────────────────────────────────────────
export default function NowPlaying() {
+20
View File
@@ -0,0 +1,20 @@
// Module-level TTL caches (shared across mounts).
// Used by NowPlaying subcomponents to avoid hammering Subsonic / Last.fm /
// Bandsintown on every track / artist change.
export const CACHE_TTL_MS = 5 * 60 * 1000;
interface CacheEntry<T> { value: T; ts: number; }
export function makeCache<T>() {
const map = new Map<string, CacheEntry<T>>();
return {
get(key: string): T | undefined {
const e = map.get(key);
if (!e) return undefined;
if (Date.now() - e.ts > CACHE_TTL_MS) { map.delete(key); return undefined; }
return e.value;
},
set(key: string, value: T) { map.set(key, { value, ts: Date.now() }); },
};
}
+87
View File
@@ -0,0 +1,87 @@
import type { SubsonicSong } from '../api/subsonicTypes';
export function formatTime(s: number): string {
if (!s || isNaN(s)) return '0:00';
const m = Math.floor(s / 60);
return `${m}:${Math.floor(s % 60).toString().padStart(2, '0')}`;
}
export function formatCompact(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n >= 10_000_000 ? 0 : 1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(n >= 10_000 ? 0 : 1)}K`;
return String(n);
}
export function formatTotalDuration(s: number): string {
if (!s || isNaN(s)) return '—';
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = Math.floor(s % 60);
if (h > 0) return `${h}h ${m}m`;
if (m > 0) return `${m}m ${sec}s`;
return `${sec}s`;
}
export function sanitizeHtml(html: string): string {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
doc.querySelectorAll('script, style, iframe, object, embed, form, input, button, select, base, meta, link').forEach(el => el.remove());
doc.querySelectorAll('*').forEach(el => {
Array.from(el.attributes).forEach(attr => {
const name = attr.name.toLowerCase();
const val = attr.value.toLowerCase().trim();
if (name.startsWith('on') || (name === 'href' && (val.startsWith('javascript:') || val.startsWith('data:'))) || (name === 'src' && (val.startsWith('javascript:') || val.startsWith('data:')))) {
el.removeAttribute(attr.name);
}
});
});
// Strip trailing "Read more on Last.fm" style links for cleaner clamped bios.
return doc.body.innerHTML.replace(/<a [^>]*>.*?<\/a>\.?\s*$/i, '').trim();
}
export function isoToParts(iso: string): { month: string; day: string; weekday: string; time: string } | null {
if (!iso) return null;
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return null;
return {
month: d.toLocaleString(undefined, { month: 'short' }),
day: String(d.getDate()),
weekday: d.toLocaleString(undefined, { weekday: 'short' }),
time: d.toLocaleString(undefined, { hour: '2-digit', minute: '2-digit' }),
};
}
export interface ContributorRow { role: string; names: string[]; }
export function buildContributorRows(song: SubsonicSong | null | undefined, mainArtistName: string): ContributorRow[] {
if (!song?.contributors || song.contributors.length === 0) return [];
const mainLower = mainArtistName.trim().toLowerCase();
const rows = new Map<string, Set<string>>();
for (const c of song.contributors) {
const role = c.role?.trim();
const name = c.artist?.name?.trim();
if (!role || !name) continue;
const label = c.subRole ? `${role}${c.subRole}` : role;
let bucket = rows.get(label);
if (!bucket) { bucket = new Set(); rows.set(label, bucket); }
bucket.add(name);
}
const out: ContributorRow[] = [];
for (const [role, names] of rows.entries()) {
const list = Array.from(names);
if (role.toLowerCase().startsWith('artist') && list.length === 1 && list[0].toLowerCase() === mainLower) continue;
out.push({ role, names: list });
}
return out;
}
/**
* Filter out the well-known Last.fm "no image" placeholder that Subsonic
* backends aggregate into `largeImageUrl`/`mediumImageUrl` when no real
* artist image exists. The placeholder MD5 is fixed and documented.
*/
export function isRealArtistImage(url?: string): boolean {
if (!url) return false;
if (url.includes('2a96cbd8b46e442fc41c2b86b821562f')) return false;
return true;
}