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)
|
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.
|
// SimilarArtists returns artists similar to the given artist MBID.
|
||||||
func (e *Service) SimilarArtists(artistMBID string) ([]LBSimilarArtist, error) {
|
func (e *Service) SimilarArtists(artistMBID string) ([]LBSimilarArtist, error) {
|
||||||
return e.lb.SimilarArtists(e.ctx, artistMBID)
|
return e.lb.SimilarArtists(e.ctx, artistMBID)
|
||||||
|
|||||||
@@ -97,6 +97,52 @@ func (c *ListenBrainzClient) TopRecordingsForArtist(
|
|||||||
return out, nil
|
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
|
// SimilarArtists returns artists similar to the one identified by
|
||||||
// artistMBID, using the ListenBrainz labs API. Returns nil, nil
|
// artistMBID, using the ListenBrainz labs API. Returns nil, nil
|
||||||
// if the endpoint is unavailable (labs API may be unstable).
|
// if the endpoint is unavailable (labs API may be unstable).
|
||||||
|
|||||||
@@ -106,3 +106,45 @@ type LBSimilarArtist struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Score float64 `json:"score"`
|
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,
|
LookupArtist,
|
||||||
BrowseReleaseGroups,
|
BrowseReleaseGroups,
|
||||||
TopRecordingsForArtist,
|
TopRecordingsForArtist,
|
||||||
|
TopReleaseGroupsForArtist,
|
||||||
SimilarArtists,
|
SimilarArtists,
|
||||||
GetArtistImageURL,
|
GetArtistImageURL,
|
||||||
CheckLibraryMBIDs,
|
CheckLibraryMBIDs,
|
||||||
@@ -13,6 +14,7 @@ import type {
|
|||||||
MBArtist,
|
MBArtist,
|
||||||
MBReleaseGroup,
|
MBReleaseGroup,
|
||||||
LBTopRecording,
|
LBTopRecording,
|
||||||
|
LBTopReleaseGroup,
|
||||||
LBSimilarArtist,
|
LBSimilarArtist,
|
||||||
} from '@go/explore/Service';
|
} from '@go/explore/Service';
|
||||||
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
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 artist: MBArtist | null = null;
|
||||||
@state() private topTracks: LBTopRecording[] = [];
|
@state() private topTracks: LBTopRecording[] = [];
|
||||||
|
@state() private topReleaseGroups: LBTopReleaseGroup[] = [];
|
||||||
@state() private releaseGroups: MBReleaseGroup[] = [];
|
@state() private releaseGroups: MBReleaseGroup[] = [];
|
||||||
@state() private loadingArtist = true;
|
@state() private loadingArtist = true;
|
||||||
@state() private loadingTracks = true;
|
@state() private loadingTracks = true;
|
||||||
|
@state() private loadingTopReleases = true;
|
||||||
@state() private loadingReleases = true;
|
@state() private loadingReleases = true;
|
||||||
@state() private errorArtist = '';
|
@state() private errorArtist = '';
|
||||||
@state() private errorTracks = '';
|
@state() private errorTracks = '';
|
||||||
@@ -330,18 +334,19 @@ export class ExploreArtistDetails extends LitElement {
|
|||||||
|
|
||||||
.top-releases-grid {
|
.top-releases-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
|
||||||
gap: 8px;
|
gap: 10px;
|
||||||
|
align-content: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.top-release-card {
|
.top-release-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
flex-direction: column;
|
||||||
gap: 10px;
|
gap: 4px;
|
||||||
padding: 6px 8px;
|
|
||||||
border-radius: 6px;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.15s ease;
|
transition: background 0.15s ease;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 6px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -353,12 +358,12 @@ export class ExploreArtistDetails extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.top-release-card:active {
|
.top-release-card:active {
|
||||||
transform: scale(0.98);
|
transform: scale(0.97);
|
||||||
}
|
}
|
||||||
|
|
||||||
.top-release-art {
|
.top-release-art {
|
||||||
width: 44px;
|
width: 100%;
|
||||||
height: 44px;
|
aspect-ratio: 1;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
@@ -366,7 +371,6 @@ export class ExploreArtistDetails extends LitElement {
|
|||||||
var(--yj-bg-overlay, #404040) 0%,
|
var(--yj-bg-overlay, #404040) 0%,
|
||||||
var(--yj-bg-surface, #282828) 100%
|
var(--yj-bg-surface, #282828) 100%
|
||||||
);
|
);
|
||||||
flex-shrink: 0;
|
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -386,15 +390,14 @@ export class ExploreArtistDetails extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.top-release-art .album-art-fallback wa-icon {
|
.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 {
|
.top-release-text {
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
display: flex;
|
text-align: center;
|
||||||
flex-direction: column;
|
|
||||||
gap: 1px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.top-release-title {
|
.top-release-title {
|
||||||
@@ -409,6 +412,7 @@ export class ExploreArtistDetails extends LitElement {
|
|||||||
.top-release-meta {
|
.top-release-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
color: var(--yj-text-tertiary, #888);
|
color: var(--yj-text-tertiary, #888);
|
||||||
font-size: var(--yj-text-xs);
|
font-size: var(--yj-text-xs);
|
||||||
@@ -642,11 +646,12 @@ export class ExploreArtistDetails extends LitElement {
|
|||||||
`[explore-artist] loading: "${this.artistName}" (${mbid})`,
|
`[explore-artist] loading: "${this.artistName}" (${mbid})`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Fire all five requests in parallel — each section is independent.
|
// Fire all requests in parallel — each section is independent.
|
||||||
const [artistResult, tracksResult, releasesResult, similarResult] =
|
const [artistResult, tracksResult, topReleasesResult, releasesResult, similarResult] =
|
||||||
await Promise.allSettled([
|
await Promise.allSettled([
|
||||||
this.fetchArtist(mbid),
|
this.fetchArtist(mbid),
|
||||||
this.fetchTopTracks(mbid),
|
this.fetchTopTracks(mbid),
|
||||||
|
this.fetchTopReleaseGroups(mbid),
|
||||||
this.fetchReleaseGroups(mbid),
|
this.fetchReleaseGroups(mbid),
|
||||||
this.fetchSimilarArtists(mbid),
|
this.fetchSimilarArtists(mbid),
|
||||||
]);
|
]);
|
||||||
@@ -660,6 +665,7 @@ export class ExploreArtistDetails extends LitElement {
|
|||||||
const summary = [
|
const summary = [
|
||||||
`artist=${artistResult.status}`,
|
`artist=${artistResult.status}`,
|
||||||
`tracks=${tracksResult.status}`,
|
`tracks=${tracksResult.status}`,
|
||||||
|
`topReleases=${topReleasesResult.status}`,
|
||||||
`releases=${releasesResult.status}`,
|
`releases=${releasesResult.status}`,
|
||||||
`similar=${similarResult.status}`,
|
`similar=${similarResult.status}`,
|
||||||
].join(', ');
|
].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) {
|
private async fetchReleaseGroups(mbid: string) {
|
||||||
try {
|
try {
|
||||||
const rgs = await BrowseReleaseGroups(mbid);
|
const rgs = await BrowseReleaseGroups(mbid);
|
||||||
@@ -1001,19 +1022,10 @@ export class ExploreArtistDetails extends LitElement {
|
|||||||
this.topSectionExpanded = !this.topSectionExpanded;
|
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() {
|
private renderTopSection() {
|
||||||
const hasTracks = !this.loadingTracks && this.topTracks.length > 0;
|
const hasTracks = !this.loadingTracks && this.topTracks.length > 0;
|
||||||
const hasReleases = !this.loadingReleases && this.releaseGroups.length > 0;
|
const hasReleases = !this.loadingTopReleases && this.topReleaseGroups.length > 0;
|
||||||
const isLoading = this.loadingTracks || this.loadingReleases;
|
const isLoading = this.loadingTracks || this.loadingTopReleases;
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return html`
|
return html`
|
||||||
@@ -1031,10 +1043,10 @@ export class ExploreArtistDetails extends LitElement {
|
|||||||
const releaseLimit = expanded ? 8 : 4;
|
const releaseLimit = expanded ? 8 : 4;
|
||||||
|
|
||||||
const tracks = this.topTracks.slice(0, trackLimit);
|
const tracks = this.topTracks.slice(0, trackLimit);
|
||||||
const releases = this.topReleases.slice(0, releaseLimit);
|
const releases = this.topReleaseGroups.slice(0, releaseLimit);
|
||||||
|
|
||||||
const canExpand =
|
const canExpand =
|
||||||
this.topTracks.length > 5 || this.releaseGroups.length > 4;
|
this.topTracks.length > 5 || this.topReleaseGroups.length > 4;
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<section>
|
<section>
|
||||||
@@ -1106,20 +1118,19 @@ export class ExploreArtistDetails extends LitElement {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private renderTopReleaseCard(rg: MBReleaseGroup) {
|
private renderTopReleaseCard(rg: LBTopReleaseGroup) {
|
||||||
const artURL = CoverArtGroupURL(rg.mbid);
|
const artURL = CoverArtGroupURL(rg.releaseGroupMbid);
|
||||||
const year = extractYear(rg.firstReleaseDate);
|
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div
|
<div
|
||||||
class="top-release-card"
|
class="top-release-card"
|
||||||
@click=${() => this.navigateToAlbum(rg)}
|
@click=${() => this.navigateToTopRelease(rg)}
|
||||||
role="button"
|
role="button"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
@keydown=${(e: KeyboardEvent) => {
|
@keydown=${(e: KeyboardEvent) => {
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
this.navigateToAlbum(rg);
|
this.navigateToTopRelease(rg);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -1139,16 +1150,27 @@ export class ExploreArtistDetails extends LitElement {
|
|||||||
${rg.title}
|
${rg.title}
|
||||||
</div>
|
</div>
|
||||||
<div class="top-release-meta">
|
<div class="top-release-meta">
|
||||||
${this.libraryMBIDs.has(rg.mbid)
|
${rg.type ? html`<span>${rg.type}</span>` : nothing}
|
||||||
? html`<span class="library-badge">In Library</span>`
|
|
||||||
: nothing}
|
|
||||||
${year ? html`<span>${year}</span>` : nothing}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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 ── */
|
/* ── Discography Section ── */
|
||||||
|
|
||||||
private renderDiscography() {
|
private renderDiscography() {
|
||||||
|
|||||||
+2
@@ -50,3 +50,5 @@ export function StartIndexBuild():Promise<void>;
|
|||||||
export function StopIndexBuild():Promise<void>;
|
export function StopIndexBuild():Promise<void>;
|
||||||
|
|
||||||
export function TopRecordingsForArtist(arg1:string):Promise<Array<explore.LBTopRecording>>;
|
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) {
|
export function TopRecordingsForArtist(arg1) {
|
||||||
return window['go']['explore']['Service']['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"];
|
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 {
|
export class MBArtist {
|
||||||
mbid: string;
|
mbid: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user