Skip to content

LB-1737: Create top artists graph showing album details #3170

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 26 commits into from
Mar 20, 2025
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
11ed834
new stats added for user
granth23 Feb 10, 2025
d0ca023
nivo rocks chart bug fix
granth23 Feb 11, 2025
bedff45
Merge branch 'metabrainz:master' into LB-1737-User-Artist-Map
granth23 Feb 11, 2025
c32aecb
db returns fixed
granth23 Feb 14, 2025
38d481e
bugs fixed amd total artist limit added
granth23 Feb 17, 2025
7721189
sitewide activity added
granth23 Feb 17, 2025
f49def5
test component added
granth23 Feb 17, 2025
472bc57
test added
granth23 Feb 18, 2025
e6108a9
album redirect added
granth23 Feb 23, 2025
d7b6c14
all artists names included
granth23 Mar 12, 2025
79e5785
text-wrap and better optimization
granth23 Mar 13, 2025
0ca50ff
Merge branch 'metabrainz:master' into LB-1737-User-Artist-Map
granth23 Mar 13, 2025
eee1df3
key-index bug fix
granth23 Mar 13, 2025
d947652
Rotation added to artist names
granth23 Mar 13, 2025
1991813
minor bug fix
granth23 Mar 13, 2025
fb406dc
Merge branch 'metabrainz:master' into LB-1737-User-Artist-Map
granth23 Mar 13, 2025
bf1d9a7
Update frontend/js/src/user/stats/components/UserArtistActivity.tsx
granth23 Mar 18, 2025
e4749ac
Update UserArtistActivity.tsx
granth23 Mar 18, 2025
03fffdb
Update UserArtistActivity.tsx
granth23 Mar 18, 2025
dddc1cc
Update UserArtistActivity.tsx
granth23 Mar 18, 2025
5829bcf
Update UserArtistActivity.tsx
granth23 Mar 18, 2025
53f80ca
Update UserArtistActivity.tsx
granth23 Mar 18, 2025
e10574e
Update UserArtistActivity.tsx
granth23 Mar 18, 2025
e26a8e8
Merge branch 'metabrainz:master' into LB-1737-User-Artist-Map
granth23 Mar 18, 2025
8c16135
Merge branch 'master' into LB-1737-User-Artist-Map
anshg1214 Mar 20, 2025
3b334ae
feat: Optimize artist activity sorting using heapq
anshg1214 Mar 20, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions frontend/js/src/user/stats/UserReports.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import UserListeningActivity from "./components/UserListeningActivity";
import UserTopEntity from "./components/UserTopEntity";
import UserDailyActivity from "./components/UserDailyActivity";
import UserArtistMap from "./components/UserArtistMap";
import UserArtistActivity from "./components/UserArtistActivity";
import { getAllStatRanges, isInvalidStatRange } from "./utils";
import GlobalAppContext from "../../utils/GlobalAppContext";
import StatsExplanationsModal from "../../common/stats/StatsExplanationsModal";
Expand Down Expand Up @@ -167,6 +168,12 @@ export default function UserReports() {
<UserDailyActivity range={range} user={user} />
</section>
)}
{user && (
<section id="artist-activity">
{statsExplanationModalButton}
<UserArtistActivity range={range} user={user} />
</section>
)}
<section id="artist-origin">
{statsExplanationModalButton}
<UserArtistMap range={range} user={user} />
Expand Down
143 changes: 143 additions & 0 deletions frontend/js/src/user/stats/components/UserArtistActivity.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { ResponsiveBar } from "@nivo/bar";
import * as React from "react";
import { faExclamationCircle, faLink } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { IconProp } from "@fortawesome/fontawesome-svg-core";
import { useQuery } from "@tanstack/react-query";
import Card from "../../../components/Card";
import Loader from "../../../components/Loader";
import { COLOR_BLACK } from "../../../utils/constants";
import GlobalAppContext from "../../../utils/GlobalAppContext";

export type UserArtistActivityProps = {
range: UserStatsAPIRange;
user: ListenBrainzUser;
};

export type UserArtistActivityState = {
data: UserArtistActivityResponse;
loading: boolean;
errorMessage: string;
hasError: boolean;
};

export default function UserArtistActivity({
range,
user,
}: UserArtistActivityProps) {
const { APIService } = React.useContext(GlobalAppContext);

const { data: loaderData, isLoading: loading } = useQuery({
queryKey: ["userArtistActivity", user.name, range],
queryFn: async () => {
try {
const queryData = await APIService.getUserArtistActivity(
user.name,
range
);
return { data: queryData, hasError: false, errorMessage: "" };
} catch (error) {
return {
data: { result: [] } as UserArtistActivityResponse,
hasError: true,
errorMessage: error.message,
};
}
},
});

const {
data: rawData = { result: [] } as UserArtistActivityResponse,
hasError = false,
errorMessage = "",
} = loaderData || {};

const processData = (data?: UserArtistActivityResponse) => {
if (!data || !data.result || data.result.length === 0) {
return [];
}
return data.result.map((artist) => ({
label: artist.name.split(",")[0],
...artist.albums.reduce(
(acc, album) => ({ ...acc, [album.name]: album.listen_count }),
{} as Record<string, number>
),
})) as ChartDataItem[];
};

const [chartData, setChartData] = React.useState<ChartDataItem[]>([]);

React.useEffect(() => {
if (rawData && rawData.result.length > 0) {
const processedData = processData(rawData);
setChartData(processedData);
}
}, [rawData]);

return (
<Card className="user-stats-card" data-testid="user-artist-activity">
<div className="row">
<div className="col-xs-10">
<h3 className="capitalize-bold" style={{ marginLeft: 20 }}>
Artist Activity
</h3>
</div>
<div className="col-xs-2 text-right">
<h4 style={{ marginTop: 20 }}>
<a href="#artist-activity">
<FontAwesomeIcon
icon={faLink as IconProp}
size="sm"
color={COLOR_BLACK}
style={{ marginRight: 20 }}
/>
</a>
</h4>
</div>
</div>
<Loader isLoading={loading}>
{hasError ? (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
minHeight: "inherit",
}}
>
<span style={{ fontSize: 24 }}>
<FontAwesomeIcon icon={faExclamationCircle as IconProp} />{" "}
{errorMessage}
</span>
</div>
) : (
<div className="row">
<div className="col-xs-12">
<div
style={{ width: "100%", height: "600px", minHeight: "400px" }}
>
<ResponsiveBar
data={chartData}
keys={Array.from(
new Set(
chartData.flatMap((item) =>
Object.keys(item).filter((key) => key !== "label")
)
)
)}
indexBy="label"
margin={{ top: 20, right: 80, bottom: 60, left: 80 }}
padding={0.2}
layout="vertical"
colors={{ scheme: "pink_yellowGreen" }}
borderColor={{ from: "color", modifiers: [["darker", 1.6]] }}
enableLabel={false}
/>
</div>
</div>
</div>
)}
</Loader>
</Card>
);
}
18 changes: 18 additions & 0 deletions frontend/js/src/utils/APIService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -574,6 +574,24 @@ export default class APIService {
return response.json();
};

getUserArtistActivity = async (
userName: string,
range: UserStatsAPIRange = "all_time"
): Promise<UserArtistActivityResponse> => {
const url = `${this.APIBaseURI}/stats/user/${userName}/artist-activity?range=${range}`;
const response = await fetch(url);
await this.checkStatus(response);
if (response.status === 204) {
const error = new APIError(
"There are no statistics available for this user for this period"
);
error.status = response.statusText;
error.response = response;
throw error;
}
return response.json();
};

getUserArtistMap = async (
userName?: string,
range: UserStatsAPIRange = "all_time",
Expand Down
16 changes: 16 additions & 0 deletions frontend/js/src/utils/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,22 @@ declare type UserDailyActivityResponse = {
};
};

declare type ChartDataItem = {
label: string;
[albumName: string]: number | string;
};

declare type UserArtistActivityResponse = {
result: Array<{
name: string;
listen_count: number;
albums: Array<{
name: string;
listen_count: number;
}>;
}>;
};

declare type UserArtistMapArtist = {
artist_name: string;
artist_mbid: string;
Expand Down
82 changes: 78 additions & 4 deletions listenbrainz/webserver/views/stats_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -301,7 +301,7 @@ def get_recording(user_name):
return _get_entity_stats(user_name, "recordings", "total_recording_count")


def _get_entity_stats(user_name: str, entity: str, count_key: str):
def _get_entity_stats(user_name: str, entity: str, count_key: str, entire_range: bool = False):
user, stats_range = _validate_stats_user_params(user_name)

offset = get_non_negative_param("offset", default=0)
Expand All @@ -311,7 +311,7 @@ def _get_entity_stats(user_name: str, entity: str, count_key: str):
if stats is None:
raise APINoContent('')

entity_list, total_entity_count = _process_user_entity(stats, offset, count)
entity_list, total_entity_count = _process_user_entity(stats, offset, count, entire_range)
return jsonify({"payload": {
"user_id": user_name,
entity: entity_list,
Expand Down Expand Up @@ -409,6 +409,77 @@ def get_listening_activity(user_name: str):
}})


@stats_api_bp.get("/user/<user_name>/artist-activity")
@crossdomain
@ratelimit()
def get_artist_activity(user_name: str):
"""
Get the artist activity for user ``user_name``. The artist activity shows the total number of listens
for each artist along with their albums and corresponding listen counts.

A sample response from the endpoint may look like:

.. code-block:: json

{
"result": [
{
"name": "Radiohead",
"listen_count": 120,
"albums": [
{"name": "OK Computer", "listen_count": 45},
{"name": "In Rainbows", "listen_count": 75}
]
},
{
"name": "The Beatles",
"listen_count": 95,
"albums": [
{"name": "Abbey Road", "listen_count": 60},
{"name": "Revolver", "listen_count": 35}
]
}
]
}

.. note::

- The example above shows artist activity data with two artists and their respective albums.
- The statistics are aggregated based on the number of listens recorded for each artist and their albums.

:statuscode 200: Successful query, you have data!
:statuscode 204: Statistics for the user haven't been calculated, empty response will be returned
:statuscode 400: Bad request, check ``response['error']`` for more details
:statuscode 404: User not found
:resheader Content-Type: *application/json*
"""
user, stats_range = _validate_stats_user_params(user_name)
offset = get_non_negative_param("offset", default=0)
count = get_non_negative_param("count", default=DEFAULT_ITEMS_PER_GET)
stats = db_stats.get(user["id"], "release_groups", stats_range, EntityRecord)
if stats is None:
raise APINoContent('')

release_group_list, _ = _process_user_entity(stats, offset, count, entire_range=True)

result = {}
for release_group in release_group_list:
artist_name = release_group["artist_name"]
listen_count = release_group["listen_count"]
release_group_name = release_group["release_group_name"]

if artist_name in result:
result[artist_name]["listen_count"] += listen_count
result[artist_name]["albums"].append({"name": release_group_name, "listen_count": listen_count})
else:
result[artist_name] = {
"name": artist_name,
"listen_count": listen_count,
"albums": [{"name": release_group_name, "listen_count": listen_count}]
}
sorted_data = sorted(result.values(), key=lambda x: x["listen_count"], reverse=True)
return jsonify({"result": sorted_data})

@stats_api_bp.get("/user/<user_name>/daily-activity")
@crossdomain
@ratelimit()
Expand Down Expand Up @@ -1245,7 +1316,7 @@ def year_in_music(user_name: str, year: int = 2024):
})


def _process_user_entity(stats: StatApi[EntityRecord], offset: int, count: int) -> Tuple[list[dict], int]:
def _process_user_entity(stats: StatApi[EntityRecord], offset: int, count: int, entire_range: bool) -> Tuple[list[dict], int]:
""" Process the statistics data according to query params

Args:
Expand All @@ -1262,7 +1333,10 @@ def _process_user_entity(stats: StatApi[EntityRecord], offset: int, count: int)
count = min(count, MAX_ITEMS_PER_GET)
count = count + offset
total_entity_count = stats.count
entity_list = [x.dict() for x in stats.data.__root__[offset:count]]
if entire_range:
entity_list = [x.dict() for x in stats.data.__root__]
else:
entity_list = [x.dict() for x in stats.data.__root__[offset:count]]

return entity_list, total_entity_count

Expand Down
Loading