A decentralized music tracking and discovery platform built on AT Protocol ๐ŸŽต
listenbrainz spotify atproto lastfm musicbrainz scrobbling

display artist's top listeners #5

merged opened by tsiry-sandratraina.com targeting main from feat/artist-listeners
Labels

None yet.

assignee

None yet.

Participants 1
AT URI
at://did:plc:7vdlgi2bflelz7mmuxoqjfcr/sh.tangled.repo.pull/3lzkqspbbc622
+573 -271
Interdiff #0 โ†’ #1
+30 -1
apps/api/lexicons/artist/defs.json
··· 74 } 75 } 76 }, 77 "listenerViewBasic": { 78 "type": "object", 79 "properties": { ··· 100 }, 101 "mostListenedSong": { 102 "type": "ref", 103 - "ref": "app.rocksky.song.defs#songViewBasic" 104 } 105 } 106 }
··· 74 } 75 } 76 }, 77 + "songViewBasic": { 78 + "type": "object", 79 + "properties": { 80 + "uri": { 81 + "type": "string", 82 + "description": "The URI of the song.", 83 + "format": "at-uri" 84 + }, 85 + "title": { 86 + "type": "string", 87 + "description": "The title of the song." 88 + }, 89 + "playCount": { 90 + "type": "integer", 91 + "description": "The number of times the song has been played.", 92 + "minimum": 0 93 + } 94 + } 95 + }, 96 "listenerViewBasic": { 97 "type": "object", 98 "properties": { ··· 119 }, 120 "mostListenedSong": { 121 "type": "ref", 122 + "ref": "app.rocksky.artist.defs#songViewBasic" 123 + }, 124 + "totalPlays": { 125 + "type": "integer", 126 + "description": "The total number of plays by the listener.", 127 + "minimum": 0 128 + }, 129 + "rank": { 130 + "type": "integer", 131 + "description": "The rank of the listener among all listeners of the artist.", 132 + "minimum": 1 133 } 134 } 135 }
apps/api/lexicons/artist/getArtistListeners.json

This file has not been changed.

+36 -1
apps/api/pkl/defs/artist/defs.pkl
··· 91 } 92 } 93 94 ["listenerViewBasic"] { 95 type = "object" 96 properties { ··· 121 } 122 123 ["mostListenedSong"] = new Ref { 124 - ref = "app.rocksky.song.defs#songViewBasic" 125 } 126 127 }
··· 91 } 92 } 93 94 + ["songViewBasic"] { 95 + type = "object" 96 + properties { 97 + ["uri"] = new StringType { 98 + type = "string" 99 + format = "at-uri" 100 + description = "The URI of the song." 101 + } 102 + 103 + ["title"] = new StringType { 104 + type = "string" 105 + description = "The title of the song." 106 + } 107 + 108 + ["playCount"] = new IntegerType { 109 + type = "integer" 110 + description = "The number of times the song has been played." 111 + minimum = 0 112 + } 113 + 114 + } 115 + } 116 + 117 ["listenerViewBasic"] { 118 type = "object" 119 properties { ··· 144 } 145 146 ["mostListenedSong"] = new Ref { 147 + ref = "app.rocksky.artist.defs#songViewBasic" 148 + } 149 + 150 + ["totalPlays"] = new IntegerType { 151 + type = "integer" 152 + description = "The total number of plays by the listener." 153 + minimum = 0 154 + } 155 + 156 + ["rank"] = new IntegerType { 157 + type = "integer" 158 + description = "The rank of the listener among all listeners of the artist." 159 + minimum = 1 160 } 161 162 }
apps/api/pkl/defs/artist/getArtistListeners.pkl

This file has not been changed.

apps/api/src/lexicon/index.ts

This file has not been changed.

+31 -1
apps/api/src/lexicon/lexicons.ts
··· 1066 }, 1067 }, 1068 }, 1069 listenerViewBasic: { 1070 type: 'object', 1071 properties: { ··· 1092 }, 1093 mostListenedSong: { 1094 type: 'ref', 1095 - ref: 'lex:app.rocksky.song.defs#songViewBasic', 1096 }, 1097 }, 1098 },
··· 1066 }, 1067 }, 1068 }, 1069 + songViewBasic: { 1070 + type: 'object', 1071 + properties: { 1072 + uri: { 1073 + type: 'string', 1074 + description: 'The URI of the song.', 1075 + format: 'at-uri', 1076 + }, 1077 + title: { 1078 + type: 'string', 1079 + description: 'The title of the song.', 1080 + }, 1081 + playCount: { 1082 + type: 'integer', 1083 + description: 'The number of times the song has been played.', 1084 + minimum: 0, 1085 + }, 1086 + }, 1087 + }, 1088 listenerViewBasic: { 1089 type: 'object', 1090 properties: { ··· 1111 }, 1112 mostListenedSong: { 1113 type: 'ref', 1114 + ref: 'lex:app.rocksky.artist.defs#songViewBasic', 1115 + }, 1116 + totalPlays: { 1117 + type: 'integer', 1118 + description: 'The total number of plays by the listener.', 1119 + minimum: 0, 1120 + }, 1121 + rank: { 1122 + type: 'integer', 1123 + description: 1124 + 'The rank of the listener among all listeners of the artist.', 1125 + minimum: 1, 1126 }, 1127 }, 1128 },
+27 -2
apps/api/src/lexicon/types/app/rocksky/artist/defs.ts
··· 5 import { lexicons } from '../../../../lexicons' 6 import { isObj, hasProp } from '../../../../util' 7 import { CID } from 'multiformats/cid' 8 - import type * as AppRockskySongDefs from '../song/defs' 9 10 export interface ArtistViewBasic { 11 /** The unique identifier of the artist. */ ··· 67 return lexicons.validate('app.rocksky.artist.defs#artistViewDetailed', v) 68 } 69 70 export interface ListenerViewBasic { 71 /** The unique identifier of the actor. */ 72 id?: string ··· 78 displayName?: string 79 /** The URL of the listener's avatar image. */ 80 avatar?: string 81 - mostListenedSong?: AppRockskySongDefs.SongViewBasic 82 [k: string]: unknown 83 } 84
··· 5 import { lexicons } from '../../../../lexicons' 6 import { isObj, hasProp } from '../../../../util' 7 import { CID } from 'multiformats/cid' 8 9 export interface ArtistViewBasic { 10 /** The unique identifier of the artist. */ ··· 66 return lexicons.validate('app.rocksky.artist.defs#artistViewDetailed', v) 67 } 68 69 + export interface SongViewBasic { 70 + /** The URI of the song. */ 71 + uri?: string 72 + /** The title of the song. */ 73 + title?: string 74 + /** The number of times the song has been played. */ 75 + playCount?: number 76 + [k: string]: unknown 77 + } 78 + 79 + export function isSongViewBasic(v: unknown): v is SongViewBasic { 80 + return ( 81 + isObj(v) && 82 + hasProp(v, '$type') && 83 + v.$type === 'app.rocksky.artist.defs#songViewBasic' 84 + ) 85 + } 86 + 87 + export function validateSongViewBasic(v: unknown): ValidationResult { 88 + return lexicons.validate('app.rocksky.artist.defs#songViewBasic', v) 89 + } 90 + 91 export interface ListenerViewBasic { 92 /** The unique identifier of the actor. */ 93 id?: string ··· 99 displayName?: string 100 /** The URL of the listener's avatar image. */ 101 avatar?: string 102 + mostListenedSong?: SongViewBasic 103 + /** The total number of plays by the listener. */ 104 + totalPlays?: number 105 + /** The rank of the listener among all listeners of the artist. */ 106 + rank?: number 107 [k: string]: unknown 108 } 109
apps/api/src/lexicon/types/app/rocksky/artist/getArtistListeners.ts

This file has not been changed.

crates/analytics/src/handlers/artists.rs

This file has not been changed.

crates/analytics/src/handlers/mod.rs

This file has not been changed.

+2 -1
crates/analytics/src/types/artist.rs
··· 51 pub avatar: String, 52 pub total_artist_plays: i64, 53 pub most_played_track: String, 54 - pub most_played_track_uri: String, 55 pub track_play_count: i64, 56 } 57
··· 51 pub avatar: String, 52 pub total_artist_plays: i64, 53 pub most_played_track: String, 54 + #[serde(skip_serializing_if = "Option::is_none")] 55 + pub most_played_track_uri: Option<String>, 56 pub track_play_count: i64, 57 } 58
+83
apps/api/src/xrpc/app/rocksky/artist/getArtistListeners.ts
···
··· 1 + import type { Context } from "context"; 2 + import { Effect, pipe } from "effect"; 3 + import type { Server } from "lexicon"; 4 + import type { ListenerViewBasic } from "lexicon/types/app/rocksky/artist/defs"; 5 + import type { QueryParams } from "lexicon/types/app/rocksky/artist/getArtistListeners"; 6 + 7 + export default function (server: Server, ctx: Context) { 8 + const getArtistListeners = (params) => 9 + pipe( 10 + { params, ctx }, 11 + retrieve, 12 + Effect.flatMap(presentation), 13 + Effect.retry({ times: 3 }), 14 + Effect.timeout("10 seconds"), 15 + Effect.catchAll((err) => { 16 + console.error(err); 17 + return Effect.succeed({ listeners: [] }); 18 + }) 19 + ); 20 + server.app.rocksky.artist.getArtistListeners({ 21 + handler: async ({ params }) => { 22 + const result = await Effect.runPromise(getArtistListeners(params)); 23 + return { 24 + encoding: "application/json", 25 + body: result, 26 + }; 27 + }, 28 + }); 29 + } 30 + 31 + const retrieve = ({ 32 + params, 33 + ctx, 34 + }: { 35 + params: QueryParams; 36 + ctx: Context; 37 + }): Effect.Effect<{ data: ArtistListener[] }, Error> => { 38 + return Effect.tryPromise({ 39 + try: () => 40 + ctx.analytics.post("library.getArtistListeners", { 41 + artist_id: params.uri, 42 + }), 43 + catch: (error) => 44 + new Error(`Failed to retrieve artist's listeners: ${error}`), 45 + }); 46 + }; 47 + 48 + const presentation = ({ 49 + data, 50 + }: { 51 + data: ArtistListener[]; 52 + }): Effect.Effect<{ listeners: ListenerViewBasic[] }, never> => { 53 + return Effect.sync(() => ({ 54 + listeners: data.map((item) => ({ 55 + id: item.user_id, 56 + did: item.did, 57 + handle: item.handle, 58 + displayName: item.display_name, 59 + avatar: item.avatar, 60 + mostListenedSong: { 61 + title: item.most_played_track, 62 + uri: item.most_played_track_uri, 63 + playCount: item.track_play_count, 64 + }, 65 + totalPlays: item.total_artist_plays, 66 + rank: item.listener_rank, 67 + })), 68 + })); 69 + }; 70 + 71 + type ArtistListener = { 72 + artist: string; 73 + avatar: string; 74 + did: string; 75 + display_name: string; 76 + handle: string; 77 + listener_rank: number; 78 + most_played_track: string; 79 + most_played_track_uri: string; 80 + total_artist_plays: number; 81 + track_play_count: number; 82 + user_id: string; 83 + };
+2
apps/api/src/xrpc/index.ts
··· 16 import updateApikey from "./app/rocksky/apikey/updateApikey"; 17 import getArtist from "./app/rocksky/artist/getArtist"; 18 import getArtistAlbums from "./app/rocksky/artist/getArtistAlbums"; 19 import getArtists from "./app/rocksky/artist/getArtists"; 20 import getArtistTracks from "./app/rocksky/artist/getArtistTracks"; 21 import getScrobblesChart from "./app/rocksky/charts/getScrobblesChart"; ··· 91 getArtist(server, ctx); 92 getArtistAlbums(server, ctx); 93 getArtists(server, ctx); 94 getArtistTracks(server, ctx); 95 getScrobblesChart(server, ctx); 96 downloadFileFromDropbox(server, ctx);
··· 16 import updateApikey from "./app/rocksky/apikey/updateApikey"; 17 import getArtist from "./app/rocksky/artist/getArtist"; 18 import getArtistAlbums from "./app/rocksky/artist/getArtistAlbums"; 19 + import getArtistListeners from "./app/rocksky/artist/getArtistListeners"; 20 import getArtists from "./app/rocksky/artist/getArtists"; 21 import getArtistTracks from "./app/rocksky/artist/getArtistTracks"; 22 import getScrobblesChart from "./app/rocksky/charts/getScrobblesChart"; ··· 92 getArtist(server, ctx); 93 getArtistAlbums(server, ctx); 94 getArtists(server, ctx); 95 + getArtistListeners(server, ctx); 96 getArtistTracks(server, ctx); 97 getScrobblesChart(server, ctx); 98 downloadFileFromDropbox(server, ctx);
+13 -5
apps/web/src/api/library.ts
··· 29 30 export const getArtistTracks = async ( 31 uri: string, 32 - limit = 10, 33 ): Promise< 34 { 35 id: string; ··· 45 > => { 46 const response = await client.get( 47 "/xrpc/app.rocksky.artist.getArtistTracks", 48 - { params: { uri, limit } }, 49 ); 50 return response.data.tracks; 51 }; 52 53 export const getArtistAlbums = async ( 54 uri: string, 55 - limit = 10, 56 ): Promise< 57 { 58 id: string; ··· 65 > => { 66 const response = await client.get( 67 "/xrpc/app.rocksky.artist.getArtistAlbums", 68 - { params: { uri, limit } }, 69 ); 70 return response.data.albums; 71 }; ··· 96 "/xrpc/app.rocksky.actor.getActorLovedSongs", 97 { 98 params: { did, limit, offset }, 99 - }, 100 ); 101 return response.data.tracks; 102 }; ··· 114 }); 115 return response.data; 116 };
··· 29 30 export const getArtistTracks = async ( 31 uri: string, 32 + limit = 10 33 ): Promise< 34 { 35 id: string; ··· 45 > => { 46 const response = await client.get( 47 "/xrpc/app.rocksky.artist.getArtistTracks", 48 + { params: { uri, limit } } 49 ); 50 return response.data.tracks; 51 }; 52 53 export const getArtistAlbums = async ( 54 uri: string, 55 + limit = 10 56 ): Promise< 57 { 58 id: string; ··· 65 > => { 66 const response = await client.get( 67 "/xrpc/app.rocksky.artist.getArtistAlbums", 68 + { params: { uri, limit } } 69 ); 70 return response.data.albums; 71 }; ··· 96 "/xrpc/app.rocksky.actor.getActorLovedSongs", 97 { 98 params: { did, limit, offset }, 99 + } 100 ); 101 return response.data.tracks; 102 }; ··· 114 }); 115 return response.data; 116 }; 117 + 118 + export const getArtistListeners = async (uri: string, limit: number) => { 119 + const response = await client.get( 120 + "/xrpc/app.rocksky.artist.getArtistListeners", 121 + { params: { uri, limit } } 122 + ); 123 + return response.data; 124 + };
+81 -72
apps/web/src/hooks/useLibrary.tsx
··· 1 import { useQuery } from "@tanstack/react-query"; 2 import { 3 - getAlbum, 4 - getAlbums, 5 - getArtist, 6 - getArtistAlbums, 7 - getArtists, 8 - getArtistTracks, 9 - getLovedTracks, 10 - getSongByUri, 11 - getTracks, 12 } from "../api/library"; 13 14 export const useSongByUriQuery = (uri: string) => 15 - useQuery({ 16 - queryKey: ["songByUri", uri], 17 - queryFn: () => getSongByUri(uri), 18 - enabled: !!uri, 19 - }); 20 21 export const useArtistTracksQuery = (uri: string, limit = 10) => 22 - useQuery({ 23 - queryKey: ["artistTracks", uri, limit], 24 - queryFn: () => getArtistTracks(uri, limit), 25 - enabled: !!uri, 26 - }); 27 28 export const useArtistAlbumsQuery = (uri: string, limit = 10) => 29 - useQuery({ 30 - queryKey: ["artistAlbums", uri, limit], 31 - queryFn: () => getArtistAlbums(uri, limit), 32 - enabled: !!uri, 33 - }); 34 35 export const useArtistsQuery = (did: string, offset = 0, limit = 30) => 36 - useQuery({ 37 - queryKey: ["artists", did, offset, limit], 38 - queryFn: () => getArtists(did, offset, limit), 39 - enabled: !!did, 40 - select: (data) => 41 - // eslint-disable-next-line @typescript-eslint/no-explicit-any 42 - data?.artists.map((x: any) => ({ 43 - ...x, 44 - scrobbles: x.playCount, 45 - })), 46 - }); 47 48 export const useAlbumsQuery = (did: string, offset = 0, limit = 12) => 49 - useQuery({ 50 - queryKey: ["albums", did, offset, limit], 51 - queryFn: () => getAlbums(did, offset, limit), 52 - enabled: !!did, 53 - select: (data) => 54 - // eslint-disable-next-line @typescript-eslint/no-explicit-any 55 - data?.albums.map((x: any) => ({ 56 - ...x, 57 - scrobbles: x.playCount, 58 - })), 59 - }); 60 61 export const useTracksQuery = (did: string, offset = 0, limit = 20) => 62 - useQuery({ 63 - queryKey: ["tracks", did, offset, limit], 64 - queryFn: () => getTracks(did, offset, limit), 65 - enabled: !!did, 66 - select: (data) => 67 - // eslint-disable-next-line @typescript-eslint/no-explicit-any 68 - data?.tracks.map((x: any) => ({ 69 - ...x, 70 - scrobbles: x.playCount, 71 - })), 72 - }); 73 74 export const useLovedTracksQuery = (did: string, offset = 0, limit = 20) => 75 - useQuery({ 76 - queryKey: ["lovedTracks", did, offset, limit], 77 - queryFn: () => getLovedTracks(did, offset, limit), 78 - enabled: !!did, 79 - }); 80 81 export const useAlbumQuery = (did: string, rkey: string) => 82 - useQuery({ 83 - queryKey: ["album", did, rkey], 84 - queryFn: () => getAlbum(did, rkey), 85 - enabled: !!did && !!rkey, 86 - }); 87 88 export const useArtistQuery = (did: string, rkey: string) => 89 - useQuery({ 90 - queryKey: ["artist", did, rkey], 91 - queryFn: () => getArtist(did, rkey), 92 - enabled: !!did && !!rkey, 93 - });
··· 1 import { useQuery } from "@tanstack/react-query"; 2 import { 3 + getAlbum, 4 + getAlbums, 5 + getArtist, 6 + getArtistAlbums, 7 + getArtistListeners, 8 + getArtists, 9 + getArtistTracks, 10 + getLovedTracks, 11 + getSongByUri, 12 + getTracks, 13 } from "../api/library"; 14 15 export const useSongByUriQuery = (uri: string) => 16 + useQuery({ 17 + queryKey: ["songByUri", uri], 18 + queryFn: () => getSongByUri(uri), 19 + enabled: !!uri, 20 + }); 21 22 export const useArtistTracksQuery = (uri: string, limit = 10) => 23 + useQuery({ 24 + queryKey: ["artistTracks", uri, limit], 25 + queryFn: () => getArtistTracks(uri, limit), 26 + enabled: !!uri, 27 + }); 28 29 export const useArtistAlbumsQuery = (uri: string, limit = 10) => 30 + useQuery({ 31 + queryKey: ["artistAlbums", uri, limit], 32 + queryFn: () => getArtistAlbums(uri, limit), 33 + enabled: !!uri, 34 + }); 35 36 export const useArtistsQuery = (did: string, offset = 0, limit = 30) => 37 + useQuery({ 38 + queryKey: ["artists", did, offset, limit], 39 + queryFn: () => getArtists(did, offset, limit), 40 + enabled: !!did, 41 + select: (data) => 42 + // eslint-disable-next-line @typescript-eslint/no-explicit-any 43 + data?.artists.map((x: any) => ({ 44 + ...x, 45 + scrobbles: x.playCount, 46 + })), 47 + }); 48 49 export const useAlbumsQuery = (did: string, offset = 0, limit = 12) => 50 + useQuery({ 51 + queryKey: ["albums", did, offset, limit], 52 + queryFn: () => getAlbums(did, offset, limit), 53 + enabled: !!did, 54 + select: (data) => 55 + // eslint-disable-next-line @typescript-eslint/no-explicit-any 56 + data?.albums.map((x: any) => ({ 57 + ...x, 58 + scrobbles: x.playCount, 59 + })), 60 + }); 61 62 export const useTracksQuery = (did: string, offset = 0, limit = 20) => 63 + useQuery({ 64 + queryKey: ["tracks", did, offset, limit], 65 + queryFn: () => getTracks(did, offset, limit), 66 + enabled: !!did, 67 + select: (data) => 68 + // eslint-disable-next-line @typescript-eslint/no-explicit-any 69 + data?.tracks.map((x: any) => ({ 70 + ...x, 71 + scrobbles: x.playCount, 72 + })), 73 + }); 74 75 export const useLovedTracksQuery = (did: string, offset = 0, limit = 20) => 76 + useQuery({ 77 + queryKey: ["lovedTracks", did, offset, limit], 78 + queryFn: () => getLovedTracks(did, offset, limit), 79 + enabled: !!did, 80 + }); 81 82 export const useAlbumQuery = (did: string, rkey: string) => 83 + useQuery({ 84 + queryKey: ["album", did, rkey], 85 + queryFn: () => getAlbum(did, rkey), 86 + enabled: !!did && !!rkey, 87 + }); 88 89 export const useArtistQuery = (did: string, rkey: string) => 90 + useQuery({ 91 + queryKey: ["artist", did, rkey], 92 + queryFn: () => getArtist(did, rkey), 93 + enabled: !!did && !!rkey, 94 + }); 95 + 96 + export const useArtistListenersQuery = (uri: string, limit = 10) => 97 + useQuery({ 98 + queryKey: ["artistListeners", uri, limit], 99 + queryFn: () => getArtistListeners(uri, limit), 100 + enabled: !!uri, 101 + select: (data) => data.listeners, 102 + });
+191 -188
apps/web/src/pages/artist/Artist.tsx
··· 10 import ArtistIcon from "../../components/Icons/Artist"; 11 import Shout from "../../components/Shout/Shout"; 12 import { 13 - useArtistAlbumsQuery, 14 - useArtistQuery, 15 - useArtistTracksQuery, 16 } from "../../hooks/useLibrary"; 17 import Main from "../../layouts/Main"; 18 import Albums from "./Albums"; 19 import PopularSongs from "./PopularSongs"; 20 21 const Group = styled.div` ··· 26 `; 27 28 const Artist = () => { 29 - const { did, rkey } = useParams({ strict: false }); 30 - 31 - const uri = `at://${did}/app.rocksky.artist/${rkey}`; 32 - const artistResult = useArtistQuery(did!, rkey!); 33 - const artistTracksResult = useArtistTracksQuery(uri); 34 - const artistAlbumsResult = useArtistAlbumsQuery(uri); 35 - 36 - const artist = useAtomValue(artistAtom); 37 - const setArtist = useSetAtom(artistAtom); 38 - const [topTracks, setTopTracks] = useState< 39 - { 40 - id: string; 41 - title: string; 42 - artist: string; 43 - albumArtist: string; 44 - albumArt: string; 45 - uri: string; 46 - scrobbles: number; 47 - albumUri?: string; 48 - artistUri?: string; 49 - }[] 50 - >([]); 51 - const [topAlbums, setTopAlbums] = useState< 52 - { 53 - id: string; 54 - title: string; 55 - artist: string; 56 - albumArt: string; 57 - artistUri: string; 58 - uri: string; 59 - }[] 60 - >([]); 61 - 62 - useEffect(() => { 63 - if (artistResult.isLoading || artistResult.isError) { 64 - return; 65 - } 66 - 67 - if (!artistResult.data || !did) { 68 - return; 69 - } 70 - 71 - setArtist({ 72 - id: artistResult.data.id, 73 - name: artistResult.data.name, 74 - born: artistResult.data.born, 75 - bornIn: artistResult.data.bornIn, 76 - died: artistResult.data.died, 77 - listeners: artistResult.data.uniqueListeners, 78 - scrobbles: artistResult.data.playCount, 79 - picture: artistResult.data.picture, 80 - tags: artistResult.data.tags, 81 - uri: artistResult.data.uri, 82 - spotifyLink: artistResult.data.spotifyLink, 83 - }); 84 - // eslint-disable-next-line react-hooks/exhaustive-deps 85 - }, [artistResult.data, artistResult.isLoading, artistResult.isError, did]); 86 - 87 - useEffect(() => { 88 - if (artistTracksResult.isLoading || artistTracksResult.isError) { 89 - return; 90 - } 91 - 92 - if (!artistTracksResult.data || !did) { 93 - return; 94 - } 95 - 96 - setTopTracks( 97 - artistTracksResult.data.map((track) => ({ 98 - ...track, 99 - scrobbles: track.playCount || 1, 100 - })), 101 - ); 102 - }, [ 103 - artistTracksResult.data, 104 - artistTracksResult.isLoading, 105 - artistTracksResult.isError, 106 - did, 107 - ]); 108 - 109 - useEffect(() => { 110 - if (artistAlbumsResult.isLoading || artistAlbumsResult.isError) { 111 - return; 112 - } 113 - 114 - if (!artistAlbumsResult.data || !did) { 115 - return; 116 - } 117 - 118 - setTopAlbums(artistAlbumsResult.data); 119 - }, [ 120 - artistAlbumsResult.data, 121 - artistAlbumsResult.isLoading, 122 - artistAlbumsResult.isError, 123 - did, 124 - ]); 125 - 126 - const loading = 127 - artistResult.isLoading || 128 - artistTracksResult.isLoading || 129 - artistAlbumsResult.isLoading; 130 - return ( 131 - <Main> 132 - <div className="pb-[100px] pt-[50px]"> 133 - <Group> 134 - <div className="mr-[20px]"> 135 - {artist?.picture && !loading && ( 136 - <Avatar name={artist?.name} src={artist?.picture} size="150px" /> 137 - )} 138 - {!artist?.picture && !loading && ( 139 - <div className="w-[150px] h-[150px] rounded-[80px] bg-[rgba(243, 243, 243, 0.725)] flex items-center justify-center"> 140 - <div 141 - style={{ 142 - height: 60, 143 - width: 60, 144 - }} 145 - > 146 - <ArtistIcon color="rgba(66, 87, 108, 0.65)" /> 147 - </div> 148 - </div> 149 - )} 150 - </div> 151 - {artist && !loading && ( 152 - <div style={{ flex: 1 }}> 153 - <HeadingMedium 154 - marginTop={"20px"} 155 - marginBottom={0} 156 - className="!text-[var(--color-text)]" 157 - > 158 - {artist?.name} 159 - </HeadingMedium> 160 - <div className="mt-[20px] flex flex-row"> 161 - <div className="mr-[20px]"> 162 - <LabelMedium 163 - margin={0} 164 - className="!text-[var(--color-text-muted)]" 165 - > 166 - Listeners 167 - </LabelMedium> 168 - <HeadingXSmall 169 - margin={0} 170 - className="!text-[var(--color-text)]" 171 - > 172 - {numeral(artist?.listeners).format("0,0")} 173 - </HeadingXSmall> 174 - </div> 175 - <div> 176 - <LabelMedium 177 - margin={0} 178 - className="!text-[var(--color-text-muted)]" 179 - > 180 - Scrobbles 181 - </LabelMedium> 182 - <HeadingXSmall 183 - margin={0} 184 - className="!text-[var(--color-text)]" 185 - > 186 - {numeral(artist?.scrobbles).format("0,0")} 187 - </HeadingXSmall> 188 - </div> 189 - <div className="flex items-center justify-end flex-1 mr-[10px]"> 190 - <a 191 - href={`https://pdsls.dev/at/${uri.replace("at://", "")}`} 192 - target="_blank" 193 - className="text-[var(--color-text)] no-underline bg-[var(--color-default-button)] rounded-[10px] p-[16px] pl-[25px] pr-[25px]" 194 - > 195 - <ExternalLink 196 - size={24} 197 - className="mr-[10px] text-[var(--color-text)]" 198 - /> 199 - View on PDSls 200 - </a> 201 - </div> 202 - </div> 203 - </div> 204 - )} 205 - </Group> 206 - 207 - <PopularSongs topTracks={topTracks} /> 208 - <Albums topAlbums={topAlbums} /> 209 - 210 - <Shout type="artist" /> 211 - </div> 212 - </Main> 213 - ); 214 }; 215 216 export default Artist;
··· 10 import ArtistIcon from "../../components/Icons/Artist"; 11 import Shout from "../../components/Shout/Shout"; 12 import { 13 + useArtistAlbumsQuery, 14 + useArtistListenersQuery, 15 + useArtistQuery, 16 + useArtistTracksQuery, 17 } from "../../hooks/useLibrary"; 18 import Main from "../../layouts/Main"; 19 import Albums from "./Albums"; 20 + import ArtistListeners from "./ArtistListeners"; 21 import PopularSongs from "./PopularSongs"; 22 23 const Group = styled.div` ··· 28 `; 29 30 const Artist = () => { 31 + const { did, rkey } = useParams({ strict: false }); 32 + 33 + const uri = `at://${did}/app.rocksky.artist/${rkey}`; 34 + const artistResult = useArtistQuery(did!, rkey!); 35 + const artistTracksResult = useArtistTracksQuery(uri); 36 + const artistAlbumsResult = useArtistAlbumsQuery(uri); 37 + const artistListenersResult = useArtistListenersQuery(uri); 38 + 39 + const artist = useAtomValue(artistAtom); 40 + const setArtist = useSetAtom(artistAtom); 41 + const [topTracks, setTopTracks] = useState< 42 + { 43 + id: string; 44 + title: string; 45 + artist: string; 46 + albumArtist: string; 47 + albumArt: string; 48 + uri: string; 49 + scrobbles: number; 50 + albumUri?: string; 51 + artistUri?: string; 52 + }[] 53 + >([]); 54 + const [topAlbums, setTopAlbums] = useState< 55 + { 56 + id: string; 57 + title: string; 58 + artist: string; 59 + albumArt: string; 60 + artistUri: string; 61 + uri: string; 62 + }[] 63 + >([]); 64 + 65 + useEffect(() => { 66 + if (artistResult.isLoading || artistResult.isError) { 67 + return; 68 + } 69 + 70 + if (!artistResult.data || !did) { 71 + return; 72 + } 73 + 74 + setArtist({ 75 + id: artistResult.data.id, 76 + name: artistResult.data.name, 77 + born: artistResult.data.born, 78 + bornIn: artistResult.data.bornIn, 79 + died: artistResult.data.died, 80 + listeners: artistResult.data.uniqueListeners, 81 + scrobbles: artistResult.data.playCount, 82 + picture: artistResult.data.picture, 83 + tags: artistResult.data.tags, 84 + uri: artistResult.data.uri, 85 + spotifyLink: artistResult.data.spotifyLink, 86 + }); 87 + // eslint-disable-next-line react-hooks/exhaustive-deps 88 + }, [artistResult.data, artistResult.isLoading, artistResult.isError, did]); 89 + 90 + useEffect(() => { 91 + if (artistTracksResult.isLoading || artistTracksResult.isError) { 92 + return; 93 + } 94 + 95 + if (!artistTracksResult.data || !did) { 96 + return; 97 + } 98 + 99 + setTopTracks( 100 + artistTracksResult.data.map((track) => ({ 101 + ...track, 102 + scrobbles: track.playCount || 1, 103 + })), 104 + ); 105 + }, [ 106 + artistTracksResult.data, 107 + artistTracksResult.isLoading, 108 + artistTracksResult.isError, 109 + did, 110 + ]); 111 + 112 + useEffect(() => { 113 + if (artistAlbumsResult.isLoading || artistAlbumsResult.isError) { 114 + return; 115 + } 116 + 117 + if (!artistAlbumsResult.data || !did) { 118 + return; 119 + } 120 + 121 + setTopAlbums(artistAlbumsResult.data); 122 + }, [ 123 + artistAlbumsResult.data, 124 + artistAlbumsResult.isLoading, 125 + artistAlbumsResult.isError, 126 + did, 127 + ]); 128 + 129 + const loading = 130 + artistResult.isLoading || 131 + artistTracksResult.isLoading || 132 + artistAlbumsResult.isLoading; 133 + return ( 134 + <Main> 135 + <div className="pb-[100px] pt-[50px]"> 136 + <Group> 137 + <div className="mr-[20px]"> 138 + {artist?.picture && !loading && ( 139 + <Avatar name={artist?.name} src={artist?.picture} size="150px" /> 140 + )} 141 + {!artist?.picture && !loading && ( 142 + <div className="w-[150px] h-[150px] rounded-[80px] bg-[rgba(243, 243, 243, 0.725)] flex items-center justify-center"> 143 + <div 144 + style={{ 145 + height: 60, 146 + width: 60, 147 + }} 148 + > 149 + <ArtistIcon color="rgba(66, 87, 108, 0.65)" /> 150 + </div> 151 + </div> 152 + )} 153 + </div> 154 + {artist && !loading && ( 155 + <div style={{ flex: 1 }}> 156 + <HeadingMedium 157 + marginTop={"20px"} 158 + marginBottom={0} 159 + className="!text-[var(--color-text)]" 160 + > 161 + {artist?.name} 162 + </HeadingMedium> 163 + <div className="mt-[20px] flex flex-row"> 164 + <div className="mr-[20px]"> 165 + <LabelMedium 166 + margin={0} 167 + className="!text-[var(--color-text-muted)]" 168 + > 169 + Listeners 170 + </LabelMedium> 171 + <HeadingXSmall 172 + margin={0} 173 + className="!text-[var(--color-text)]" 174 + > 175 + {numeral(artist?.listeners).format("0,0")} 176 + </HeadingXSmall> 177 + </div> 178 + <div> 179 + <LabelMedium 180 + margin={0} 181 + className="!text-[var(--color-text-muted)]" 182 + > 183 + Scrobbles 184 + </LabelMedium> 185 + <HeadingXSmall 186 + margin={0} 187 + className="!text-[var(--color-text)]" 188 + > 189 + {numeral(artist?.scrobbles).format("0,0")} 190 + </HeadingXSmall> 191 + </div> 192 + <div className="flex items-center justify-end flex-1 mr-[10px]"> 193 + <a 194 + href={`https://pdsls.dev/at/${uri.replace("at://", "")}`} 195 + target="_blank" 196 + className="text-[var(--color-text)] no-underline bg-[var(--color-default-button)] rounded-[10px] p-[16px] pl-[25px] pr-[25px]" 197 + > 198 + <ExternalLink 199 + size={24} 200 + className="mr-[10px] text-[var(--color-text)]" 201 + /> 202 + View on PDSls 203 + </a> 204 + </div> 205 + </div> 206 + </div> 207 + )} 208 + </Group> 209 + 210 + <PopularSongs topTracks={topTracks} /> 211 + <Albums topAlbums={topAlbums} /> 212 + <ArtistListeners listeners={artistListenersResult.data} /> 213 + <Shout type="artist" /> 214 + </div> 215 + </Main> 216 + ); 217 }; 218 219 export default Artist;
+74
apps/web/src/pages/artist/ArtistListeners/ArtistListeners.tsx
···
··· 1 + import { Link } from "@tanstack/react-router"; 2 + import { Avatar } from "baseui/avatar"; 3 + import { HeadingSmall } from "baseui/typography"; 4 + 5 + interface ArtistListenersProps { 6 + listeners: { 7 + id: string; 8 + did: string; 9 + handle: string; 10 + displayName: string; 11 + avatar: string; 12 + mostListenedSong: { 13 + title: string; 14 + uri: string; 15 + playCount: number; 16 + }; 17 + totalPlays: number; 18 + rank: number; 19 + }[]; 20 + } 21 + 22 + function ArtistListeners(props: ArtistListenersProps) { 23 + return ( 24 + <> 25 + <HeadingSmall 26 + marginBottom={"15px"} 27 + className="!text-[var(--color-text)] !mb-[30px]" 28 + > 29 + Listeners 30 + </HeadingSmall> 31 + {props.listeners?.map((item) => ( 32 + <div 33 + key={item.id} 34 + className="mb-[30px] flex flex-row items-center gap-[20px]" 35 + > 36 + <Link 37 + to={`/profile/${item.handle}` as string} 38 + className="no-underline" 39 + > 40 + <Avatar src={item.avatar} name={item.displayName} size={"60px"} /> 41 + </Link> 42 + <div> 43 + <Link 44 + to={`/profile/${item.handle}` as string} 45 + className="text-[var(--color-text)] hover:underline no-underline" 46 + style={{ fontWeight: 600 }} 47 + > 48 + @{item.handle} 49 + </Link> 50 + <div className="!text-[14px] mt-[5px]"> 51 + Listens to{" "} 52 + {item.mostListenedSong.uri && ( 53 + <Link 54 + to={`${item.mostListenedSong.uri?.split("at:/")[1].replace("app.rocksky.", "")}`} 55 + className="text-[var(--color-primary)] hover:underline no-underline" 56 + > 57 + {item.mostListenedSong.title} 58 + </Link> 59 + )} 60 + {!item.mostListenedSong.uri && ( 61 + <div style={{ fontWeight: 600 }}> 62 + {item.mostListenedSong.title} 63 + </div> 64 + )}{" "} 65 + a lot 66 + </div> 67 + </div> 68 + </div> 69 + ))} 70 + </> 71 + ); 72 + } 73 + 74 + export default ArtistListeners;
+3
apps/web/src/pages/artist/ArtistListeners/index.tsx
···
··· 1 + import ArtistListeners from "./ArtistListeners"; 2 + 3 + export default ArtistListeners;

History

2 rounds 0 comments
sign up or login to add to the discussion
5 commits
expand
feat: add listenerViewBasic schema and getArtistListeners endpoint with associated types
feat: add songViewBasic schema and getArtistListeners endpoint with associated types
feat: add getArtistListeners function to the server endpoint
feat: make most_played_track_uri optional in ArtistListener struct
feat: add ArtistListeners component and integrate with Artist page
expand 0 comments
pull request successfully merged
1 commit
expand
feat: add listenerViewBasic schema and getArtistListeners endpoint with associated types
expand 0 comments