feat(artist): add Tributes section and improve album categorization
Major improvements to album categorization logic: Detection improvements: - Broadcasts: detect plural forms, "Radio Broadcasts", "The Beast Radio" - Live albums: detect "Alive", city+year patterns (Seattle 1989, Tokyo 1986) - Podcasts: detect "Report Episode X", single-track 20+ min releases - Singles: 1-4 tracks (classic 45rpm), explicit markers in title New Tributes section: - Albums by different artists now go to "Tributes & Covers" - Separates tribute/cover albums from legitimate artist content - Keeps Others clean for compilations, B-sides, unofficial releases Categorization priority: 1. Different artist -> Tributes 2. Broadcasts/bootlegs/podcasts -> Others 3. Compilations (greatest hits, B-sides) -> Others 4. Live albums -> Live 5. Studio albums (7+ tracks, 25+ min, deluxe) -> Discography 6. EPs/Singles (1-4 tracks, <15 min) -> EPs & Singles
This commit is contained in:
1 parent
ecf85bbd0b
commit
74483f2b10
3 files changed
+134
-38
No files matched your search
@@ -69,55 +69,79 @@ type ArtistAlbumSummary = ArtistDetail['albums'][number];
|
||||
type ArtistPlaylistSummary = ArtistDetail['playlists'][number];
|
||||
|
||||
/**
|
||||
* Check if album belongs to a different artist (should go to Others)
|
||||
* Check if album belongs to a different artist (should go to Tributes)
|
||||
* This catches albums that appear in an artist's discography but are actually by someone else
|
||||
*/
|
||||
function isDifferentArtist(album: QobuzAlbum, mainArtistId: number | undefined): boolean {
|
||||
if (!mainArtistId || !album.artist?.id) return false;
|
||||
|
||||
// If the album's primary artist ID doesn't match the artist we're viewing
|
||||
return album.artist.id !== mainArtistId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is a tribute/cover album by OTHER artists
|
||||
* These go to the Tributes section, not Others
|
||||
*/
|
||||
function isTributeAlbum(album: QobuzAlbum, mainArtistId: number | undefined): boolean {
|
||||
const title = album.title?.toLowerCase() ?? '';
|
||||
|
||||
// Explicit tribute/cover patterns in title
|
||||
const tributePatterns = /\b(tribute to|tribute|a]tribute|covers? of|as made famous|karaoke|in the style of|salute to|celebrating)\b/;
|
||||
|
||||
// If it's by a different artist AND has tribute-like patterns
|
||||
if (isDifferentArtist(album, mainArtistId)) {
|
||||
// Any album by another artist with tribute patterns
|
||||
if (tributePatterns.test(title)) return true;
|
||||
|
||||
// Albums that mention the main artist's name in title but are by someone else
|
||||
// These are likely tribute albums
|
||||
return true; // All different artist albums go to tributes
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is an unofficial/bootleg release (should go to Others)
|
||||
*/
|
||||
function isUnofficialRelease(album: QobuzAlbum): boolean {
|
||||
const title = album.title?.toLowerCase() ?? '';
|
||||
const label = album.label?.name?.toLowerCase() ?? '';
|
||||
const trackCount = album.tracks_count ?? 0;
|
||||
const duration = album.duration ?? 0;
|
||||
|
||||
// FM Broadcasts, radio recordings, bootlegs
|
||||
const broadcastPatterns = /\b(fm broadcast|radio broadcast|broadcast|bootleg|unofficial|pirate)\b/;
|
||||
// FM Broadcasts, radio recordings, bootlegs (including plural forms)
|
||||
const broadcastPatterns = /\b(fm broadcasts?|radio broadcasts?|broadcasts?|bootleg|unofficial|pirate)\b/;
|
||||
|
||||
// Interview albums, spoken word that isn't the artist's music
|
||||
const nonMusicPatterns = /\b(interview|speaks|talking|in their own words)\b/;
|
||||
|
||||
// Podcasts, radio shows, reports (single long track, episodic naming)
|
||||
const podcastPatterns = /\b(episode|report|podcast|show|program|special)\s*\d+|\b(episode|report)\b/i;
|
||||
const isPodcastLike = podcastPatterns.test(title) && trackCount === 1 && duration >= 1200; // 20+ min single track
|
||||
|
||||
// Labels known for unofficial releases
|
||||
const bootlegLabels = /\b(leftfield media|purple pyramid|cleopatra|laser media)\b/;
|
||||
const bootlegLabels = /\b(leftfield media|purple pyramid|cleopatra|laser media|radio lu|broadcast archives)\b/;
|
||||
|
||||
return broadcastPatterns.test(title) ||
|
||||
nonMusicPatterns.test(title) ||
|
||||
bootlegLabels.test(label);
|
||||
bootlegLabels.test(label) ||
|
||||
isPodcastLike;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is a compilation/greatest hits (should go to Others)
|
||||
* Note: Tributes are now handled separately
|
||||
*/
|
||||
function isCompilationAlbum(album: QobuzAlbum): boolean {
|
||||
const title = album.title?.toLowerCase() ?? '';
|
||||
|
||||
// Greatest hits, best of, anthologies
|
||||
const compilationPatterns = /\b(greatest hits|best of|anthology|the very best|essential|definitive collection|gold|platinum|hits collection|complete collection|hit collection)\b/;
|
||||
|
||||
// Tribute albums, cover albums (not by the original artist)
|
||||
const tributePatterns = /\b(tribute to|tribute|covers?|as made famous|karaoke)\b/;
|
||||
const compilationPatterns = /\b(greatest hits|best of|anthology|the very best|essentials?|definitive collection|gold|platinum|hits collection|complete collection|hit collection|b-sides|rarities)\b/;
|
||||
|
||||
// Various artists compilations
|
||||
const variousPatterns = /\b(various artists|v\.?a\.?|original soundtrack|ost)\b/;
|
||||
|
||||
return compilationPatterns.test(title) ||
|
||||
tributePatterns.test(title) ||
|
||||
variousPatterns.test(title);
|
||||
return compilationPatterns.test(title) || variousPatterns.test(title);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -125,10 +149,11 @@ function isCompilationAlbum(album: QobuzAlbum): boolean {
|
||||
*/
|
||||
function isLiveAlbum(album: QobuzAlbum): boolean {
|
||||
const title = album.title?.toLowerCase() ?? '';
|
||||
const originalTitle = album.title ?? '';
|
||||
const genre = album.genre?.name?.toLowerCase() ?? '';
|
||||
|
||||
// Don't classify FM broadcasts as live - they go to Others
|
||||
if (/\b(fm broadcast|radio broadcast|broadcast)\b/.test(title)) {
|
||||
if (/\b(fm broadcasts?|radio broadcasts?|broadcasts?)\b/i.test(title)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -136,19 +161,26 @@ function isLiveAlbum(album: QobuzAlbum): boolean {
|
||||
if (genre.includes('live')) return true;
|
||||
|
||||
// Common live album patterns
|
||||
const livePatterns = /\blive\b|\blive at\b|\blive in\b|\bin concert\b|\bunplugged\b|\bmtv unplugged\b|\bacoustic live\b/;
|
||||
const livePatterns = /\blive\b|\blive at\b|\blive in\b|\bin concert\b|\bunplugged\b|\bmtv unplugged\b|\bacoustic live\b|\balive\b/;
|
||||
|
||||
// Tour recordings (but not "tour edition" which is a studio album variant)
|
||||
const tourPatterns = /\b(tour|on tour)\b(?!.*\bedition\b)/;
|
||||
|
||||
// Location + year pattern common in live albums (e.g., "Dallas 1989", "Tokyo 1986")
|
||||
// But exclude if it also says "remaster" or "deluxe" which indicates studio album
|
||||
const locationYearPattern = /\b[A-Z][a-z]+\s*(19|20)\d{2}\b/;
|
||||
// Check for studio variant indicators
|
||||
const isStudioVariant = /\b(remaster|deluxe|anniversary|expanded)\b/.test(title);
|
||||
|
||||
if (livePatterns.test(title)) return true;
|
||||
if (tourPatterns.test(title) && !isStudioVariant) return true;
|
||||
if (locationYearPattern.test(album.title ?? '') && !isStudioVariant && /\blive\b/.test(title)) return true;
|
||||
|
||||
// City/Location + Year pattern (e.g., "Seattle 1989", "Tokyo 1986", "Dallas, Texas 1989")
|
||||
// Common cities for live recordings
|
||||
const cities = /\b(seattle|tokyo|london|paris|new york|los angeles|chicago|dallas|amsterdam|berlin|sydney|melbourne|montreal|toronto|rio|sao paulo|mexico city|denver|boston|philadelphia|san francisco|cleveland|detroit|atlanta|miami|phoenix|houston|minneapolis|st\.? louis|kansas city|nashville|memphis|austin|portland|oakland|stockholm|oslo|dublin|manchester|birmingham|glasgow|brussels|madrid|barcelona|rome|milan|vienna|munich|hamburg|zurich|prague|warsaw|moscow|seoul|osaka|singapore|hong kong|taipei|manila|jakarta|mumbai|delhi|bangalore|cape town|johannesburg)\b/i;
|
||||
const yearPattern = /\b(19[6-9]\d|20[0-2]\d)\b/;
|
||||
|
||||
// If title contains a city and a year (likely a live recording location/date)
|
||||
if (cities.test(originalTitle) && yearPattern.test(originalTitle) && !isStudioVariant) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -162,17 +194,21 @@ function isEpOrSingle(album: QobuzAlbum): boolean {
|
||||
const duration = album.duration ?? 0;
|
||||
const title = album.title?.toLowerCase() ?? '';
|
||||
|
||||
// Explicitly marked as EP or Single in title
|
||||
if (/\b(- ep|\.ep|\(ep\)|- single|\(single\)|single)\b/.test(title)) return true;
|
||||
// Explicitly marked as EP or Single in title (highest priority)
|
||||
if (/\b(- ep|\.ep|\(ep\))\b/.test(title)) return true;
|
||||
if (/\bep\s*$/.test(title)) return true; // Ends with "EP"
|
||||
if (/\b(- single|\(single\))\b/.test(title)) return true;
|
||||
// "Single" alone in title (but not "single version" which could be on any album)
|
||||
if (/\bsingle\b/.test(title) && !/\b(version|mix|edit)\b/.test(title)) return true;
|
||||
|
||||
// Very short releases: 1-3 tracks OR under 15 minutes
|
||||
// This is more conservative - 8 tracks could be a full album
|
||||
if (trackCount > 0 && trackCount <= 3) return true;
|
||||
if (duration > 0 && duration <= 900) return true; // 15 minutes
|
||||
// Very short releases: 1-4 tracks (classic 45rpm single had 2 tracks per side = 4 total)
|
||||
if (trackCount > 0 && trackCount <= 4) return true;
|
||||
|
||||
// 4-6 tracks AND under 25 minutes = likely EP
|
||||
if (trackCount >= 4 && trackCount <= 6 && duration > 0 && duration <= 1500) return true;
|
||||
// Under 15 minutes is definitely EP/Single territory
|
||||
if (duration > 0 && duration <= 900) return true;
|
||||
|
||||
// 5-6 tracks AND under 20 minutes = likely EP
|
||||
if (trackCount >= 5 && trackCount <= 6 && duration > 0 && duration <= 1200) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -186,18 +222,21 @@ function isStudioAlbum(album: QobuzAlbum): boolean {
|
||||
const trackCount = album.tracks_count ?? 0;
|
||||
const duration = album.duration ?? 0;
|
||||
|
||||
// Single-track releases over 20 min are likely podcasts/radio shows, not studio albums
|
||||
if (trackCount === 1 && duration >= 1200) return false;
|
||||
|
||||
// Deluxe, remastered, anniversary editions are studio albums
|
||||
const studioVariantPatterns = /\b(deluxe|remaster|anniversary|expanded|special edition|collector|box set)\b/;
|
||||
|
||||
// If it has these patterns and reasonable length, it's a studio album
|
||||
if (studioVariantPatterns.test(title)) {
|
||||
// Box sets with many tracks are still studio albums
|
||||
if (trackCount >= 8 || duration >= 2400) return true; // 40+ minutes
|
||||
if (trackCount >= 7 || duration >= 2100) return true; // 35+ minutes
|
||||
}
|
||||
|
||||
// Standard album: 8+ tracks or 30+ minutes
|
||||
if (trackCount >= 8) return true;
|
||||
if (duration >= 1800) return true; // 30 minutes
|
||||
// Standard album: 7+ tracks or 25+ minutes (lowered slightly to catch prog albums)
|
||||
if (trackCount >= 7) return true;
|
||||
if (duration >= 1500) return true; // 25 minutes
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -305,18 +344,19 @@ export function convertQobuzAlbum(album: QobuzAlbum): AlbumDetail {
|
||||
* Categorize an album into the appropriate section
|
||||
* Priority order matters - check most specific categories first
|
||||
*/
|
||||
function categorizeAlbum(album: QobuzAlbum, mainArtistId: number): 'others' | 'live' | 'eps' | 'albums' {
|
||||
// 1. Different artist -> Others (highest priority)
|
||||
if (isDifferentArtist(album, mainArtistId)) {
|
||||
return 'others';
|
||||
function categorizeAlbum(album: QobuzAlbum, mainArtistId: number): 'tributes' | 'others' | 'live' | 'eps' | 'albums' {
|
||||
// 1. Albums by different artists -> Tributes (highest priority)
|
||||
// These are tribute albums, covers, or albums that somehow ended up in the discography
|
||||
if (isTributeAlbum(album, mainArtistId)) {
|
||||
return 'tributes';
|
||||
}
|
||||
|
||||
// 2. Unofficial releases (broadcasts, bootlegs) -> Others
|
||||
// 2. Unofficial releases (broadcasts, bootlegs, podcasts) -> Others
|
||||
if (isUnofficialRelease(album)) {
|
||||
return 'others';
|
||||
}
|
||||
|
||||
// 3. Compilations (greatest hits, tributes) -> Others
|
||||
// 3. Compilations (greatest hits, best of) -> Others
|
||||
if (isCompilationAlbum(album)) {
|
||||
return 'others';
|
||||
}
|
||||
@@ -352,6 +392,7 @@ export function convertQobuzArtist(artist: QobuzArtist): ArtistDetail {
|
||||
const albums: ArtistAlbumSummary[] = [];
|
||||
const epsSingles: ArtistAlbumSummary[] = [];
|
||||
const liveAlbums: ArtistAlbumSummary[] = [];
|
||||
const tributes: ArtistAlbumSummary[] = [];
|
||||
const others: ArtistAlbumSummary[] = [];
|
||||
|
||||
for (const album of albumItems) {
|
||||
@@ -365,6 +406,9 @@ export function convertQobuzArtist(artist: QobuzArtist): ArtistDetail {
|
||||
case 'eps':
|
||||
epsSingles.push(summary);
|
||||
break;
|
||||
case 'tributes':
|
||||
tributes.push(summary);
|
||||
break;
|
||||
case 'others':
|
||||
others.push(summary);
|
||||
break;
|
||||
@@ -385,6 +429,7 @@ export function convertQobuzArtist(artist: QobuzArtist): ArtistDetail {
|
||||
epsSingles,
|
||||
liveAlbums,
|
||||
compilations: buildCompilationAlbums(artist.tracks_appears_on?.items),
|
||||
tributes,
|
||||
others,
|
||||
playlists: toArtistPlaylists(artist.playlists),
|
||||
totalAlbums: artist.albums?.total || artist.albums_count || 0,
|
||||
@@ -403,11 +448,13 @@ export function appendArtistAlbums(
|
||||
const existingAlbumIds = new Set(artist.albums.map(album => album.id));
|
||||
const existingEpIds = new Set(artist.epsSingles.map(album => album.id));
|
||||
const existingLiveIds = new Set(artist.liveAlbums.map(album => album.id));
|
||||
const existingTributeIds = new Set(artist.tributes.map(album => album.id));
|
||||
const existingOtherIds = new Set(artist.others.map(album => album.id));
|
||||
|
||||
const albums = [...artist.albums];
|
||||
const epsSingles = [...artist.epsSingles];
|
||||
const liveAlbums = [...artist.liveAlbums];
|
||||
const tributes = [...artist.tributes];
|
||||
const others = [...artist.others];
|
||||
|
||||
for (const album of newAlbums) {
|
||||
@@ -427,6 +474,12 @@ export function appendArtistAlbums(
|
||||
existingEpIds.add(summary.id);
|
||||
}
|
||||
break;
|
||||
case 'tributes':
|
||||
if (!existingTributeIds.has(summary.id)) {
|
||||
tributes.push(summary);
|
||||
existingTributeIds.add(summary.id);
|
||||
}
|
||||
break;
|
||||
case 'others':
|
||||
if (!existingOtherIds.has(summary.id)) {
|
||||
others.push(summary);
|
||||
@@ -448,6 +501,7 @@ export function appendArtistAlbums(
|
||||
albums,
|
||||
epsSingles,
|
||||
liveAlbums,
|
||||
tributes,
|
||||
others,
|
||||
totalAlbums: totalAlbums ?? artist.totalAlbums,
|
||||
albumsFetched: albumsFetched ?? artist.albumsFetched + newAlbums.length
|
||||
|
||||
@@ -114,6 +114,7 @@
|
||||
let epsSinglesSection = $state<HTMLDivElement | null>(null);
|
||||
let liveAlbumsSection = $state<HTMLDivElement | null>(null);
|
||||
let compilationsSection = $state<HTMLDivElement | null>(null);
|
||||
let tributesSection = $state<HTMLDivElement | null>(null);
|
||||
let othersSection = $state<HTMLDivElement | null>(null);
|
||||
let playlistsSection = $state<HTMLDivElement | null>(null);
|
||||
let activeJumpSection = $state('about');
|
||||
@@ -151,6 +152,7 @@
|
||||
...artist.epsSingles,
|
||||
...artist.liveAlbums,
|
||||
...artist.compilations,
|
||||
...artist.tributes,
|
||||
...artist.others
|
||||
];
|
||||
loadAllAlbumDownloadStatuses(allAlbums);
|
||||
@@ -188,6 +190,7 @@
|
||||
...artist.epsSingles,
|
||||
...artist.liveAlbums,
|
||||
...artist.compilations,
|
||||
...artist.tributes,
|
||||
...artist.others
|
||||
];
|
||||
await loadAllAlbumDownloadStatuses(allAlbums);
|
||||
@@ -350,6 +353,7 @@
|
||||
let hasEpsSingles = $derived(artist.epsSingles.length > 0);
|
||||
let hasLiveAlbums = $derived(artist.liveAlbums.length > 0);
|
||||
let hasCompilations = $derived(artist.compilations.length > 0);
|
||||
let hasTributes = $derived(artist.tributes.length > 0);
|
||||
let hasOthers = $derived(artist.others.length > 0);
|
||||
let hasPlaylists = $derived(artist.playlists.length > 0);
|
||||
let jumpSections = $derived.by(() => [
|
||||
@@ -359,6 +363,7 @@
|
||||
{ id: 'eps', label: 'EPs & Singles', el: epsSinglesSection, visible: hasEpsSingles },
|
||||
{ id: 'live', label: 'Live Albums', el: liveAlbumsSection, visible: hasLiveAlbums },
|
||||
{ id: 'compilations', label: 'Compilations', el: compilationsSection, visible: hasCompilations },
|
||||
{ id: 'tributes', label: 'Tributes', el: tributesSection, visible: hasTributes },
|
||||
{ id: 'others', label: 'Others', el: othersSection, visible: hasOthers },
|
||||
{ id: 'playlists', label: 'Playlists', el: playlistsSection, visible: hasPlaylists },
|
||||
].filter(section => section.visible));
|
||||
@@ -752,6 +757,36 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if artist.tributes.length > 0}
|
||||
<div class="divider"></div>
|
||||
|
||||
<div class="discography section-anchor" bind:this={tributesSection}>
|
||||
<h2 class="section-title">Tributes & Covers</h2>
|
||||
<div class="albums-grid">
|
||||
{#each artist.tributes as album}
|
||||
<AlbumCard
|
||||
albumId={album.id}
|
||||
artwork={album.artwork}
|
||||
title={album.title}
|
||||
artist={album.year || ''}
|
||||
quality={album.quality}
|
||||
onPlay={onAlbumPlay ? () => onAlbumPlay(album.id) : undefined}
|
||||
onPlayNext={onAlbumPlayNext ? () => onAlbumPlayNext(album.id) : undefined}
|
||||
onPlayLater={onAlbumPlayLater ? () => onAlbumPlayLater(album.id) : undefined}
|
||||
onShareQobuz={onAlbumShareQobuz ? () => onAlbumShareQobuz(album.id) : undefined}
|
||||
onShareSonglink={onAlbumShareSonglink ? () => onAlbumShareSonglink(album.id) : undefined}
|
||||
onDownload={onAlbumDownload ? () => onAlbumDownload(album.id) : undefined}
|
||||
isAlbumFullyDownloaded={isAlbumDownloaded(album.id)}
|
||||
onOpenContainingFolder={onOpenAlbumFolder ? () => onOpenAlbumFolder(album.id) : undefined}
|
||||
onReDownloadAlbum={onReDownloadAlbum ? () => onReDownloadAlbum(album.id) : undefined}
|
||||
{downloadStateVersion}
|
||||
onclick={() => { onAlbumClick?.(album.id); loadAlbumDownloadStatus(album.id); }}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if artist.others.length > 0}
|
||||
<div class="divider"></div>
|
||||
|
||||
|
||||
@@ -152,6 +152,13 @@ export interface ArtistDetail {
|
||||
year?: string;
|
||||
quality: string;
|
||||
}[];
|
||||
tributes: {
|
||||
id: string;
|
||||
title: string;
|
||||
artwork: string;
|
||||
year?: string;
|
||||
quality: string;
|
||||
}[];
|
||||
others: {
|
||||
id: string;
|
||||
title: string;
|
||||
|
||||
Reference in new issue
Block a user