display artist's top listeners #5

merged
opened by tsiry-sandratraina.com targeting main from feat/artist-listeners
Changed files
+707 -266
apps
api
lexicons
pkl
src
lexicon
types
app
rocksky
xrpc
app
rocksky
web
src
api
hooks
pages
artist
crates
analytics
src
handlers
+38
apps/api/lexicons/artist/getArtistListeners.json
···
+
{
+
"lexicon": 1,
+
"id": "app.rocksky.artist.getArtistListeners",
+
"defs": {
+
"main": {
+
"type": "query",
+
"description": "Get artist listeners",
+
"parameters": {
+
"type": "params",
+
"required": [
+
"uri"
+
],
+
"properties": {
+
"uri": {
+
"type": "string",
+
"description": "The URI of the artist to retrieve listeners from",
+
"format": "at-uri"
+
}
+
}
+
},
+
"output": {
+
"encoding": "application/json",
+
"schema": {
+
"type": "object",
+
"properties": {
+
"listeners": {
+
"type": "array",
+
"items": {
+
"type": "ref",
+
"ref": "app.rocksky.artist.defs#listenerViewBasic"
+
}
+
}
+
}
+
}
+
}
+
}
+
}
+
}
+33
apps/api/pkl/defs/artist/getArtistListeners.pkl
···
+
amends "../../schema/lexicon.pkl"
+
+
lexicon = 1
+
id = "app.rocksky.artist.getArtistListeners"
+
defs = new Mapping<String, Query> {
+
["main"] {
+
type = "query"
+
description = "Get artist listeners"
+
parameters = new Params {
+
required = List("uri")
+
properties {
+
["uri"] = new StringType {
+
description = "The URI of the artist to retrieve listeners from"
+
format = "at-uri"
+
}
+
}
+
}
+
output {
+
encoding = "application/json"
+
schema = new ObjectType {
+
type = "object"
+
properties = new Mapping<String, Array> {
+
["listeners"] = new Array {
+
type = "array"
+
items = new Ref {
+
ref = "app.rocksky.artist.defs#listenerViewBasic"
+
}
+
}
+
}
+
}
+
}
+
}
+
}
+12
apps/api/src/lexicon/index.ts
···
import type * as AppRockskyApikeyUpdateApikey from './types/app/rocksky/apikey/updateApikey'
import type * as AppRockskyArtistGetArtistAlbums from './types/app/rocksky/artist/getArtistAlbums'
import type * as AppRockskyArtistGetArtist from './types/app/rocksky/artist/getArtist'
+
import type * as AppRockskyArtistGetArtistListeners from './types/app/rocksky/artist/getArtistListeners'
import type * as AppRockskyArtistGetArtists from './types/app/rocksky/artist/getArtists'
import type * as AppRockskyArtistGetArtistTracks from './types/app/rocksky/artist/getArtistTracks'
import type * as AppRockskyChartsGetScrobblesChart from './types/app/rocksky/charts/getScrobblesChart'
···
return this._server.xrpc.method(nsid, cfg)
}
+
getArtistListeners<AV extends AuthVerifier>(
+
cfg: ConfigOf<
+
AV,
+
AppRockskyArtistGetArtistListeners.Handler<ExtractAuth<AV>>,
+
AppRockskyArtistGetArtistListeners.HandlerReqCtx<ExtractAuth<AV>>
+
>,
+
) {
+
const nsid = 'app.rocksky.artist.getArtistListeners' // @ts-ignore
+
return this._server.xrpc.method(nsid, cfg)
+
}
+
getArtists<AV extends AuthVerifier>(
cfg: ConfigOf<
AV,
+48
apps/api/src/lexicon/types/app/rocksky/artist/getArtistListeners.ts
···
+
/**
+
* GENERATED CODE - DO NOT MODIFY
+
*/
+
import type express from 'express'
+
import { ValidationResult, BlobRef } from '@atproto/lexicon'
+
import { lexicons } from '../../../../lexicons'
+
import { isObj, hasProp } from '../../../../util'
+
import { CID } from 'multiformats/cid'
+
import type { HandlerAuth, HandlerPipeThrough } from '@atproto/xrpc-server'
+
import type * as AppRockskyArtistDefs from './defs'
+
+
export interface QueryParams {
+
/** The URI of the artist to retrieve listeners from */
+
uri: string
+
}
+
+
export type InputSchema = undefined
+
+
export interface OutputSchema {
+
listeners?: AppRockskyArtistDefs.ListenerViewBasic[]
+
[k: string]: unknown
+
}
+
+
export type HandlerInput = undefined
+
+
export interface HandlerSuccess {
+
encoding: 'application/json'
+
body: OutputSchema
+
headers?: { [key: string]: string }
+
}
+
+
export interface HandlerError {
+
status: number
+
message?: string
+
}
+
+
export type HandlerOutput = HandlerError | HandlerSuccess | HandlerPipeThrough
+
export type HandlerReqCtx<HA extends HandlerAuth = never> = {
+
auth: HA
+
params: QueryParams
+
input: HandlerInput
+
req: express.Request
+
res: express.Response
+
resetRouteRateLimits: () => Promise<void>
+
}
+
export type Handler<HA extends HandlerAuth = never> = (
+
ctx: HandlerReqCtx<HA>,
+
) => Promise<HandlerOutput> | HandlerOutput
+126 -1
crates/analytics/src/handlers/artists.rs
···
use crate::types::{
album::Album,
artist::{
-
Artist, GetArtistAlbumsParams, GetArtistTracksParams, GetArtistsParams, GetTopArtistsParams,
+
Artist, ArtistListener, GetArtistAlbumsParams, GetArtistListenersParams,
+
GetArtistTracksParams, GetArtistsParams, GetTopArtistsParams,
},
track::Track,
};
···
let albums: Result<Vec<_>, _> = albums.collect();
Ok(HttpResponse::Ok().json(albums?))
}
+
+
pub async fn get_artist_listeners(
+
payload: &mut web::Payload,
+
_req: &HttpRequest,
+
conn: Arc<Mutex<Connection>>,
+
) -> Result<HttpResponse, Error> {
+
let body = read_payload!(payload);
+
let params = serde_json::from_slice::<GetArtistListenersParams>(&body)?;
+
let pagination = params.pagination.unwrap_or_default();
+
let offset = pagination.skip.unwrap_or(0);
+
let limit = pagination.take.unwrap_or(10);
+
+
let conn = conn.lock().unwrap();
+
let mut stmt =
+
conn.prepare("SELECT id, name, uri FROM artists WHERE id = ? OR uri = ? OR name = ?")?;
+
let artist = stmt.query_row(
+
[&params.artist_id, &params.artist_id, &params.artist_id],
+
|row| {
+
Ok(crate::types::artist::ArtistBasic {
+
id: row.get(0)?,
+
name: row.get(1)?,
+
uri: row.get(2)?,
+
})
+
},
+
)?;
+
+
if artist.id.is_empty() {
+
return Ok(HttpResponse::Ok().json(Vec::<ArtistListener>::new()));
+
}
+
+
let mut stmt = conn.prepare(
+
r#"
+
WITH user_track_counts AS (
+
SELECT
+
s.user_id,
+
s.track_id,
+
t.artist,
+
t.title as track_title,
+
t.uri as track_uri,
+
COUNT(*) as play_count
+
FROM scrobbles s
+
JOIN tracks t ON s.track_id = t.id
+
WHERE t.artist = ?
+
GROUP BY s.user_id, s.track_id, t.artist, t.title, t.uri
+
),
+
user_top_tracks AS (
+
SELECT
+
user_id,
+
artist,
+
track_id,
+
track_title,
+
track_uri,
+
play_count,
+
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY play_count DESC, track_title) as rn
+
FROM user_track_counts
+
),
+
artist_listener_counts AS (
+
SELECT
+
user_id,
+
artist,
+
SUM(play_count) as total_artist_plays
+
FROM user_track_counts
+
GROUP BY user_id, artist
+
),
+
top_artist_listeners AS (
+
SELECT
+
user_id,
+
artist,
+
total_artist_plays,
+
ROW_NUMBER() OVER (ORDER BY total_artist_plays DESC) as listener_rank
+
FROM artist_listener_counts
+
),
+
paginated_listeners AS (
+
SELECT
+
user_id,
+
artist,
+
total_artist_plays,
+
listener_rank
+
FROM top_artist_listeners
+
ORDER BY listener_rank
+
LIMIT ? OFFSET ?
+
)
+
SELECT
+
pl.artist,
+
pl.listener_rank,
+
u.id as user_id,
+
u.display_name,
+
u.did,
+
u.handle,
+
u.avatar,
+
pl.total_artist_plays,
+
utt.track_title as most_played_track,
+
utt.track_uri as most_played_track_uri,
+
utt.play_count as track_play_count
+
FROM paginated_listeners pl
+
JOIN users u ON pl.user_id = u.id
+
JOIN user_top_tracks utt ON pl.user_id = utt.user_id
+
AND utt.rn = 1
+
ORDER BY pl.listener_rank;
+
"#,
+
)?;
+
+
let listeners = stmt.query_map(
+
[&artist.name, &limit.to_string(), &offset.to_string()],
+
|row| {
+
Ok(ArtistListener {
+
artist: row.get(0)?,
+
listener_rank: row.get(1)?,
+
user_id: row.get(2)?,
+
display_name: row.get(3)?,
+
did: row.get(4)?,
+
handle: row.get(5)?,
+
avatar: row.get(6)?,
+
total_artist_plays: row.get(7)?,
+
most_played_track: row.get(8)?,
+
most_played_track_uri: row.get(9)?,
+
track_play_count: row.get(10)?,
+
})
+
},
+
)?;
+
+
let listeners: Result<Vec<_>, _> = listeners.collect();
+
Ok(HttpResponse::Ok().json(listeners?))
+
}
+3
crates/analytics/src/handlers/mod.rs
···
};
use tracks::{get_loved_tracks, get_top_tracks, get_tracks};
+
use crate::handlers::artists::get_artist_listeners;
+
pub mod albums;
pub mod artists;
pub mod scrobbles;
···
"library.getAlbumTracks" => get_album_tracks(payload, req, conn.clone()).await,
"library.getArtistAlbums" => get_artist_albums(payload, req, conn.clone()).await,
"library.getArtistTracks" => get_artist_tracks(payload, req, conn.clone()).await,
+
"library.getArtistListeners" => get_artist_listeners(payload, req, conn.clone()).await,
_ => return Err(anyhow::anyhow!("Method not found")),
}
}
+83
apps/api/src/xrpc/app/rocksky/artist/getArtistListeners.ts
···
+
import type { Context } from "context";
+
import { Effect, pipe } from "effect";
+
import type { Server } from "lexicon";
+
import type { ListenerViewBasic } from "lexicon/types/app/rocksky/artist/defs";
+
import type { QueryParams } from "lexicon/types/app/rocksky/artist/getArtistListeners";
+
+
export default function (server: Server, ctx: Context) {
+
const getArtistListeners = (params) =>
+
pipe(
+
{ params, ctx },
+
retrieve,
+
Effect.flatMap(presentation),
+
Effect.retry({ times: 3 }),
+
Effect.timeout("10 seconds"),
+
Effect.catchAll((err) => {
+
console.error(err);
+
return Effect.succeed({ listeners: [] });
+
})
+
);
+
server.app.rocksky.artist.getArtistListeners({
+
handler: async ({ params }) => {
+
const result = await Effect.runPromise(getArtistListeners(params));
+
return {
+
encoding: "application/json",
+
body: result,
+
};
+
},
+
});
+
}
+
+
const retrieve = ({
+
params,
+
ctx,
+
}: {
+
params: QueryParams;
+
ctx: Context;
+
}): Effect.Effect<{ data: ArtistListener[] }, Error> => {
+
return Effect.tryPromise({
+
try: () =>
+
ctx.analytics.post("library.getArtistListeners", {
+
artist_id: params.uri,
+
}),
+
catch: (error) =>
+
new Error(`Failed to retrieve artist's listeners: ${error}`),
+
});
+
};
+
+
const presentation = ({
+
data,
+
}: {
+
data: ArtistListener[];
+
}): Effect.Effect<{ listeners: ListenerViewBasic[] }, never> => {
+
return Effect.sync(() => ({
+
listeners: data.map((item) => ({
+
id: item.user_id,
+
did: item.did,
+
handle: item.handle,
+
displayName: item.display_name,
+
avatar: item.avatar,
+
mostListenedSong: {
+
title: item.most_played_track,
+
uri: item.most_played_track_uri,
+
playCount: item.track_play_count,
+
},
+
totalPlays: item.total_artist_plays,
+
rank: item.listener_rank,
+
})),
+
}));
+
};
+
+
type ArtistListener = {
+
artist: string;
+
avatar: string;
+
did: string;
+
display_name: string;
+
handle: string;
+
listener_rank: number;
+
most_played_track: string;
+
most_played_track_uri: string;
+
total_artist_plays: number;
+
track_play_count: number;
+
user_id: string;
+
};
+2
apps/api/src/xrpc/index.ts
···
import updateApikey from "./app/rocksky/apikey/updateApikey";
import getArtist from "./app/rocksky/artist/getArtist";
import getArtistAlbums from "./app/rocksky/artist/getArtistAlbums";
+
import getArtistListeners from "./app/rocksky/artist/getArtistListeners";
import getArtists from "./app/rocksky/artist/getArtists";
import getArtistTracks from "./app/rocksky/artist/getArtistTracks";
import getScrobblesChart from "./app/rocksky/charts/getScrobblesChart";
···
getArtist(server, ctx);
getArtistAlbums(server, ctx);
getArtists(server, ctx);
+
getArtistListeners(server, ctx);
getArtistTracks(server, ctx);
getScrobblesChart(server, ctx);
downloadFileFromDropbox(server, ctx);
+13 -5
apps/web/src/api/library.ts
···
export const getArtistTracks = async (
uri: string,
-
limit = 10,
+
limit = 10
): Promise<
{
id: string;
···
> => {
const response = await client.get(
"/xrpc/app.rocksky.artist.getArtistTracks",
-
{ params: { uri, limit } },
+
{ params: { uri, limit } }
);
return response.data.tracks;
};
export const getArtistAlbums = async (
uri: string,
-
limit = 10,
+
limit = 10
): Promise<
{
id: string;
···
> => {
const response = await client.get(
"/xrpc/app.rocksky.artist.getArtistAlbums",
-
{ params: { uri, limit } },
+
{ params: { uri, limit } }
);
return response.data.albums;
};
···
"/xrpc/app.rocksky.actor.getActorLovedSongs",
{
params: { did, limit, offset },
-
},
+
}
);
return response.data.tracks;
};
···
});
return response.data;
};
+
+
export const getArtistListeners = async (uri: string, limit: number) => {
+
const response = await client.get(
+
"/xrpc/app.rocksky.artist.getArtistListeners",
+
{ params: { uri, limit } }
+
);
+
return response.data;
+
};
+81 -72
apps/web/src/hooks/useLibrary.tsx
···
import { useQuery } from "@tanstack/react-query";
import {
-
getAlbum,
-
getAlbums,
-
getArtist,
-
getArtistAlbums,
-
getArtists,
-
getArtistTracks,
-
getLovedTracks,
-
getSongByUri,
-
getTracks,
+
getAlbum,
+
getAlbums,
+
getArtist,
+
getArtistAlbums,
+
getArtistListeners,
+
getArtists,
+
getArtistTracks,
+
getLovedTracks,
+
getSongByUri,
+
getTracks,
} from "../api/library";
export const useSongByUriQuery = (uri: string) =>
-
useQuery({
-
queryKey: ["songByUri", uri],
-
queryFn: () => getSongByUri(uri),
-
enabled: !!uri,
-
});
+
useQuery({
+
queryKey: ["songByUri", uri],
+
queryFn: () => getSongByUri(uri),
+
enabled: !!uri,
+
});
export const useArtistTracksQuery = (uri: string, limit = 10) =>
-
useQuery({
-
queryKey: ["artistTracks", uri, limit],
-
queryFn: () => getArtistTracks(uri, limit),
-
enabled: !!uri,
-
});
+
useQuery({
+
queryKey: ["artistTracks", uri, limit],
+
queryFn: () => getArtistTracks(uri, limit),
+
enabled: !!uri,
+
});
export const useArtistAlbumsQuery = (uri: string, limit = 10) =>
-
useQuery({
-
queryKey: ["artistAlbums", uri, limit],
-
queryFn: () => getArtistAlbums(uri, limit),
-
enabled: !!uri,
-
});
+
useQuery({
+
queryKey: ["artistAlbums", uri, limit],
+
queryFn: () => getArtistAlbums(uri, limit),
+
enabled: !!uri,
+
});
export const useArtistsQuery = (did: string, offset = 0, limit = 30) =>
-
useQuery({
-
queryKey: ["artists", did, offset, limit],
-
queryFn: () => getArtists(did, offset, limit),
-
enabled: !!did,
-
select: (data) =>
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
-
data?.artists.map((x: any) => ({
-
...x,
-
scrobbles: x.playCount,
-
})),
-
});
+
useQuery({
+
queryKey: ["artists", did, offset, limit],
+
queryFn: () => getArtists(did, offset, limit),
+
enabled: !!did,
+
select: (data) =>
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
+
data?.artists.map((x: any) => ({
+
...x,
+
scrobbles: x.playCount,
+
})),
+
});
export const useAlbumsQuery = (did: string, offset = 0, limit = 12) =>
-
useQuery({
-
queryKey: ["albums", did, offset, limit],
-
queryFn: () => getAlbums(did, offset, limit),
-
enabled: !!did,
-
select: (data) =>
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
-
data?.albums.map((x: any) => ({
-
...x,
-
scrobbles: x.playCount,
-
})),
-
});
+
useQuery({
+
queryKey: ["albums", did, offset, limit],
+
queryFn: () => getAlbums(did, offset, limit),
+
enabled: !!did,
+
select: (data) =>
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
+
data?.albums.map((x: any) => ({
+
...x,
+
scrobbles: x.playCount,
+
})),
+
});
export const useTracksQuery = (did: string, offset = 0, limit = 20) =>
-
useQuery({
-
queryKey: ["tracks", did, offset, limit],
-
queryFn: () => getTracks(did, offset, limit),
-
enabled: !!did,
-
select: (data) =>
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
-
data?.tracks.map((x: any) => ({
-
...x,
-
scrobbles: x.playCount,
-
})),
-
});
+
useQuery({
+
queryKey: ["tracks", did, offset, limit],
+
queryFn: () => getTracks(did, offset, limit),
+
enabled: !!did,
+
select: (data) =>
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
+
data?.tracks.map((x: any) => ({
+
...x,
+
scrobbles: x.playCount,
+
})),
+
});
export const useLovedTracksQuery = (did: string, offset = 0, limit = 20) =>
-
useQuery({
-
queryKey: ["lovedTracks", did, offset, limit],
-
queryFn: () => getLovedTracks(did, offset, limit),
-
enabled: !!did,
-
});
+
useQuery({
+
queryKey: ["lovedTracks", did, offset, limit],
+
queryFn: () => getLovedTracks(did, offset, limit),
+
enabled: !!did,
+
});
export const useAlbumQuery = (did: string, rkey: string) =>
-
useQuery({
-
queryKey: ["album", did, rkey],
-
queryFn: () => getAlbum(did, rkey),
-
enabled: !!did && !!rkey,
-
});
+
useQuery({
+
queryKey: ["album", did, rkey],
+
queryFn: () => getAlbum(did, rkey),
+
enabled: !!did && !!rkey,
+
});
export const useArtistQuery = (did: string, rkey: string) =>
-
useQuery({
-
queryKey: ["artist", did, rkey],
-
queryFn: () => getArtist(did, rkey),
-
enabled: !!did && !!rkey,
-
});
+
useQuery({
+
queryKey: ["artist", did, rkey],
+
queryFn: () => getArtist(did, rkey),
+
enabled: !!did && !!rkey,
+
});
+
+
export const useArtistListenersQuery = (uri: string, limit = 10) =>
+
useQuery({
+
queryKey: ["artistListeners", uri, limit],
+
queryFn: () => getArtistListeners(uri, limit),
+
enabled: !!uri,
+
select: (data) => data.listeners,
+
});
+191 -188
apps/web/src/pages/artist/Artist.tsx
···
import ArtistIcon from "../../components/Icons/Artist";
import Shout from "../../components/Shout/Shout";
import {
-
useArtistAlbumsQuery,
-
useArtistQuery,
-
useArtistTracksQuery,
+
useArtistAlbumsQuery,
+
useArtistListenersQuery,
+
useArtistQuery,
+
useArtistTracksQuery,
} from "../../hooks/useLibrary";
import Main from "../../layouts/Main";
import Albums from "./Albums";
+
import ArtistListeners from "./ArtistListeners";
import PopularSongs from "./PopularSongs";
const Group = styled.div`
···
`;
const Artist = () => {
-
const { did, rkey } = useParams({ strict: false });
-
-
const uri = `at://${did}/app.rocksky.artist/${rkey}`;
-
const artistResult = useArtistQuery(did!, rkey!);
-
const artistTracksResult = useArtistTracksQuery(uri);
-
const artistAlbumsResult = useArtistAlbumsQuery(uri);
-
-
const artist = useAtomValue(artistAtom);
-
const setArtist = useSetAtom(artistAtom);
-
const [topTracks, setTopTracks] = useState<
-
{
-
id: string;
-
title: string;
-
artist: string;
-
albumArtist: string;
-
albumArt: string;
-
uri: string;
-
scrobbles: number;
-
albumUri?: string;
-
artistUri?: string;
-
}[]
-
>([]);
-
const [topAlbums, setTopAlbums] = useState<
-
{
-
id: string;
-
title: string;
-
artist: string;
-
albumArt: string;
-
artistUri: string;
-
uri: string;
-
}[]
-
>([]);
-
-
useEffect(() => {
-
if (artistResult.isLoading || artistResult.isError) {
-
return;
-
}
-
-
if (!artistResult.data || !did) {
-
return;
-
}
-
-
setArtist({
-
id: artistResult.data.id,
-
name: artistResult.data.name,
-
born: artistResult.data.born,
-
bornIn: artistResult.data.bornIn,
-
died: artistResult.data.died,
-
listeners: artistResult.data.uniqueListeners,
-
scrobbles: artistResult.data.playCount,
-
picture: artistResult.data.picture,
-
tags: artistResult.data.tags,
-
uri: artistResult.data.uri,
-
spotifyLink: artistResult.data.spotifyLink,
-
});
-
// eslint-disable-next-line react-hooks/exhaustive-deps
-
}, [artistResult.data, artistResult.isLoading, artistResult.isError, did]);
-
-
useEffect(() => {
-
if (artistTracksResult.isLoading || artistTracksResult.isError) {
-
return;
-
}
-
-
if (!artistTracksResult.data || !did) {
-
return;
-
}
-
-
setTopTracks(
-
artistTracksResult.data.map((track) => ({
-
...track,
-
scrobbles: track.playCount || 1,
-
})),
-
);
-
}, [
-
artistTracksResult.data,
-
artistTracksResult.isLoading,
-
artistTracksResult.isError,
-
did,
-
]);
-
-
useEffect(() => {
-
if (artistAlbumsResult.isLoading || artistAlbumsResult.isError) {
-
return;
-
}
-
-
if (!artistAlbumsResult.data || !did) {
-
return;
-
}
-
-
setTopAlbums(artistAlbumsResult.data);
-
}, [
-
artistAlbumsResult.data,
-
artistAlbumsResult.isLoading,
-
artistAlbumsResult.isError,
-
did,
-
]);
-
-
const loading =
-
artistResult.isLoading ||
-
artistTracksResult.isLoading ||
-
artistAlbumsResult.isLoading;
-
return (
-
<Main>
-
<div className="pb-[100px] pt-[50px]">
-
<Group>
-
<div className="mr-[20px]">
-
{artist?.picture && !loading && (
-
<Avatar name={artist?.name} src={artist?.picture} size="150px" />
-
)}
-
{!artist?.picture && !loading && (
-
<div className="w-[150px] h-[150px] rounded-[80px] bg-[rgba(243, 243, 243, 0.725)] flex items-center justify-center">
-
<div
-
style={{
-
height: 60,
-
width: 60,
-
}}
-
>
-
<ArtistIcon color="rgba(66, 87, 108, 0.65)" />
-
</div>
-
</div>
-
)}
-
</div>
-
{artist && !loading && (
-
<div style={{ flex: 1 }}>
-
<HeadingMedium
-
marginTop={"20px"}
-
marginBottom={0}
-
className="!text-[var(--color-text)]"
-
>
-
{artist?.name}
-
</HeadingMedium>
-
<div className="mt-[20px] flex flex-row">
-
<div className="mr-[20px]">
-
<LabelMedium
-
margin={0}
-
className="!text-[var(--color-text-muted)]"
-
>
-
Listeners
-
</LabelMedium>
-
<HeadingXSmall
-
margin={0}
-
className="!text-[var(--color-text)]"
-
>
-
{numeral(artist?.listeners).format("0,0")}
-
</HeadingXSmall>
-
</div>
-
<div>
-
<LabelMedium
-
margin={0}
-
className="!text-[var(--color-text-muted)]"
-
>
-
Scrobbles
-
</LabelMedium>
-
<HeadingXSmall
-
margin={0}
-
className="!text-[var(--color-text)]"
-
>
-
{numeral(artist?.scrobbles).format("0,0")}
-
</HeadingXSmall>
-
</div>
-
<div className="flex items-center justify-end flex-1 mr-[10px]">
-
<a
-
href={`https://pdsls.dev/at/${uri.replace("at://", "")}`}
-
target="_blank"
-
className="text-[var(--color-text)] no-underline bg-[var(--color-default-button)] rounded-[10px] p-[16px] pl-[25px] pr-[25px]"
-
>
-
<ExternalLink
-
size={24}
-
className="mr-[10px] text-[var(--color-text)]"
-
/>
-
View on PDSls
-
</a>
-
</div>
-
</div>
-
</div>
-
)}
-
</Group>
-
-
<PopularSongs topTracks={topTracks} />
-
<Albums topAlbums={topAlbums} />
-
-
<Shout type="artist" />
-
</div>
-
</Main>
-
);
+
const { did, rkey } = useParams({ strict: false });
+
+
const uri = `at://${did}/app.rocksky.artist/${rkey}`;
+
const artistResult = useArtistQuery(did!, rkey!);
+
const artistTracksResult = useArtistTracksQuery(uri);
+
const artistAlbumsResult = useArtistAlbumsQuery(uri);
+
const artistListenersResult = useArtistListenersQuery(uri);
+
+
const artist = useAtomValue(artistAtom);
+
const setArtist = useSetAtom(artistAtom);
+
const [topTracks, setTopTracks] = useState<
+
{
+
id: string;
+
title: string;
+
artist: string;
+
albumArtist: string;
+
albumArt: string;
+
uri: string;
+
scrobbles: number;
+
albumUri?: string;
+
artistUri?: string;
+
}[]
+
>([]);
+
const [topAlbums, setTopAlbums] = useState<
+
{
+
id: string;
+
title: string;
+
artist: string;
+
albumArt: string;
+
artistUri: string;
+
uri: string;
+
}[]
+
>([]);
+
+
useEffect(() => {
+
if (artistResult.isLoading || artistResult.isError) {
+
return;
+
}
+
+
if (!artistResult.data || !did) {
+
return;
+
}
+
+
setArtist({
+
id: artistResult.data.id,
+
name: artistResult.data.name,
+
born: artistResult.data.born,
+
bornIn: artistResult.data.bornIn,
+
died: artistResult.data.died,
+
listeners: artistResult.data.uniqueListeners,
+
scrobbles: artistResult.data.playCount,
+
picture: artistResult.data.picture,
+
tags: artistResult.data.tags,
+
uri: artistResult.data.uri,
+
spotifyLink: artistResult.data.spotifyLink,
+
});
+
// eslint-disable-next-line react-hooks/exhaustive-deps
+
}, [artistResult.data, artistResult.isLoading, artistResult.isError, did]);
+
+
useEffect(() => {
+
if (artistTracksResult.isLoading || artistTracksResult.isError) {
+
return;
+
}
+
+
if (!artistTracksResult.data || !did) {
+
return;
+
}
+
+
setTopTracks(
+
artistTracksResult.data.map((track) => ({
+
...track,
+
scrobbles: track.playCount || 1,
+
})),
+
);
+
}, [
+
artistTracksResult.data,
+
artistTracksResult.isLoading,
+
artistTracksResult.isError,
+
did,
+
]);
+
+
useEffect(() => {
+
if (artistAlbumsResult.isLoading || artistAlbumsResult.isError) {
+
return;
+
}
+
+
if (!artistAlbumsResult.data || !did) {
+
return;
+
}
+
+
setTopAlbums(artistAlbumsResult.data);
+
}, [
+
artistAlbumsResult.data,
+
artistAlbumsResult.isLoading,
+
artistAlbumsResult.isError,
+
did,
+
]);
+
+
const loading =
+
artistResult.isLoading ||
+
artistTracksResult.isLoading ||
+
artistAlbumsResult.isLoading;
+
return (
+
<Main>
+
<div className="pb-[100px] pt-[50px]">
+
<Group>
+
<div className="mr-[20px]">
+
{artist?.picture && !loading && (
+
<Avatar name={artist?.name} src={artist?.picture} size="150px" />
+
)}
+
{!artist?.picture && !loading && (
+
<div className="w-[150px] h-[150px] rounded-[80px] bg-[rgba(243, 243, 243, 0.725)] flex items-center justify-center">
+
<div
+
style={{
+
height: 60,
+
width: 60,
+
}}
+
>
+
<ArtistIcon color="rgba(66, 87, 108, 0.65)" />
+
</div>
+
</div>
+
)}
+
</div>
+
{artist && !loading && (
+
<div style={{ flex: 1 }}>
+
<HeadingMedium
+
marginTop={"20px"}
+
marginBottom={0}
+
className="!text-[var(--color-text)]"
+
>
+
{artist?.name}
+
</HeadingMedium>
+
<div className="mt-[20px] flex flex-row">
+
<div className="mr-[20px]">
+
<LabelMedium
+
margin={0}
+
className="!text-[var(--color-text-muted)]"
+
>
+
Listeners
+
</LabelMedium>
+
<HeadingXSmall
+
margin={0}
+
className="!text-[var(--color-text)]"
+
>
+
{numeral(artist?.listeners).format("0,0")}
+
</HeadingXSmall>
+
</div>
+
<div>
+
<LabelMedium
+
margin={0}
+
className="!text-[var(--color-text-muted)]"
+
>
+
Scrobbles
+
</LabelMedium>
+
<HeadingXSmall
+
margin={0}
+
className="!text-[var(--color-text)]"
+
>
+
{numeral(artist?.scrobbles).format("0,0")}
+
</HeadingXSmall>
+
</div>
+
<div className="flex items-center justify-end flex-1 mr-[10px]">
+
<a
+
href={`https://pdsls.dev/at/${uri.replace("at://", "")}`}
+
target="_blank"
+
className="text-[var(--color-text)] no-underline bg-[var(--color-default-button)] rounded-[10px] p-[16px] pl-[25px] pr-[25px]"
+
>
+
<ExternalLink
+
size={24}
+
className="mr-[10px] text-[var(--color-text)]"
+
/>
+
View on PDSls
+
</a>
+
</div>
+
</div>
+
</div>
+
)}
+
</Group>
+
+
<PopularSongs topTracks={topTracks} />
+
<Albums topAlbums={topAlbums} />
+
<ArtistListeners listeners={artistListenersResult.data} />
+
<Shout type="artist" />
+
</div>
+
</Main>
+
);
};
export default Artist;
+74
apps/web/src/pages/artist/ArtistListeners/ArtistListeners.tsx
···
+
import { Link } from "@tanstack/react-router";
+
import { Avatar } from "baseui/avatar";
+
import { HeadingSmall } from "baseui/typography";
+
+
interface ArtistListenersProps {
+
listeners: {
+
id: string;
+
did: string;
+
handle: string;
+
displayName: string;
+
avatar: string;
+
mostListenedSong: {
+
title: string;
+
uri: string;
+
playCount: number;
+
};
+
totalPlays: number;
+
rank: number;
+
}[];
+
}
+
+
function ArtistListeners(props: ArtistListenersProps) {
+
return (
+
<>
+
<HeadingSmall
+
marginBottom={"15px"}
+
className="!text-[var(--color-text)] !mb-[30px]"
+
>
+
Listeners
+
</HeadingSmall>
+
{props.listeners?.map((item) => (
+
<div
+
key={item.id}
+
className="mb-[30px] flex flex-row items-center gap-[20px]"
+
>
+
<Link
+
to={`/profile/${item.handle}` as string}
+
className="no-underline"
+
>
+
<Avatar src={item.avatar} name={item.displayName} size={"60px"} />
+
</Link>
+
<div>
+
<Link
+
to={`/profile/${item.handle}` as string}
+
className="text-[var(--color-text)] hover:underline no-underline"
+
style={{ fontWeight: 600 }}
+
>
+
@{item.handle}
+
</Link>
+
<div className="!text-[14px] mt-[5px]">
+
Listens to{" "}
+
{item.mostListenedSong.uri && (
+
<Link
+
to={`${item.mostListenedSong.uri?.split("at:/")[1].replace("app.rocksky.", "")}`}
+
className="text-[var(--color-primary)] hover:underline no-underline"
+
>
+
{item.mostListenedSong.title}
+
</Link>
+
)}
+
{!item.mostListenedSong.uri && (
+
<div style={{ fontWeight: 600 }}>
+
{item.mostListenedSong.title}
+
</div>
+
)}{" "}
+
a lot
+
</div>
+
</div>
+
</div>
+
))}
+
</>
+
);
+
}
+
+
export default ArtistListeners;
+3
apps/web/src/pages/artist/ArtistListeners/index.tsx
···
+
import ArtistListeners from "./ArtistListeners";
+
+
export default ArtistListeners;