Skip to content
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

Add Genre Graph for YIM 2024 #3087

Merged
merged 9 commits into from
Dec 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
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
Loading