Skip to content

Commit

Permalink
Merge pull request #3087 from metabrainz/ansh/add-yim-genre-graph
Browse files Browse the repository at this point in the history
Add Genre Graph for YIM 2024
  • Loading branch information
anshg1214 authored Dec 20, 2024
2 parents d635879 + ce999bd commit 5e45f6e
Show file tree
Hide file tree
Showing 6 changed files with 230 additions and 6 deletions.
2 changes: 1 addition & 1 deletion frontend/css/year-in-music.less
Original file line number Diff line number Diff line change
Expand Up @@ -651,7 +651,7 @@
&.yim-2024 {
--backgroundColor: #4c6c52;
--cardBackgroundColor: #fefff5;
--accentColor: #2B9F7A;
--accentColor: #2b9f7a;
--selectedColor: var(--accentColor);
--swiper-navigation-color: var(--accentColor);
@text-color: var(--accentColor);
Expand Down
89 changes: 84 additions & 5 deletions frontend/js/src/user/year-in-music/2024/YearInMusic2024.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
} from "swiper";
import { Swiper, SwiperSlide } from "swiper/react";
import { CalendarDatum, ResponsiveCalendar } from "@nivo/calendar";
import { ResponsiveTreeMap } from "@nivo/treemap";
import Tooltip from "react-tooltip";
import { toast } from "react-toastify";
import {
Expand Down Expand Up @@ -55,9 +56,26 @@ import { RouteQuery } from "../../../utils/Loader";
import { useBrainzPlayerDispatch } from "../../../common/brainzplayer/BrainzPlayerContext";
import { YearInMusicProps } from "../2023/YearInMusic2023";

type Node = {
id: string;
loc: number;
name: string;
children?: Node[];
};

type GenreGraphData = {
children: Node[];
name: string;
};

type YearInMusicProps2024 = YearInMusicProps & {
genreGraphData: GenreGraphData;
};

type YearInMusicLoaderData = {
user: YearInMusicProps["user"];
data: YearInMusicProps["yearInMusicData"];
user: YearInMusicProps2024["user"];
data: YearInMusicProps2024["yearInMusicData"];
genreGraphData: YearInMusicProps2024["genreGraphData"];
};

const YIM2024Seasons = {
Expand All @@ -83,14 +101,14 @@ export type YearInMusicState = {
};

export default class YearInMusic extends React.Component<
YearInMusicProps,
YearInMusicProps2024,
YearInMusicState
> {
static contextType = GlobalAppContext;
declare context: React.ContextType<typeof GlobalAppContext>;
private buddiesScrollContainer: React.RefObject<HTMLDivElement>;

constructor(props: YearInMusicProps) {
constructor(props: YearInMusicProps2024) {
super(props);
this.state = {
mosaics: [],
Expand Down Expand Up @@ -357,6 +375,7 @@ export default class YearInMusic extends React.Component<
topDiscoveriesPlaylist,
topMissedRecordingsPlaylist,
missingPlaylistData,
genreGraphData,
} = this.props;
const {
selectedMetric,
Expand All @@ -370,6 +389,14 @@ export default class YearInMusic extends React.Component<
const backgroundColor = selectedSeason.background;
const cardBackgroundColor = selectedSeason.cardBackground;

const textColors = Object.values(YIM2024Seasons).map(
(season) => season.text
);
const reorderedColors = [
...textColors.slice(textColors.indexOf(selectedSeason.text)),
...textColors.slice(0, textColors.indexOf(selectedSeason.text)),
];

// Some data might not have been calculated for some users
// This boolean lets us warn them of that
let missingSomeData = missingPlaylistData;
Expand Down Expand Up @@ -401,6 +428,7 @@ export default class YearInMusic extends React.Component<
const isCurrentUser = user.name === currentUser?.name;
const youOrUsername = isCurrentUser ? "you" : `${user.name}`;
const yourOrUsersName = isCurrentUser ? "your" : `${user.name}'s`;
const hasOrHave = isCurrentUser ? "have" : "has";

/* Most listened years */
let mostListenedYearDataForGraph;
Expand Down Expand Up @@ -1128,6 +1156,49 @@ export default class YearInMusic extends React.Component<
</div>
</div>
)}
{genreGraphData && (
<div className="" id="genre-graph">
<h3 className="text-center">
What genres {hasOrHave} {youOrUsername} explored?{" "}
<FontAwesomeIcon
icon={faQuestionCircle}
data-tip
data-for="genre-graph-helptext"
size="xs"
/>
<Tooltip id="genre-graph-helptext">
The top genres {youOrUsername} listened to this year
</Tooltip>
</h3>
<div className="graph-container">
<div className="graph" style={{ height: "400px" }}>
<ResponsiveTreeMap
margin={{ left: 30, bottom: 30, right: 30, top: 30 }}
data={genreGraphData}
identity="name"
value="loc"
valueFormat=".02s"
label="id"
labelSkipSize={12}
labelTextColor={{
from: "color",
modifiers: [["darker", 1.2]],
}}
colors={reorderedColors}
parentLabelPosition="left"
parentLabelTextColor={{
from: "color",
modifiers: [["darker", 2]],
}}
borderColor={{
from: "color",
modifiers: [["darker", 0.1]],
}}
/>
</div>
</div>
</div>
)}
<div className="yim-share-button-container">
<ImageShareButtons
svgURL={`${APIService.APIBaseURI}/art/year-in-music/2024/${user.name}?image=stats&season=${selectedSeasonName}`}
Expand Down Expand Up @@ -1537,7 +1608,14 @@ export function YearInMusicWrapper() {
RouteQuery(["year-in-music-2024", params], location.pathname)
);
const fallbackUser = { name: "" };
const { user = fallbackUser, data: yearInMusicData } = data || {};
const {
user = fallbackUser,
data: yearInMusicData,
genreGraphData = {
children: [],
name: "",
},
} = data || {};
const listens: BaseListenFormat[] = [];

if (yearInMusicData?.top_recordings) {
Expand Down Expand Up @@ -1617,6 +1695,7 @@ export function YearInMusicWrapper() {
<YearInMusic
user={user ?? fallbackUser}
yearInMusicData={yearInMusicData}
genreGraphData={genreGraphData}
topDiscoveriesPlaylist={topDiscoveriesPlaylist}
topMissedRecordingsPlaylist={topMissedRecordingsPlaylist}
missingPlaylistData={missingPlaylistData}
Expand Down
26 changes: 26 additions & 0 deletions listenbrainz/db/genre.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
from psycopg2.extras import DictCursor


def load_genre_with_subgenres(mb_curs: DictCursor):
query = """
SELECT
g1.name as genre,
g1.gid::text as genre_gid,
g2.name as subgenre,
g2.gid::text as subgenre_gid
FROM genre g1
LEFT JOIN (
SELECT entity0, entity1
FROM l_genre_genre lgg
WHERE lgg.link IN (
SELECT id
FROM link
WHERE link_type = 1095
)
) lgg ON g1.id = lgg.entity0
LEFT JOIN genre g2 ON lgg.entity1 = g2.id
ORDER BY g1.name, COALESCE(g2.name, '');
"""

mb_curs.execute(query)
return mb_curs.fetchall()
96 changes: 96 additions & 0 deletions listenbrainz/webserver/views/user.py
Original file line number Diff line number Diff line change
@@ -1,27 +1,36 @@
from datetime import datetime
from collections import defaultdict

import listenbrainz.db.user as db_user
import listenbrainz.db.user_relationship as db_user_relationship

from flask import Blueprint, render_template, request, url_for, jsonify, current_app
from flask_login import current_user, login_required
import psycopg2
from psycopg2.extras import DictCursor

from listenbrainz import webserver
from listenbrainz.db.msid_mbid_mapping import fetch_track_metadata_for_items
from listenbrainz.db.playlist import get_playlists_for_user, get_recommendation_playlists_for_user
from listenbrainz.db.pinned_recording import get_current_pin_for_user, get_pin_count_for_user, get_pin_history_for_user
from listenbrainz.db.feedback import get_feedback_count_for_user, get_feedback_for_user
from listenbrainz.db import year_in_music as db_year_in_music
from listenbrainz.db.genre import load_genre_with_subgenres
from listenbrainz.webserver.decorators import web_listenstore_needed
from listenbrainz.webserver import timescale_connection, db_conn, ts_conn
from listenbrainz.webserver.errors import APIBadRequest
from listenbrainz.webserver.login import User, api_login_required
from listenbrainz.webserver.views.api import DEFAULT_NUMBER_OF_PLAYLISTS_PER_CALL
from werkzeug.exceptions import NotFound

from brainzutils import cache

LISTENS_PER_PAGE = 25
DEFAULT_NUMBER_OF_FEEDBACK_ITEMS_PER_CALL = 25

TAG_HEIRARCHY_CACHE_KEY = "tag_hierarchy"
TAG_HEIRARCHY_CACHE_EXPIRY = 60 * 60 * 24 * 7 # 7 days

user_bp = Blueprint("user", __name__)
redirect_bp = Blueprint("redirect", __name__)

Expand Down Expand Up @@ -322,6 +331,75 @@ def taste(user_name: str):
return jsonify(data)


def process_genre_data(yim_top_genre: list, data: list, user_name: str):
if not yim_top_genre or not data:
return {}

yimDataDict = {genre["genre"]: genre["genre_count"] for genre in yim_top_genre}

adj_matrix = defaultdict(list)
is_head = defaultdict(lambda: True)
id_name_map = {}
parent_map = defaultdict(lambda: None)

for row in data:
genre_id = row["genre_gid"]
is_head[genre_id]
id_name_map[genre_id] = row.get("genre")

subgenre_id = row["subgenre_gid"]
if subgenre_id:
is_head[subgenre_id] = False
id_name_map[subgenre_id] = row.get("subgenre")
parent_map[subgenre_id] = genre_id
adj_matrix[genre_id].append(subgenre_id)
else:
adj_matrix[genre_id] = []

visited = set()
rootNodes = [node for node in is_head if is_head[node]]

def create_node(id):
if id in visited:
return None
visited.add(id)

genreCount = yimDataDict.get(id_name_map[id], 0)
children = []

for subGenre in sorted(adj_matrix[id]):
childNode = create_node(subGenre)
if isinstance(childNode, list):
children.extend(childNode)
elif childNode is not None:
children.append(childNode)

if genreCount == 0:
if len(children) == 0:
return None
return children

data = {"id": id, "name": id_name_map[id], "children": children, "loc": genreCount}

if len(children) == 0:
del data["children"]

return data

outputArr = []
for rootNode in rootNodes:
node = create_node(rootNode)
if isinstance(node, list):
outputArr.extend(node)
elif node is not None:
outputArr.append(node)

return {
"name": user_name,
"children": outputArr
}


@user_bp.route("/<user_name>/year-in-music/", methods=['POST'])
@user_bp.route("/<user_name>/year-in-music/<int:year>/", methods=['POST'])
def year_in_music(user_name, year: int = 2024):
Expand All @@ -339,8 +417,26 @@ def year_in_music(user_name, year: int = 2024):
yearInMusicData = {}
current_app.logger.error(f"Error getting Year in Music data for user {user_name}: {e}")

genreGraphData = {}
if yearInMusicData and year == 2024:
try:
data = cache.get(TAG_HEIRARCHY_CACHE_KEY)
if not data:
with psycopg2.connect(current_app.config["MB_DATABASE_URI"]) as mb_conn,\
mb_conn.cursor(cursor_factory=DictCursor) as mb_curs:
data = load_genre_with_subgenres(mb_curs)
data = [dict(row) for row in data] if data else []
cache.set(TAG_HEIRARCHY_CACHE_KEY, data, expirein=TAG_HEIRARCHY_CACHE_EXPIRY)
except Exception as e:
current_app.logger.error("Error loading genre hierarchy: %s", e)
return jsonify({"error": "Failed to load genre hierarchy"}), 500

yimTopGenre = yearInMusicData.get("top_genres", [])
genreGraphData = process_genre_data(yimTopGenre, data, user_name)

return jsonify({
"data": yearInMusicData,
**({"genreGraphData": genreGraphData} if year == 2024 else {}),
"user": {
"id": user.id,
"name": user.musicbrainz_id,
Expand Down
22 changes: 22 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
"@nivo/legends": "^0.81.0",
"@nivo/network": "^0.81.0",
"@nivo/tooltip": "^0.81.0",
"@nivo/treemap": "^0.81.0",
"@react-spring/web": "^9.7.3",
"@sentry/react": "^8.33.1",
"@tanstack/react-query": "^5.28.4",
Expand Down

0 comments on commit 5e45f6e

Please sign in to comment.