feat: top releases sorted by popularity with library-style album cards
Replaced the date-sorted release group approach with a dedicated TopReleaseGroupsForArtist LB API call that returns releases ranked by total listen count (popularity). Added LBTopReleaseGroup type and Wails bindings. Restyled the top-releases cards to match the library album view: square cover art on top with title and type below, centered text, auto-filling the available width with even spacing.
This commit is contained in:
@@ -184,6 +184,11 @@ func (e *Service) TopRecordingsForArtist(artistMBID string) ([]LBTopRecording, e
|
||||
return e.lb.TopRecordingsForArtist(e.ctx, artistMBID)
|
||||
}
|
||||
|
||||
// TopReleaseGroupsForArtist returns the most-listened release groups for an artist.
|
||||
func (e *Service) TopReleaseGroupsForArtist(artistMBID string) ([]LBTopReleaseGroup, error) {
|
||||
return e.lb.TopReleaseGroupsForArtist(e.ctx, artistMBID)
|
||||
}
|
||||
|
||||
// SimilarArtists returns artists similar to the given artist MBID.
|
||||
func (e *Service) SimilarArtists(artistMBID string) ([]LBSimilarArtist, error) {
|
||||
return e.lb.SimilarArtists(e.ctx, artistMBID)
|
||||
|
||||
@@ -97,6 +97,52 @@ func (c *ListenBrainzClient) TopRecordingsForArtist(
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// TopReleaseGroupsForArtist returns the most-listened release groups
|
||||
// for the artist identified by artistMBID.
|
||||
func (c *ListenBrainzClient) TopReleaseGroupsForArtist(
|
||||
ctx context.Context, artistMBID string,
|
||||
) ([]LBTopReleaseGroup, error) {
|
||||
url := fmt.Sprintf(
|
||||
"%s/1/popularity/top-release-groups-for-artist/%s",
|
||||
listenBrainzBaseURL,
|
||||
artistMBID,
|
||||
)
|
||||
cacheKey := "lb:top-release-groups:" + artistMBID
|
||||
|
||||
if data, ok := c.cache.Get(cacheKey); ok {
|
||||
var out []LBTopReleaseGroup
|
||||
if err := json.Unmarshal(data, &out); err == nil {
|
||||
return out, nil
|
||||
}
|
||||
}
|
||||
|
||||
body, err := c.doGet(ctx, url)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("listenbrainz top release groups: %w", err)
|
||||
}
|
||||
|
||||
var wire []lbTopReleaseGroupWire
|
||||
if err := json.Unmarshal(body, &wire); err != nil {
|
||||
return nil, fmt.Errorf("listenbrainz top release groups unmarshal: %w", err)
|
||||
}
|
||||
|
||||
const maxTopReleaseGroups = 10
|
||||
|
||||
limit := len(wire)
|
||||
if limit > maxTopReleaseGroups {
|
||||
limit = maxTopReleaseGroups
|
||||
}
|
||||
|
||||
out := make([]LBTopReleaseGroup, limit)
|
||||
for i := range limit {
|
||||
out[i] = wire[i].toPublic()
|
||||
}
|
||||
|
||||
c.cacheJSON(cacheKey, out, cacheTTLSearch, artistMBID, "artist")
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SimilarArtists returns artists similar to the one identified by
|
||||
// artistMBID, using the ListenBrainz labs API. Returns nil, nil
|
||||
// if the endpoint is unavailable (labs API may be unstable).
|
||||
|
||||
@@ -106,3 +106,45 @@ type LBSimilarArtist struct {
|
||||
Name string `json:"name"`
|
||||
Score float64 `json:"score"`
|
||||
}
|
||||
|
||||
// LBTopReleaseGroup represents a popular release group from the
|
||||
// ListenBrainz popularity API.
|
||||
type LBTopReleaseGroup struct {
|
||||
ReleaseGroupMBID string `json:"releaseGroupMbid"`
|
||||
Title string `json:"title"`
|
||||
ArtistName string `json:"artistName"`
|
||||
Type string `json:"type"`
|
||||
TotalListenCount int `json:"totalListenCount"`
|
||||
}
|
||||
|
||||
// lbTopReleaseGroupWire matches the ListenBrainz API's snake_case
|
||||
// JSON response for the popularity/top-release-groups-for-artist
|
||||
// endpoint.
|
||||
type lbTopReleaseGroupWire struct {
|
||||
ReleaseGroupMBID string `json:"release_group_mbid"`
|
||||
TotalListenCount int `json:"total_listen_count"`
|
||||
ReleaseGroup struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
} `json:"release_group"`
|
||||
Artist struct {
|
||||
Artists []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"artists"`
|
||||
} `json:"artist"`
|
||||
}
|
||||
|
||||
func (w lbTopReleaseGroupWire) toPublic() LBTopReleaseGroup {
|
||||
artistName := ""
|
||||
if len(w.Artist.Artists) > 0 {
|
||||
artistName = w.Artist.Artists[0].Name
|
||||
}
|
||||
|
||||
return LBTopReleaseGroup{
|
||||
ReleaseGroupMBID: w.ReleaseGroupMBID,
|
||||
Title: w.ReleaseGroup.Name,
|
||||
ArtistName: artistName,
|
||||
Type: w.ReleaseGroup.Type,
|
||||
TotalListenCount: w.TotalListenCount,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
LookupArtist,
|
||||
BrowseReleaseGroups,
|
||||
TopRecordingsForArtist,
|
||||
TopReleaseGroupsForArtist,
|
||||
SimilarArtists,
|
||||
GetArtistImageURL,
|
||||
CheckLibraryMBIDs,
|
||||
@@ -13,6 +14,7 @@ import type {
|
||||
MBArtist,
|
||||
MBReleaseGroup,
|
||||
LBTopRecording,
|
||||
LBTopReleaseGroup,
|
||||
LBSimilarArtist,
|
||||
} from '@go/explore/Service';
|
||||
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
||||
@@ -81,9 +83,11 @@ export class ExploreArtistDetails extends LitElement {
|
||||
|
||||
@state() private artist: MBArtist | null = null;
|
||||
@state() private topTracks: LBTopRecording[] = [];
|
||||
@state() private topReleaseGroups: LBTopReleaseGroup[] = [];
|
||||
@state() private releaseGroups: MBReleaseGroup[] = [];
|
||||
@state() private loadingArtist = true;
|
||||
@state() private loadingTracks = true;
|
||||
@state() private loadingTopReleases = true;
|
||||
@state() private loadingReleases = true;
|
||||
@state() private errorArtist = '';
|
||||
@state() private errorTracks = '';
|
||||
@@ -330,18 +334,19 @@ export class ExploreArtistDetails extends LitElement {
|
||||
|
||||
.top-releases-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
|
||||
gap: 10px;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.top-release-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
padding: 4px;
|
||||
border-radius: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@@ -353,12 +358,12 @@ export class ExploreArtistDetails extends LitElement {
|
||||
}
|
||||
|
||||
.top-release-card:active {
|
||||
transform: scale(0.98);
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.top-release-art {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(
|
||||
@@ -366,7 +371,6 @@ export class ExploreArtistDetails extends LitElement {
|
||||
var(--yj-bg-overlay, #404040) 0%,
|
||||
var(--yj-bg-surface, #282828) 100%
|
||||
);
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@@ -386,15 +390,14 @@ export class ExploreArtistDetails extends LitElement {
|
||||
}
|
||||
|
||||
.top-release-art .album-art-fallback wa-icon {
|
||||
font-size: 16px;
|
||||
font-size: 20px;
|
||||
color: var(--yj-text-tertiary, #888);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.top-release-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.top-release-title {
|
||||
@@ -409,6 +412,7 @@ export class ExploreArtistDetails extends LitElement {
|
||||
.top-release-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
color: var(--yj-text-tertiary, #888);
|
||||
font-size: var(--yj-text-xs);
|
||||
@@ -642,11 +646,12 @@ export class ExploreArtistDetails extends LitElement {
|
||||
`[explore-artist] loading: "${this.artistName}" (${mbid})`,
|
||||
);
|
||||
|
||||
// Fire all five requests in parallel — each section is independent.
|
||||
const [artistResult, tracksResult, releasesResult, similarResult] =
|
||||
// Fire all requests in parallel — each section is independent.
|
||||
const [artistResult, tracksResult, topReleasesResult, releasesResult, similarResult] =
|
||||
await Promise.allSettled([
|
||||
this.fetchArtist(mbid),
|
||||
this.fetchTopTracks(mbid),
|
||||
this.fetchTopReleaseGroups(mbid),
|
||||
this.fetchReleaseGroups(mbid),
|
||||
this.fetchSimilarArtists(mbid),
|
||||
]);
|
||||
@@ -660,6 +665,7 @@ export class ExploreArtistDetails extends LitElement {
|
||||
const summary = [
|
||||
`artist=${artistResult.status}`,
|
||||
`tracks=${tracksResult.status}`,
|
||||
`topReleases=${topReleasesResult.status}`,
|
||||
`releases=${releasesResult.status}`,
|
||||
`similar=${similarResult.status}`,
|
||||
].join(', ');
|
||||
@@ -695,6 +701,21 @@ export class ExploreArtistDetails extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchTopReleaseGroups(mbid: string) {
|
||||
try {
|
||||
const rgs = await TopReleaseGroupsForArtist(mbid);
|
||||
this.topReleaseGroups = rgs ?? [];
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(
|
||||
`[explore-artist] TopReleaseGroupsForArtist error: ${msg}`,
|
||||
);
|
||||
this.topReleaseGroups = [];
|
||||
} finally {
|
||||
this.loadingTopReleases = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchReleaseGroups(mbid: string) {
|
||||
try {
|
||||
const rgs = await BrowseReleaseGroups(mbid);
|
||||
@@ -1001,19 +1022,10 @@ export class ExploreArtistDetails extends LitElement {
|
||||
this.topSectionExpanded = !this.topSectionExpanded;
|
||||
}
|
||||
|
||||
/** Top releases = all release groups sorted newest-first. */
|
||||
private get topReleases(): MBReleaseGroup[] {
|
||||
return [...this.releaseGroups].sort((a, b) => {
|
||||
const da = a.firstReleaseDate || '';
|
||||
const db = b.firstReleaseDate || '';
|
||||
return db.localeCompare(da);
|
||||
});
|
||||
}
|
||||
|
||||
private renderTopSection() {
|
||||
const hasTracks = !this.loadingTracks && this.topTracks.length > 0;
|
||||
const hasReleases = !this.loadingReleases && this.releaseGroups.length > 0;
|
||||
const isLoading = this.loadingTracks || this.loadingReleases;
|
||||
const hasReleases = !this.loadingTopReleases && this.topReleaseGroups.length > 0;
|
||||
const isLoading = this.loadingTracks || this.loadingTopReleases;
|
||||
|
||||
if (isLoading) {
|
||||
return html`
|
||||
@@ -1031,10 +1043,10 @@ export class ExploreArtistDetails extends LitElement {
|
||||
const releaseLimit = expanded ? 8 : 4;
|
||||
|
||||
const tracks = this.topTracks.slice(0, trackLimit);
|
||||
const releases = this.topReleases.slice(0, releaseLimit);
|
||||
const releases = this.topReleaseGroups.slice(0, releaseLimit);
|
||||
|
||||
const canExpand =
|
||||
this.topTracks.length > 5 || this.releaseGroups.length > 4;
|
||||
this.topTracks.length > 5 || this.topReleaseGroups.length > 4;
|
||||
|
||||
return html`
|
||||
<section>
|
||||
@@ -1106,20 +1118,19 @@ export class ExploreArtistDetails extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private renderTopReleaseCard(rg: MBReleaseGroup) {
|
||||
const artURL = CoverArtGroupURL(rg.mbid);
|
||||
const year = extractYear(rg.firstReleaseDate);
|
||||
private renderTopReleaseCard(rg: LBTopReleaseGroup) {
|
||||
const artURL = CoverArtGroupURL(rg.releaseGroupMbid);
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="top-release-card"
|
||||
@click=${() => this.navigateToAlbum(rg)}
|
||||
@click=${() => this.navigateToTopRelease(rg)}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@keydown=${(e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
this.navigateToAlbum(rg);
|
||||
this.navigateToTopRelease(rg);
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -1139,16 +1150,27 @@ export class ExploreArtistDetails extends LitElement {
|
||||
${rg.title}
|
||||
</div>
|
||||
<div class="top-release-meta">
|
||||
${this.libraryMBIDs.has(rg.mbid)
|
||||
? html`<span class="library-badge">In Library</span>`
|
||||
: nothing}
|
||||
${year ? html`<span>${year}</span>` : nothing}
|
||||
${rg.type ? html`<span>${rg.type}</span>` : nothing}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private navigateToTopRelease(rg: LBTopReleaseGroup) {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('navigate', {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
detail: {
|
||||
view: 'explore-album-details',
|
||||
releaseGroupMBID: rg.releaseGroupMbid,
|
||||
albumName: rg.title,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Discography Section ── */
|
||||
|
||||
private renderDiscography() {
|
||||
|
||||
+2
@@ -50,3 +50,5 @@ export function StartIndexBuild():Promise<void>;
|
||||
export function StopIndexBuild():Promise<void>;
|
||||
|
||||
export function TopRecordingsForArtist(arg1:string):Promise<Array<explore.LBTopRecording>>;
|
||||
|
||||
export function TopReleaseGroupsForArtist(arg1:string):Promise<Array<explore.LBTopReleaseGroup>>;
|
||||
|
||||
@@ -97,3 +97,7 @@ export function StopIndexBuild() {
|
||||
export function TopRecordingsForArtist(arg1) {
|
||||
return window['go']['explore']['Service']['TopRecordingsForArtist'](arg1);
|
||||
}
|
||||
|
||||
export function TopReleaseGroupsForArtist(arg1) {
|
||||
return window['go']['explore']['Service']['TopReleaseGroupsForArtist'](arg1);
|
||||
}
|
||||
|
||||
@@ -34,6 +34,26 @@ export namespace explore {
|
||||
this.totalListenCount = source["totalListenCount"];
|
||||
}
|
||||
}
|
||||
export class LBTopReleaseGroup {
|
||||
releaseGroupMbid: string;
|
||||
title: string;
|
||||
artistName: string;
|
||||
type: string;
|
||||
totalListenCount: number;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new LBTopReleaseGroup(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.releaseGroupMbid = source["releaseGroupMbid"];
|
||||
this.title = source["title"];
|
||||
this.artistName = source["artistName"];
|
||||
this.type = source["type"];
|
||||
this.totalListenCount = source["totalListenCount"];
|
||||
}
|
||||
}
|
||||
export class MBArtist {
|
||||
mbid: string;
|
||||
name: string;
|
||||
|
||||
Reference in New Issue
Block a user