diff --git a/frontend/css/donations.less b/frontend/css/donations.less new file mode 100644 index 0000000000..fe0c93ece5 --- /dev/null +++ b/frontend/css/donations.less @@ -0,0 +1,79 @@ +#donations-page { + background: @homepage-background; + margin-left: -15px; + margin-right: -15px; + margin-bottom: -20px; + padding: 3em 2em; + position: relative; + .donations-page-header { + text-align: center; + margin-bottom: 2em; + color: @white; + font-size: 24px; + } + .donations-page-footer { + z-index: 1; + position: relative; + font-size: 1.5rem; + } + .grey-wedge { + height: 530px; + bottom: 0; + clip-path: polygon(100% 100%, 100% 18%, 0px 0%, 0% 100%); + background: #e9e9e9; + position: absolute; + left: 0; + width: 100%; + } + .blob { + position: absolute; + top: 25%; + opacity: 70%; + } +} + +#donations-tiers { + position: relative; + width: 90%; + margin-left: auto; + margin-right: auto; + margin-bottom: 3em; + z-index: 1; + display: flex; + flex-wrap: wrap; + gap: 1.5em; + align-items: stretch; + ul { + margin-left: 1.5em; + } + .tier { + padding: 1em; + flex: 1; + flex-basis: 250px; + scale: 1; + height: auto; + &:hover { + scale: 1.1; + } + background: fadeout(white, 30%); + transition: scale 0.2s ease-in-out; + // blurred glass effect + // background: rgba(255, 255, 255, 0.2); + border-radius: 16px; + box-shadow: 0 4px 30px rgba(0, 0, 0, 0.1); + backdrop-filter: blur(5px); + -webkit-backdrop-filter: blur(5px); + border: 1px solid rgba(255, 255, 255, 0.3); + &:first-child { + flex-basis: 100%; + height: auto; + } + } + .tier-heading { + text-align: center; + margin-bottom: 1.5em; + } + .perk { + margin-bottom: 0.5em; + } +} diff --git a/frontend/css/donors-page.less b/frontend/css/donors-page.less new file mode 100644 index 0000000000..000f848b41 --- /dev/null +++ b/frontend/css/donors-page.less @@ -0,0 +1,136 @@ +.donor-card { + display: flex; + flex-direction: column; + justify-content: space-between; + border: 1px solid #e2e8f0; + padding: 1rem; + margin: 1.5rem 0; + @media (min-width: 640px) { + flex-direction: row; + align-items: center; + } + + .donor-info { + display: flex; + margin-bottom: 1rem; + flex-direction: column; + + @media (min-width: 640px) { + margin-bottom: 0; + } + + .donation-user { + font-weight: 600; + font-size: 1.75rem; + margin-bottom: 0.5rem; + .donor-name { + font-weight: 300 !important; + &:hover, + &:visited { + color: #353070 !important; + } + } + } + + .donation-date { + display: flex; + color: #6b7280; + gap: 10px; + align-items: baseline; + } + } + + .donor-stats { + display: flex; + flex-direction: column; + align-items: flex-end; + + .donation-amount { + font-weight: 600; + font-size: 1.75rem; + margin-bottom: 0.5rem; + } + + .recent-listens { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + + .listen-item { + display: flex; + align-items: center; + color: #6b7280; + background-color: #edf2f7; + padding: 0.25rem 0.75rem; + border-radius: 9999px; + gap: 0.5rem; + font-weight: 300; + + &:hover, + &:visited { + color: #6b7280 !important; + } + } + } + } + + &:hover { + background-color: @table-bg-hover; + } +} + +#donors { + margin-top: 15px; + .donations-page-header { + .donation-header-text { + position: relative; + color: @white; + font-size: 16px; + } + a { + color: @black; + font-weight: bold; + margin-left: 2em; + &.btn { + border-radius: @border-radius-base; + } + } + opacity: 0.85; + border-radius: @border-radius-large; + background: @homepage-background; + padding: 3em 2em; + position: relative; + text-align: center; + margin-bottom: 2em; + overflow: hidden; + canvas { + position: absolute; + opacity: 0.5; + } + } +} + +.recent-donor-card { + gap: 1rem; + > div { + width: 150px; + } + .user-link { + word-wrap: break-word; + } + .donor-pinned-recording { + display: flex; + align-items: center; + background: #edf2f7; + gap: 0.5rem; + margin-left: auto; + border-radius: 10px; + .text { + color: #6b7280; + text-wrap: wrap; + } + svg { + color: #353070; + } + } +} diff --git a/frontend/css/homepage.less b/frontend/css/homepage.less index 8f471c123d..021d32cf8a 100644 --- a/frontend/css/homepage.less +++ b/frontend/css/homepage.less @@ -1,5 +1,10 @@ @dark-grey: #46433a; @even-darker-grey: #353070; +@homepage-background: linear-gradient( + 288deg, + @dark-grey 16.96%, + @even-darker-grey 98.91% +); #homepage-container { overflow-y: auto; height: 100vh; // absolute fallback @@ -16,11 +21,7 @@ } .homepage-upper { - background: linear-gradient( - 288deg, - @dark-grey 16.96%, - @even-darker-grey 98.91% - ); + background: @homepage-background; height: 100%; position: relative; padding-left: 50px; @@ -118,11 +119,7 @@ } .homepage-lower { - background: linear-gradient( - 288deg, - @dark-grey 16.96%, - @even-darker-grey 98.91% - ); + background: @homepage-background; height: 100%; position: relative; padding-left: 50px; diff --git a/frontend/css/main.less b/frontend/css/main.less index 0c6c427b31..63857a261a 100644 --- a/frontend/css/main.less +++ b/frontend/css/main.less @@ -38,6 +38,8 @@ @import "release-card.less"; @import "search.less"; @import "accordion.less"; +@import "donors-page.less"; +@import "donations.less"; @import "scroll-container.less"; @icon-font-path: "/static/fonts/"; diff --git a/frontend/js/src/about/About.tsx b/frontend/js/src/about/About.tsx index 20bbe9d290..f68602fc14 100644 --- a/frontend/js/src/about/About.tsx +++ b/frontend/js/src/about/About.tsx @@ -1,4 +1,5 @@ import * as React from "react"; +import { Link } from "react-router-dom"; export default function About() { return ( @@ -87,8 +88,7 @@ export default function About() {

Listenbrainz is a free open source project that is not run for profit. If you would like to help the project out financially, consider{" "} - donating to the MetaBrainz - Foundation. + donating to the MetaBrainz Foundation.

Developers

diff --git a/frontend/js/src/about/donations/Donate.tsx b/frontend/js/src/about/donations/Donate.tsx new file mode 100644 index 0000000000..45a12561d0 --- /dev/null +++ b/frontend/js/src/about/donations/Donate.tsx @@ -0,0 +1,227 @@ +import { faCheck } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import * as React from "react"; +import { Link } from "react-router-dom"; +import { COLOR_LB_GREEN } from "../../utils/constants"; +import Blob from "../../home/Blob"; + +export default function Donate() { + return ( +

+ + +
+
+ Money can't buy happiness, but it can buy +
+ LISTENBRAINZ HOSTING +
+
+
+
+

+ Free for everyone +

+ + + All website features, for free, forever + +
+
+
+ +
    +
  • + + User flair +
    + + Add a special effect to your username on the website + +
  • +
  • + + Our eternal gratitude +
  • +
+
+
+ +
    +
  • + + User flair +
    + + Add a special effect to your username on the website + +
  • +
  • + + Our eternal gratitude +
  • +
  • + + Inner sense of peace and accomplishment +
  • +
+
+
+ +
    +
  • + + User flair +
    + + Add a special effect to your username on the website + +
  • +
  • + + Our eternal gratitude +
  • +
  • + + Inner sense of peace and accomplishment +
  • +
  • + + Make your family proud +
  • +
  • + + Instant street cred +
  • +
  • + + De-shittify the internet +
  • +
+
+
+
+

Jokes aside

+
+

+ We are a{" "} + + non-profit foundation + {" "} + and we are free to build the music website of our dreams, unbiased + by financial deals. +
+ One where you aren't the product and your personal + data isn't the price you pay. +

+

+ All features are free for everyone —no paywalls, no “Pro++” + features. +

+
+
+

+ By donating —either once or regularly— you'll join thousands + of music lovers in helping us build an honest, unbiased and + community-driven space for music discovery. +

+

+ At our scale, every contribution matters. +

+

+ See all our donors +

+
+
+
+
+
+ ); +} diff --git a/frontend/js/src/about/layout.tsx b/frontend/js/src/about/layout.tsx index 5a6d6b92f3..95b8ee4170 100644 --- a/frontend/js/src/about/layout.tsx +++ b/frontend/js/src/about/layout.tsx @@ -10,6 +10,7 @@ type Section = { const sections: Section[] = [ { to: "about/", label: "About" }, + { to: "donate/", label: "Donate" }, { to: "current-status/", label: "Site status" }, { to: "add-data/", label: "Submitting data" }, { to: "data/", label: "Using our data" }, diff --git a/frontend/js/src/about/routes/index.tsx b/frontend/js/src/about/routes/index.tsx index 4d9c0921c1..10531944e4 100644 --- a/frontend/js/src/about/routes/index.tsx +++ b/frontend/js/src/about/routes/index.tsx @@ -52,6 +52,13 @@ const getAboutRoutes = (): RouteObject[] => { }, ], }, + { + path: "donate/", + lazy: async () => { + const Donate = await import("../donations/Donate"); + return { Component: Donate.default }; + }, + }, ]; return routes; }; diff --git a/frontend/js/src/components/Footer.tsx b/frontend/js/src/components/Footer.tsx index 0fb91c08a0..bc62bcc607 100644 --- a/frontend/js/src/components/Footer.tsx +++ b/frontend/js/src/components/Footer.tsx @@ -2,6 +2,7 @@ import * as React from "react"; import { faAnglesRight } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { Link } from "react-router-dom"; export default function Footer() { return ( @@ -50,13 +51,7 @@ export default function Footer() {
{!listens.length && ( diff --git a/frontend/js/src/recent/components/RecentDonors.tsx b/frontend/js/src/recent/components/RecentDonors.tsx new file mode 100644 index 0000000000..68eedd7c45 --- /dev/null +++ b/frontend/js/src/recent/components/RecentDonors.tsx @@ -0,0 +1,87 @@ +import { faThumbtack } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import * as React from "react"; +import { Link } from "react-router-dom"; +import { + getRecordingMBID, + getTrackName, + pinnedRecordingToListen, +} from "../../utils/utils"; + +type RecentDonorsCardProps = { + donors: DonationInfoWithPinnedRecording[]; +}; + +function RecentDonorsCard(props: RecentDonorsCardProps) { + const { donors } = props; + + if (!donors || donors.length === 0) { + return null; + } + + return ( + <> +

+ Recent Donors +
+ + See all donations + +

+
+ {donors && + donors.map((donor) => { + const pinnedRecordingListen = donor.pinnedRecording + ? pinnedRecordingToListen(donor.pinnedRecording) + : null; + return ( +
+
+ {donor.musicbrainz_id && + (donor.is_listenbrainz_user ? ( + + {donor.musicbrainz_id} + + ) : ( + + {donor.musicbrainz_id} + + ))} +

+ {donor.currency === "usd" ? "$" : "€"} + {donor.donation} +

+
+ {pinnedRecordingListen && ( + + +
+ {getTrackName(pinnedRecordingListen)} +
+ + )} +
+ ); + })} +
+ + ); +} + +export default RecentDonorsCard; diff --git a/frontend/js/src/routes/index.tsx b/frontend/js/src/routes/index.tsx index cbea3b43cd..7008619ba7 100644 --- a/frontend/js/src/routes/index.tsx +++ b/frontend/js/src/routes/index.tsx @@ -170,6 +170,13 @@ const getIndexRoutes = (): RouteObject[] => { return { Component: APIAuth.default }; }, }, + { + path: "donors/", + lazy: async () => { + const Donors = await import("../donors/Donors"); + return { Component: Donors.default }; + }, + }, ], }, ]; diff --git a/frontend/js/src/utils/donation.d.ts b/frontend/js/src/utils/donation.d.ts new file mode 100644 index 0000000000..6ee3f068fd --- /dev/null +++ b/frontend/js/src/utils/donation.d.ts @@ -0,0 +1,14 @@ +type DonationInfo = { + id: number; + donated_at: string; + donation: number; + currency: "usd" | "eur"; + musicbrainz_id: string; + is_listenbrainz_user: boolean; + listenCount: number; + playlistCount: number; +}; + +type DonationInfoWithPinnedRecording = DonationInfo & { + pinnedRecording: PinnedRecording; +}; diff --git a/frontend/js/tests/recent/RecentListens.test.tsx b/frontend/js/tests/recent/RecentListens.test.tsx index 8f1cc696ef..7ab3d7a4ac 100644 --- a/frontend/js/tests/recent/RecentListens.test.tsx +++ b/frontend/js/tests/recent/RecentListens.test.tsx @@ -58,6 +58,7 @@ const props = { userPinnedRecording, globalListenCount, globalUserCount, + recentDonors: [], }; // Create a new instance of GlobalAppContext diff --git a/listenbrainz/db/donation.py b/listenbrainz/db/donation.py index 91fa80b9db..b8eaffd708 100644 --- a/listenbrainz/db/donation.py +++ b/listenbrainz/db/donation.py @@ -107,7 +107,18 @@ def get_recent_donors(meb_conn, db_conn, count: int, offset: int): }) donors = results.all() - return get_flairs_for_donors(db_conn, donors) + total_count_query = """ + SELECT COUNT(*) + FROM payment + WHERE editor_id IS NOT NULL + AND is_donation = 't' + AND payment_date >= (NOW() - INTERVAL '1 year') + """ + + result = meb_conn.execute(text(total_count_query)) + total_count = result.scalar() + + return get_flairs_for_donors(db_conn, donors), total_count def get_biggest_donors(meb_conn, db_conn, count: int, offset: int): @@ -156,7 +167,28 @@ def get_biggest_donors(meb_conn, db_conn, count: int, offset: int): }) donors = results.all() - return get_flairs_for_donors(db_conn, donors) + total_count_query = """ + WITH select_donations AS ( + SELECT editor_id + , currency + FROM payment + WHERE editor_id IS NOT NULL + AND is_donation = 't' + AND payment_date >= (NOW() - INTERVAL '1 year') + ) + SELECT COUNT(*) + FROM ( + SELECT editor_id + , currency + FROM select_donations + GROUP BY editor_id, currency + ) AS total_count; + """ + + result = meb_conn.execute(text(total_count_query)) + total_count = result.scalar() + + return get_flairs_for_donors(db_conn, donors), total_count def is_user_eligible_donor(meb_conn, musicbrainz_row_id: int): diff --git a/listenbrainz/db/pinned_recording.py b/listenbrainz/db/pinned_recording.py index b5f1e327c6..ea3863ef0d 100644 --- a/listenbrainz/db/pinned_recording.py +++ b/listenbrainz/db/pinned_recording.py @@ -103,25 +103,38 @@ def delete(db_conn, row_id: int, user_id: int): return result.rowcount == 1 -def get_current_pin_for_user(db_conn, user_id: int) -> PinnedRecording: - """ Get the currently active pinned recording for the user if they have one. +def get_current_pin_for_users(db_conn, user_ids: Iterable[int]) -> List[PinnedRecording]: + """ Get the currently active pinned recording for the users if they have one. Args: db_conn: database connection - user_id: the row ID of the user in the DB + user_ids: the row IDs of the users in the DB Returns: - A PinnedRecording object. + A list of PinnedRecording objects. """ result = db_conn.execute(sqlalchemy.text(""" SELECT {columns} FROM pinned_recording as pin - WHERE (user_id = :user_id + WHERE (user_id IN :user_ids AND pinned_until >= NOW()) - """.format(columns=','.join(PINNED_REC_GET_COLUMNS))), {'user_id': user_id}) - row = result.mappings().first() - return PinnedRecording(**row) if row else None + """.format(columns=','.join(PINNED_REC_GET_COLUMNS))), {"user_ids": tuple(user_ids)}) + return [PinnedRecording(**row) if row else None for row in result.mappings()] + + +def get_current_pin_for_user(db_conn, user_id: int) -> PinnedRecording: + """ Get the currently active pinned recording for the user if they have one. + + Args: + db_conn: database connection + user_id: the row ID of the user in the DB + + Returns: + A PinnedRecording object. + """ + + return next(iter(get_current_pin_for_users(db_conn, [user_id])), None) def get_pin_history_for_user(db_conn, user_id: int, count: int, offset: int) -> List[PinnedRecording]: diff --git a/listenbrainz/db/playlist.py b/listenbrainz/db/playlist.py index 36564a9908..7d6c87a3cd 100644 --- a/listenbrainz/db/playlist.py +++ b/listenbrainz/db/playlist.py @@ -983,3 +983,14 @@ def get_playlist_recordings_metadata(mb_curs, ts_curs, playlist: Playlist) -> Pl rec.additional_metadata = additional_metadata return playlist + + +def get_playlist_count(ts_conn, creator_ids: List[str]) -> dict: + query = text(""" + SELECT creator_id, COUNT(*) as count + FROM playlist.playlist + WHERE creator_id IN :creator_ids + GROUP BY creator_id + """) + result = ts_conn.execute(query, {"creator_ids": tuple(creator_ids)}) + return {row[0]: row[1] for row in result.fetchall()} diff --git a/listenbrainz/listenstore/timescale_listenstore.py b/listenbrainz/listenstore/timescale_listenstore.py index ac9caa2603..648bf7c97b 100644 --- a/listenbrainz/listenstore/timescale_listenstore.py +++ b/listenbrainz/listenstore/timescale_listenstore.py @@ -86,6 +86,31 @@ def get_listen_count_for_user(self, user_id: int): cache.set(REDIS_USER_LISTEN_COUNT + str(user_id), count, REDIS_USER_LISTEN_COUNT_EXPIRY) return count + def get_listen_count_for_users(self, user_ids: list): + """Get the total number of listens for a list of users. + + Args: + user_ids: the list of users to get listens for + """ + cached_count_map = cache.get_many([REDIS_USER_LISTEN_COUNT + str(user_id) for user_id in user_ids]) + # Extract the user_ids for which we don't have cached counts. cached_cout is a dict of key-value pairs + # where key is the cache key and value is the cached value. We need to extract the user_id from the cache key. + listen_count = {int(key.split(".")[1]): value for key, value in cached_count_map.items() + if value is not None} + missing_user_ids = set(user_ids) - set(listen_count.keys()) + + if not missing_user_ids: + return listen_count + + query = "SELECT user_id, count, created FROM listen_user_metadata WHERE user_id = ANY(:user_ids)" + result = ts_conn.execute(sqlalchemy.text(query), {"user_ids": list(missing_user_ids)}) + data = result.fetchall() + listen_count.update({row.user_id: row.count for row in data}) + cache.set_many({REDIS_USER_LISTEN_COUNT + str(row.user_id): row.count for row in data}, + expirein=REDIS_USER_LISTEN_COUNT_EXPIRY) + return listen_count + + def get_timestamps_for_user(self, user_id: int) -> Tuple[Optional[datetime], Optional[datetime]]: """ Return the min_ts and max_ts for the given list of users """ query = """ diff --git a/listenbrainz/webserver/__init__.py b/listenbrainz/webserver/__init__.py index 3b77b975ad..789bc63b01 100644 --- a/listenbrainz/webserver/__init__.py +++ b/listenbrainz/webserver/__init__.py @@ -357,6 +357,9 @@ def _register_blueprints(app): from listenbrainz.webserver.views.explore import explore_bp app.register_blueprint(explore_bp, url_prefix='/explore') + from listenbrainz.webserver.views.donors import donors_bp + app.register_blueprint(donors_bp, url_prefix='/donors') + from listenbrainz.webserver.views.api import api_bp app.register_blueprint(api_bp, url_prefix=API_PREFIX) diff --git a/listenbrainz/webserver/views/donor_api.py b/listenbrainz/webserver/views/donor_api.py index 5fb16be2c4..840fff5cd8 100644 --- a/listenbrainz/webserver/views/donor_api.py +++ b/listenbrainz/webserver/views/donor_api.py @@ -22,7 +22,7 @@ def recent_donors(): count = _parse_int_arg("count", DEFAULT_DONOR_COUNT) offset = _parse_int_arg("offset", 0) - donors = get_recent_donors(meb_conn, db_conn, count, offset) + donors, _ = get_recent_donors(meb_conn, db_conn, count, offset) return jsonify(donors) @@ -36,5 +36,5 @@ def biggest_donors(): count = _parse_int_arg("count", DEFAULT_DONOR_COUNT) offset = _parse_int_arg("offset", 0) - donors = get_biggest_donors(meb_conn, db_conn, count, offset) + donors, _ = get_biggest_donors(meb_conn, db_conn, count, offset) return jsonify(donors) diff --git a/listenbrainz/webserver/views/donors.py b/listenbrainz/webserver/views/donors.py new file mode 100644 index 0000000000..6b1a7c3dab --- /dev/null +++ b/listenbrainz/webserver/views/donors.py @@ -0,0 +1,53 @@ +from flask import Blueprint, render_template, jsonify, request +from math import ceil + +from listenbrainz.webserver.views.api_tools import _parse_int_arg + +from listenbrainz.db.donation import get_recent_donors, get_biggest_donors +from listenbrainz.webserver import db_conn, meb_conn, ts_conn, timescale_connection +import listenbrainz.db.user as db_user +import listenbrainz.db.playlist as db_playlist + +DEFAULT_DONOR_COUNT = 25 +donors_bp = Blueprint("donors", __name__) + + +@donors_bp.route("/", defaults={'path': ''}) +@donors_bp.route('//') +def donors(path): + return render_template("index.html") + + +@donors_bp.route("/", methods=["POST"]) +def donors_post(): + page = _parse_int_arg("page", 1) + sort = request.args.get("sort", "date") + offset = (int(page) - 1) * DEFAULT_DONOR_COUNT + + if sort == "amount": + donors, donation_count = get_biggest_donors(meb_conn, db_conn, DEFAULT_DONOR_COUNT, offset) + else: + donors, donation_count = get_recent_donors(meb_conn, db_conn, DEFAULT_DONOR_COUNT, offset) + + donation_count_pages = ceil(donation_count / DEFAULT_DONOR_COUNT) + + musicbrainz_ids = [donor["musicbrainz_id"] for donor in donors if donor['is_listenbrainz_user']] + donors_info = db_user.get_many_users_by_mb_id(db_conn, musicbrainz_ids) if musicbrainz_ids else {} + donor_ids = [donor_info.id for _, donor_info in donors_info.items()] + + user_listen_count = timescale_connection._ts.get_listen_count_for_users(donor_ids) if donor_ids else {} + user_playlist_count = db_playlist.get_playlist_count(ts_conn, donor_ids) if donor_ids else {} + + for donor in donors: + donor_info = donors_info.get(donor["musicbrainz_id"]) + if not donor_info: + donor['listenCount'] = None + donor['playlistCount'] = None + else: + donor['listenCount'] = user_listen_count.get(donor_info.id, 0) + donor['playlistCount'] = user_playlist_count.get(donor_info.id, 0) + + return jsonify({ + "data": donors, + "totalPageCount": donation_count_pages, + }) diff --git a/listenbrainz/webserver/views/index.py b/listenbrainz/webserver/views/index.py index 22168a3d7e..aa3705e811 100644 --- a/listenbrainz/webserver/views/index.py +++ b/listenbrainz/webserver/views/index.py @@ -18,11 +18,14 @@ from listenbrainz.background.background_tasks import add_task from listenbrainz.db.exceptions import DatabaseException from listenbrainz.webserver.decorators import web_listenstore_needed -from listenbrainz.webserver import flash, db_conn +from listenbrainz.webserver import flash, db_conn, meb_conn, ts_conn from listenbrainz.webserver.timescale_connection import _ts from listenbrainz.webserver.redis_connection import _redis import listenbrainz.db.stats as db_stats import listenbrainz.db.user_relationship as db_user_relationship +from listenbrainz.db.donation import get_recent_donors +from listenbrainz.db.pinned_recording import get_current_pin_for_users +from listenbrainz.db.msid_mbid_mapping import fetch_track_metadata_for_items from listenbrainz.webserver.views.user_timeline_event_api import get_feed_events_for_user index_bp = Blueprint('index', __name__) @@ -136,10 +139,37 @@ def recent_listens(): except DatabaseException as e: user_count = 'Unknown' + recent_donors, _ = get_recent_donors(meb_conn, db_conn, 25, 0) + + # Get MusicBrainz IDs for donors who are ListenBrainz users + musicbrainz_ids = [donor["musicbrainz_id"] + for donor in recent_donors + if donor.get('is_listenbrainz_user')] + + # Fetch donor info only if there are valid MusicBrainz IDs + donors_info = db_user.get_many_users_by_mb_id(db_conn, musicbrainz_ids) if musicbrainz_ids else {} + donor_ids = [donor_info.id for donor_info in donors_info.values()] + + # Get current pinned recordings + pinned_recordings_data = {} + if donor_ids: + pinned_recordings = get_current_pin_for_users(db_conn, donor_ids) + if pinned_recordings: + pinned_recordings_metadata = fetch_track_metadata_for_items(ts_conn, pinned_recordings) + # Map recordings by user_id for quick lookup + pinned_recordings_data = {recording.user_id: dict(recording) + for recording in pinned_recordings_metadata} + + # Add pinned recordings to recent donors + for donor in recent_donors: + donor_info = donors_info.get(donor["musicbrainz_id"]) + donor["pinnedRecording"] = pinned_recordings_data.get(donor_info.id) if donor_info else None + props = { "listens": recent, "globalListenCount": listen_count, - "globalUserCount": user_count + "globalUserCount": user_count, + "recentDonors": recent_donors, } return jsonify(props)