+30
-1
apps/api/lexicons/artist/defs.json
+30
-1
apps/api/lexicons/artist/defs.json
···
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
apps/api/lexicons/artist/getArtistListeners.json
This file has not been changed.
+36
-1
apps/api/pkl/defs/artist/defs.pkl
+36
-1
apps/api/pkl/defs/artist/defs.pkl
···
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
apps/api/pkl/defs/artist/getArtistListeners.pkl
This file has not been changed.
apps/api/src/lexicon/index.ts
apps/api/src/lexicon/index.ts
This file has not been changed.
+31
-1
apps/api/src/lexicon/lexicons.ts
+31
-1
apps/api/src/lexicon/lexicons.ts
···
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
+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
apps/api/src/lexicon/types/app/rocksky/artist/getArtistListeners.ts
This file has not been changed.
crates/analytics/src/handlers/artists.rs
crates/analytics/src/handlers/artists.rs
This file has not been changed.
crates/analytics/src/handlers/mod.rs
crates/analytics/src/handlers/mod.rs
This file has not been changed.
+2
-1
crates/analytics/src/types/artist.rs
+2
-1
crates/analytics/src/types/artist.rs
+83
apps/api/src/xrpc/app/rocksky/artist/getArtistListeners.ts
+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
+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
+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
+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
+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
+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;
History
2 rounds
0 comments
tsiry-sandratraina.com
submitted
#1
5 commits
expand
collapse
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
tsiry-sandratraina.com
submitted
#0
1 commit
expand
collapse
feat: add listenerViewBasic schema and getArtistListeners endpoint with associated types