diff --git a/apps/amethyst/app/(tabs)/:o/music/[artist]/index.tsx b/apps/amethyst/app/(tabs)/:o/music/[artist]/index.tsx index d84dca5..d0ce751 100644 --- a/apps/amethyst/app/(tabs)/:o/music/[artist]/index.tsx +++ b/apps/amethyst/app/(tabs)/:o/music/[artist]/index.tsx @@ -1,24 +1,46 @@ import { useEffect, useState } from "react"; import { ActivityIndicator, Image, Pressable, View } from "react-native"; import { Link, Stack, useLocalSearchParams } from "expo-router"; +import { + ArtistLeaderboardList, + ArtistListenerPeriodTabs, + normalizeArtistListenerPeriod, +} from "@/components/teal/ArtistLeaderboard"; import RightRail from "@/components/teal/RightRail"; import TealShell, { SectionHeading, } from "@/components/teal/TealShell"; +import { Button } from "@/components/ui/button"; import { Text } from "@/components/ui/text"; import { Icon } from "@/lib/icons/iconWithClassName"; -import { coverArtUrl, getArtist, getArtistImageUrl } from "@/lib/teal/api"; -import { musicAlbumHref } from "@/lib/teal/routes"; -import { ChevronRight, Disc3, Mic2 } from "lucide-react-native"; +import { + coverArtUrl, + getArtist, + getArtistImageUrl, + getArtistListeners, + type ArtistListenerPeriod, +} from "@/lib/teal/api"; +import { musicAlbumHref, musicArtistListenersHref } from "@/lib/teal/routes"; +import { ChevronRight, Disc3, Mic2, Trophy } from "lucide-react-native"; import type { ArtistView } from "@teal/lexicons/src/types/fm/teal/alpha/music/defs"; +import type { ArtistListenerView } from "@teal/lexicons/src/types/fm/teal/alpha/music/defs"; export default function ArtistDetail() { const params = useLocalSearchParams(); const mbid = Array.isArray(params.mbid) ? params.mbid[0] : params.mbid; const name = Array.isArray(params.name) ? params.name[0] : params.name; + const initialPeriod = Array.isArray(params.period) + ? params.period[0] + : params.period; const [artist, setArtist] = useState(null); + const [listeners, setListeners] = useState([]); + const [listenerPeriod, setListenerPeriod] = useState( + normalizeArtistListenerPeriod(initialPeriod), + ); const [error, setError] = useState(); + const [listenerError, setListenerError] = useState(); + const [listenersLoading, setListenersLoading] = useState(false); const [artFailed, setArtFailed] = useState(false); const [artistImage, setArtistImage] = useState(); @@ -40,6 +62,30 @@ export default function ArtistDetail() { }; }, [mbid, name]); + useEffect(() => { + let mounted = true; + setListenersLoading(true); + setListenerError(undefined); + getArtistListeners(mbid, name, listenerPeriod, 5) + .then(({ listeners }) => { + if (mounted) setListeners(listeners); + }) + .catch((loadError) => { + if (mounted) { + setListeners([]); + setListenerError( + loadError instanceof Error ? loadError.message : String(loadError), + ); + } + }) + .finally(() => { + if (mounted) setListenersLoading(false); + }); + return () => { + mounted = false; + }; + }, [mbid, name, listenerPeriod]); + useEffect(() => { let mounted = true; setArtistImage(undefined); @@ -124,6 +170,57 @@ export default function ArtistDetail() { + + + + + + {listenersLoading && listeners.length === 0 ? ( + + + + ) : listenerError ? ( + + + Could not load listeners: {listenerError} + + + ) : listeners.length === 0 ? ( + + + + No indexed listeners for this period yet. + + + ) : ( + <> + + + + + + )} + + (null); + const [listeners, setListeners] = useState([]); + const [period, setPeriod] = useState( + normalizeArtistListenerPeriod(periodParam), + ); + const [cursor, setCursor] = useState(); + const [error, setError] = useState(); + const [loading, setLoading] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); + const [artistImage, setArtistImage] = useState(); + const [artFailed, setArtFailed] = useState(false); + const loadingMoreRef = useRef(false); + + useEffect(() => { + let mounted = true; + setError(undefined); + setArtist(null); + getArtist(mbid, name) + .then(({ artist }) => { + if (mounted) setArtist(artist); + }) + .catch((loadError) => { + if (mounted) { + setError( + loadError instanceof Error ? loadError.message : String(loadError), + ); + } + }); + return () => { + mounted = false; + }; + }, [mbid, name]); + + useEffect(() => { + let mounted = true; + setLoading(true); + setError(undefined); + setListeners([]); + setCursor(undefined); + getArtistListeners(mbid, name, period, 50) + .then((page) => { + if (!mounted) return; + setListeners(page.listeners); + setCursor(page.cursor); + }) + .catch((loadError) => { + if (mounted) { + setError( + loadError instanceof Error ? loadError.message : String(loadError), + ); + } + }) + .finally(() => { + if (mounted) setLoading(false); + }); + return () => { + mounted = false; + }; + }, [mbid, name, period]); + + useEffect(() => { + let mounted = true; + setArtistImage(undefined); + setArtFailed(false); + if (!artist?.mbid) return; + getArtistImageUrl(artist.mbid, 500).then((imageUrl) => { + if (mounted) setArtistImage(imageUrl); + }); + return () => { + mounted = false; + }; + }, [artist?.mbid]); + + const loadMore = useCallback(() => { + if (!cursor || loadingMoreRef.current) return; + loadingMoreRef.current = true; + setLoadingMore(true); + getArtistListeners(mbid, name, period, 50, cursor) + .then((page) => { + setListeners((current) => { + const knownDids = new Set( + current.map((listener) => listener.actor.did).filter(Boolean), + ); + return [ + ...current, + ...page.listeners.filter( + (listener) => + !listener.actor.did || !knownDids.has(listener.actor.did), + ), + ]; + }); + setCursor(page.cursor); + }) + .catch((loadError) => { + setError( + loadError instanceof Error ? loadError.message : String(loadError), + ); + }) + .finally(() => { + loadingMoreRef.current = false; + setLoadingMore(false); + }); + }, [cursor, mbid, name, period]); + + const handleScroll = useCallback( + (event: NativeSyntheticEvent) => { + const { contentOffset, contentSize, layoutMeasurement } = + event.nativeEvent; + const remaining = + contentSize.height - (contentOffset.y + layoutMeasurement.height); + if (remaining < 800) loadMore(); + }, + [loadMore], + ); + + const representativeArt = coverArtUrl(artist?.albums[0]?.mbid, 500); + const heroArt = artistImage || (artFailed ? undefined : representativeArt); + const title = artist?.name || name || "Artist"; + + return ( + } onScroll={handleScroll}> + + + + {heroArt && ( + setArtFailed(true)} + /> + )} + + + + {heroArt ? ( + setArtFailed(true)} + /> + ) : ( + + )} + + + + Artist leaderboard + + + {title} + + {artist && ( + + {artist.playCount} indexed listens + + )} + + + + + + + + + + {loading ? ( + + + + ) : error ? ( + + + Could not load leaderboard: {error} + + + ) : listeners.length === 0 ? ( + + + + No indexed listeners for this period yet. + + + ) : ( + <> + + {loadingMore && ( + + + + )} + {!cursor && ( + + You reached the end of this artist leaderboard. + + )} + + )} + + ); +} diff --git a/apps/amethyst/components/teal/ArtistLeaderboard.tsx b/apps/amethyst/components/teal/ArtistLeaderboard.tsx new file mode 100644 index 0000000..1c831e9 --- /dev/null +++ b/apps/amethyst/components/teal/ArtistLeaderboard.tsx @@ -0,0 +1,166 @@ +import { useEffect, useState } from "react"; +import { Image, Pressable, View } from "react-native"; +import { Link } from "expo-router"; +import { Button } from "@/components/ui/button"; +import { Text } from "@/components/ui/text"; +import { + actorAvatarUrl, + actorProfileHref, + displayActorName, + getCachedBlueskyProfile, + normalizeHandle, + type DisplayActor, +} from "@/lib/teal/actors"; +import type { ArtistListenerPeriod } from "@/lib/teal/api"; +import { Headphones } from "lucide-react-native"; +import { Icon } from "@/lib/icons/iconWithClassName"; + +import type { ArtistListenerView } from "@teal/lexicons/src/types/fm/teal/alpha/music/defs"; + +export const ARTIST_LISTENER_PERIODS: Array<{ + label: string; + value: ArtistListenerPeriod; +}> = [ + { label: "All time", value: "all" }, + { label: "30 days", value: "30days" }, + { label: "7 days", value: "7days" }, +]; + +export function normalizeArtistListenerPeriod( + value: string | undefined, +): ArtistListenerPeriod { + if (value === "30days" || value === "7days") return value; + return "all"; +} + +export function ArtistListenerPeriodTabs({ + period, + onChange, +}: { + period: ArtistListenerPeriod; + onChange: (period: ArtistListenerPeriod) => void; +}) { + return ( + + {ARTIST_LISTENER_PERIODS.map((item) => ( + + ))} + + ); +} + +export function ArtistListenerRow({ + listener, + rank, +}: { + listener: ArtistListenerView; + rank: number; +}) { + const [blueskyActor, setBlueskyActor] = useState(); + const indexedActor = listener.actor as DisplayActor; + const did = indexedActor.did; + const mergedActor = { + ...blueskyActor, + ...indexedActor, + avatar: indexedActor.avatar || blueskyActor?.avatar, + displayName: indexedActor.displayName || blueskyActor?.displayName, + handle: indexedActor.handle || blueskyActor?.handle, + }; + const href = actorProfileHref(mergedActor, did); + const name = displayActorName(mergedActor, did); + const handle = normalizeHandle(mergedActor.handle); + const avatar = actorAvatarUrl(mergedActor, did); + + useEffect(() => { + let mounted = true; + if (!did || (indexedActor.displayName && indexedActor.handle)) { + setBlueskyActor(undefined); + return; + } + getCachedBlueskyProfile(did).then((profile) => { + if (mounted) setBlueskyActor(profile); + }); + return () => { + mounted = false; + }; + }, [did, indexedActor.displayName, indexedActor.handle]); + + return ( + + + + + #{rank} + + + + {avatar ? ( + + ) : ( + + {name.slice(0, 1).toUpperCase()} + + )} + + + + {name} + + {handle ? ( + + @{handle} + + ) : ( + + {did} + + )} + + + + + + {listener.playCount} + + + + listens + + + + + ); +} + +export function ArtistLeaderboardList({ + listeners, + offset = 0, +}: { + listeners: ArtistListenerView[]; + offset?: number; +}) { + return ( + + {listeners.map((listener, index) => ( + + ))} + + ); +} diff --git a/apps/amethyst/lib/teal/api.ts b/apps/amethyst/lib/teal/api.ts index 235838f..467b2c1 100644 --- a/apps/amethyst/lib/teal/api.ts +++ b/apps/amethyst/lib/teal/api.ts @@ -7,6 +7,7 @@ import type { import type { PlayView } from "@teal/lexicons/src/types/fm/teal/alpha/feed/defs"; import type { AlbumView, + ArtistListenerView, ArtistView as MusicArtistView, } from "@teal/lexicons/src/types/fm/teal/alpha/music/defs"; import type { SongResult } from "@teal/lexicons/src/types/fm/teal/alpha/search/defs"; @@ -118,6 +119,8 @@ export type GraphSummaryView = { viewerFollowing?: string; }; +export type ArtistListenerPeriod = "all" | "30days" | "7days"; + async function getXrpc( method: string, params: Record = {}, @@ -175,6 +178,25 @@ export function getArtist(mbid?: string, name?: string) { }); } +export function getArtistListeners( + mbid?: string, + name?: string, + period: ArtistListenerPeriod = "all", + limit = 50, + cursor?: string, +) { + return getXrpc<{ listeners: ArtistListenerView[]; cursor?: string }>( + "fm.teal.alpha.music.getArtistListeners", + { + mbid, + name, + period, + limit, + cursor, + }, + ); +} + export function getAlbum(mbid: string, limit = 30, cursor?: string) { return getXrpc<{ album: AlbumView; plays: PlayView[]; cursor?: string }>( "fm.teal.alpha.music.getAlbum", diff --git a/apps/amethyst/lib/teal/routes.ts b/apps/amethyst/lib/teal/routes.ts index 27483cd..579e33f 100644 --- a/apps/amethyst/lib/teal/routes.ts +++ b/apps/amethyst/lib/teal/routes.ts @@ -14,6 +14,17 @@ export function musicArtistHref(name: string, mbid?: string) { return `/:o/music/${routePart(name)}?${query.toString()}`; } +export function musicArtistListenersHref( + name: string, + mbid?: string, + period?: string, +) { + const query = new URLSearchParams({ name }); + if (mbid) query.set("mbid", mbid); + if (period) query.set("period", period); + return `/:o/music/${routePart(name)}/listeners?${query.toString()}`; +} + export function musicAlbumHref( artistName: string, releaseName: string, diff --git a/apps/aqua/src/repos/music.rs b/apps/aqua/src/repos/music.rs index 8e04f3a..7bf08b7 100644 --- a/apps/aqua/src/repos/music.rs +++ b/apps/aqua/src/repos/music.rs @@ -6,10 +6,15 @@ use jacquard_common::from_json_value; use jacquard_common::types::string::{AtUri, Did}; use serde::Deserialize; use types::fm_teal::alpha::feed::PlayView; -use types::fm_teal::alpha::music::{AlbumSummary, AlbumView, ArtistView, TrackSummary}; +use types::fm_teal::alpha::music::{ + AlbumSummary, AlbumView, ArtistListenerView, ArtistView, TrackSummary, +}; use uuid::Uuid; -use super::stats::{LatestPlaysCursor, decode_latest_cursor, encode_latest_cursor}; +use super::stats::{ + LatestPlaysCursor, decode_latest_cursor, decode_offset_cursor, encode_latest_cursor, + encode_offset_cursor, +}; use super::{mbid_uri, mini_profile, pg::PgDataSource, utc_to_atrium_datetime}; pub struct AlbumPage { @@ -18,6 +23,37 @@ pub struct AlbumPage { pub cursor: Option, } +pub struct ArtistListenersPage { + pub listeners: Vec, + pub cursor: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ArtistListenersPeriod { + All, + ThirtyDays, + SevenDays, +} + +impl ArtistListenersPeriod { + fn parse(period: Option<&str>) -> anyhow::Result { + match period.unwrap_or("all") { + "all" => Ok(Self::All), + "30days" => Ok(Self::ThirtyDays), + "7days" => Ok(Self::SevenDays), + other => anyhow::bail!("unsupported period: {other}"), + } + } + + fn condition_sql(self) -> &'static str { + match self { + Self::All => "", + Self::ThirtyDays => "AND p.played_time >= NOW() - INTERVAL '30 days'", + Self::SevenDays => "AND p.played_time >= NOW() - INTERVAL '7 days'", + } + } +} + #[async_trait] pub trait MusicRepo: Send + Sync { async fn get_artist( @@ -25,6 +61,14 @@ pub trait MusicRepo: Send + Sync { mbid: Option<&str>, name: Option<&str>, ) -> anyhow::Result; + async fn get_artist_listeners( + &self, + mbid: Option<&str>, + name: Option<&str>, + period: Option<&str>, + limit: Option, + cursor: Option<&str>, + ) -> anyhow::Result; async fn get_album( &self, mbid: &str, @@ -216,6 +260,95 @@ impl MusicRepo for PgDataSource { }) } + async fn get_artist_listeners( + &self, + mbid: Option<&str>, + name: Option<&str>, + period: Option<&str>, + limit: Option, + cursor: Option<&str>, + ) -> anyhow::Result { + let mbid = mbid.map(parse_mbid).transpose()?; + if mbid.is_none() && name.is_none_or(str::is_empty) { + anyhow::bail!("mbid or name is required"); + } + + let artist_id = sqlx::query_as::<_, (i32,)>( + r#" + SELECT ae.id + FROM artists_extended ae + LEFT JOIN play_to_artists_extended ptae ON ae.id = ptae.artist_id + WHERE ($1::uuid IS NOT NULL AND ae.mbid = $1) + OR ($1::uuid IS NULL AND LOWER(ae.name) = LOWER($2)) + GROUP BY ae.id + ORDER BY COUNT(DISTINCT ptae.play_uri) DESC + LIMIT 1 + "#, + ) + .bind(mbid) + .bind(name) + .fetch_optional(&self.db) + .await? + .ok_or_else(|| anyhow::anyhow!("artist not found"))? + .0; + + let period = ArtistListenersPeriod::parse(period)?; + let limit = limit.unwrap_or(50).clamp(1, 100) as i64; + let offset = decode_offset_cursor(cursor)?; + let query_limit = limit + 1; + let sql = format!( + r#" + SELECT + p.did, + prof.handle, + prof.display_name, + prof.avatar, + COUNT(DISTINCT p.uri)::bigint AS play_count + FROM plays p + INNER JOIN play_to_artists_extended ptae ON p.uri = ptae.play_uri + LEFT JOIN profiles prof ON prof.did = p.did + WHERE ptae.artist_id = $1 + {} + GROUP BY p.did, prof.handle, prof.display_name, prof.avatar + ORDER BY play_count DESC, p.did ASC + LIMIT $2 OFFSET $3 + "#, + period.condition_sql() + ); + + let rows = + sqlx::query_as::<_, (String, Option, Option, Option, i64)>( + &sql, + ) + .bind(artist_id) + .bind(query_limit) + .bind(offset) + .fetch_all(&self.db) + .await?; + + let has_more = rows.len() > limit as usize; + let mut listeners = Vec::with_capacity(rows.len().min(limit as usize)); + for (did, handle, display_name, avatar, play_count) in rows.into_iter().take(limit as usize) + { + if let Some(actor) = mini_profile(Some(did), handle, display_name, avatar) { + listeners.push(ArtistListenerView { + actor, + play_count, + extra_data: Default::default(), + }); + } + } + + Ok(ArtistListenersPage { + listeners, + cursor: if has_more { + Some(encode_offset_cursor(offset + limit)?) + } else { + None + }, + }) + } + async fn get_album( &self, mbid: &str, @@ -434,7 +567,10 @@ impl MusicRepo for PgDataSource { #[cfg(test)] mod tests { - use super::{MusicBrainzTrackOrder, ObservedAlbumTrack, sort_tracks_by_release_order}; + use super::{ + ArtistListenersPeriod, MusicBrainzTrackOrder, ObservedAlbumTrack, + sort_tracks_by_release_order, + }; use uuid::Uuid; fn observed_track(name: &str, recording_mbid: Option) -> ObservedAlbumTrack { @@ -493,4 +629,25 @@ mod tests { assert_eq!(tracks[0].name, "Alpha"); assert_eq!(tracks[1].name, "Zulu"); } + + #[test] + fn artist_listeners_period_accepts_lexicon_values() { + assert_eq!( + ArtistListenersPeriod::parse(None).unwrap(), + ArtistListenersPeriod::All + ); + assert_eq!( + ArtistListenersPeriod::parse(Some("all")).unwrap(), + ArtistListenersPeriod::All + ); + assert_eq!( + ArtistListenersPeriod::parse(Some("30days")).unwrap(), + ArtistListenersPeriod::ThirtyDays + ); + assert_eq!( + ArtistListenersPeriod::parse(Some("7days")).unwrap(), + ArtistListenersPeriod::SevenDays + ); + assert!(ArtistListenersPeriod::parse(Some("90days")).is_err()); + } } diff --git a/apps/aqua/src/xrpc/music.rs b/apps/aqua/src/xrpc/music.rs index 2f06854..2872364 100644 --- a/apps/aqua/src/xrpc/music.rs +++ b/apps/aqua/src/xrpc/music.rs @@ -2,13 +2,17 @@ use axum::{Extension, http::StatusCode, response::IntoResponse, routing::get}; use jacquard_common::IntoStatic; use serde::{Deserialize, Serialize}; use types::fm_teal::alpha::feed::PlayView; -use types::fm_teal::alpha::music::{AlbumView, ArtistView}; +use types::fm_teal::alpha::music::{AlbumView, ArtistListenerView, ArtistView}; use crate::ctx::Context; pub fn music_routes() -> axum::Router { axum::Router::new() .route("/fm.teal.alpha.music.getArtist", get(get_artist)) + .route( + "/fm.teal.alpha.music.getArtistListeners", + get(get_artist_listeners), + ) .route("/fm.teal.alpha.music.getAlbum", get(get_album)) } @@ -49,6 +53,57 @@ pub async fn get_artist( } } +#[derive(Deserialize)] +pub struct GetArtistListenersQuery { + pub mbid: Option, + pub name: Option, + pub period: Option, + pub limit: Option, + pub cursor: Option, +} + +#[derive(Serialize)] +pub struct GetArtistListenersResponse { + listeners: Vec, + cursor: Option, +} + +pub async fn get_artist_listeners( + Extension(ctx): Extension, + axum::extract::Query(query): axum::extract::Query, +) -> Result { + if query.mbid.is_none() && query.name.as_deref().is_none_or(str::is_empty) { + return Err(( + StatusCode::BAD_REQUEST, + "mbid or name is required".to_string(), + )); + } + + match ctx + .db + .get_artist_listeners( + query.mbid.as_deref(), + query.name.as_deref(), + query.period.as_deref(), + query.limit, + query.cursor.as_deref(), + ) + .await + { + Ok(page) => Ok(axum::Json(GetArtistListenersResponse { + listeners: page.listeners.into_static(), + cursor: page.cursor, + })), + Err(error) if error.to_string() == "artist not found" => { + Err((StatusCode::NOT_FOUND, error.to_string())) + } + Err(error) if error.to_string().starts_with("unsupported period:") => { + Err((StatusCode::BAD_REQUEST, error.to_string())) + } + Err(error) => Err((StatusCode::INTERNAL_SERVER_ERROR, error.to_string())), + } +} + #[derive(Deserialize)] pub struct GetAlbumQuery { pub mbid: String, diff --git a/lexicons/fm.teal.alpha/music/defs.json b/lexicons/fm.teal.alpha/music/defs.json index 6c575a1..55a233d 100644 --- a/lexicons/fm.teal.alpha/music/defs.json +++ b/lexicons/fm.teal.alpha/music/defs.json @@ -28,6 +28,21 @@ } } }, + "artistListenerView": { + "type": "object", + "required": ["actor", "playCount"], + "properties": { + "actor": { + "type": "ref", + "ref": "fm.teal.alpha.actor.defs#miniProfileView", + "description": "The listener ranked on this artist leaderboard" + }, + "playCount": { + "type": "integer", + "description": "Number of indexed listens by this actor for the artist" + } + } + }, "albumView": { "type": "object", "required": ["mbid", "name", "artistName", "playCount", "tracks"], diff --git a/lexicons/fm.teal.alpha/music/getArtistListeners.json b/lexicons/fm.teal.alpha/music/getArtistListeners.json new file mode 100644 index 0000000..693401e --- /dev/null +++ b/lexicons/fm.teal.alpha/music/getArtistListeners.json @@ -0,0 +1,61 @@ +{ + "lexicon": 1, + "id": "fm.teal.alpha.music.getArtistListeners", + "defs": { + "main": { + "type": "query", + "description": "Get listeners ranked by indexed listen count for an artist", + "parameters": { + "type": "params", + "properties": { + "mbid": { + "type": "string", + "format": "uri", + "description": "MusicBrainz artist ID URI, formatted as mbid:" + }, + "name": { + "type": "string", + "description": "Artist name fallback when no MusicBrainz ID is available" + }, + "period": { + "type": "string", + "enum": ["all", "30days", "7days"], + "default": "all", + "description": "Time period for the leaderboard" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 50, + "description": "Number of listeners to return" + }, + "cursor": { + "type": "string", + "description": "Pagination cursor" + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["listeners"], + "properties": { + "listeners": { + "type": "array", + "items": { + "type": "ref", + "ref": "fm.teal.alpha.music.defs#artistListenerView" + } + }, + "cursor": { + "type": "string", + "description": "Next page cursor" + } + } + } + } + } + } +} diff --git a/migrations/20241220000012_artist_listener_leaderboard_index.sql b/migrations/20241220000012_artist_listener_leaderboard_index.sql new file mode 100644 index 0000000..2f31070 --- /dev/null +++ b/migrations/20241220000012_artist_listener_leaderboard_index.sql @@ -0,0 +1,2 @@ +CREATE INDEX idx_play_to_artists_extended_artist_play +ON play_to_artists_extended (artist_id, play_uri); diff --git a/packages/lexicons/src/index.ts b/packages/lexicons/src/index.ts index 770b795..2ab5a7e 100644 --- a/packages/lexicons/src/index.ts +++ b/packages/lexicons/src/index.ts @@ -19,6 +19,7 @@ import * as FmTealAlphaGraphGetFollows from './types/fm/teal/alpha/graph/getFoll import * as FmTealAlphaGraphGetSummary from './types/fm/teal/alpha/graph/getSummary' import * as FmTealAlphaMusicGetAlbum from './types/fm/teal/alpha/music/getAlbum' import * as FmTealAlphaMusicGetArtist from './types/fm/teal/alpha/music/getArtist' +import * as FmTealAlphaMusicGetArtistListeners from './types/fm/teal/alpha/music/getArtistListeners' import * as FmTealAlphaSearchGetResults from './types/fm/teal/alpha/search/getResults' import * as FmTealAlphaStatsGetLatest from './types/fm/teal/alpha/stats/getLatest' import * as FmTealAlphaStatsGetTopArtists from './types/fm/teal/alpha/stats/getTopArtists' @@ -234,6 +235,17 @@ export class FmTealAlphaMusicNS { const nsid = 'fm.teal.alpha.music.getArtist' // @ts-ignore return this._server.xrpc.method(nsid, cfg) } + + getArtistListeners( + cfg: ConfigOf< + AV, + FmTealAlphaMusicGetArtistListeners.Handler>, + FmTealAlphaMusicGetArtistListeners.HandlerReqCtx> + >, + ) { + const nsid = 'fm.teal.alpha.music.getArtistListeners' // @ts-ignore + return this._server.xrpc.method(nsid, cfg) + } } export class FmTealAlphaRichtextNS { diff --git a/packages/lexicons/src/lexicons.ts b/packages/lexicons/src/lexicons.ts index 5cb0b35..1b237fe 100644 --- a/packages/lexicons/src/lexicons.ts +++ b/packages/lexicons/src/lexicons.ts @@ -1392,6 +1392,22 @@ export const schemaDict = { }, }, }, + artistListenerView: { + type: 'object', + required: ['actor', 'playCount'], + properties: { + actor: { + type: 'ref', + ref: 'lex:fm.teal.alpha.actor.defs#miniProfileView', + description: 'The listener ranked on this artist leaderboard', + }, + playCount: { + type: 'integer', + description: + 'Number of indexed listens by this actor for the artist', + }, + }, + }, albumView: { type: 'object', required: ['mbid', 'name', 'artistName', 'playCount', 'tracks'], @@ -1573,6 +1589,70 @@ export const schemaDict = { }, }, }, + FmTealAlphaMusicGetArtistListeners: { + lexicon: 1, + id: 'fm.teal.alpha.music.getArtistListeners', + defs: { + main: { + type: 'query', + description: + 'Get listeners ranked by indexed listen count for an artist', + parameters: { + type: 'params', + properties: { + mbid: { + type: 'string', + format: 'uri', + description: + 'MusicBrainz artist ID URI, formatted as mbid:', + }, + name: { + type: 'string', + description: + 'Artist name fallback when no MusicBrainz ID is available', + }, + period: { + type: 'string', + enum: ['all', '30days', '7days'], + default: 'all', + description: 'Time period for the leaderboard', + }, + limit: { + type: 'integer', + minimum: 1, + maximum: 100, + default: 50, + description: 'Number of listeners to return', + }, + cursor: { + type: 'string', + description: 'Pagination cursor', + }, + }, + }, + output: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['listeners'], + properties: { + listeners: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:fm.teal.alpha.music.defs#artistListenerView', + }, + }, + cursor: { + type: 'string', + description: 'Next page cursor', + }, + }, + }, + }, + }, + }, + }, FmTealAlphaRichtextFacet: { lexicon: 1, id: 'fm.teal.alpha.richtext.facet', @@ -2144,6 +2224,7 @@ export const ids = { FmTealAlphaMusicDefs: 'fm.teal.alpha.music.defs', FmTealAlphaMusicGetAlbum: 'fm.teal.alpha.music.getAlbum', FmTealAlphaMusicGetArtist: 'fm.teal.alpha.music.getArtist', + FmTealAlphaMusicGetArtistListeners: 'fm.teal.alpha.music.getArtistListeners', FmTealAlphaRichtextFacet: 'fm.teal.alpha.richtext.facet', FmTealAlphaSearchDefs: 'fm.teal.alpha.search.defs', FmTealAlphaSearchGetResults: 'fm.teal.alpha.search.getResults', diff --git a/packages/lexicons/src/types/fm/teal/alpha/music/defs.ts b/packages/lexicons/src/types/fm/teal/alpha/music/defs.ts index 7b1c243..70445aa 100644 --- a/packages/lexicons/src/types/fm/teal/alpha/music/defs.ts +++ b/packages/lexicons/src/types/fm/teal/alpha/music/defs.ts @@ -5,6 +5,7 @@ import { ValidationResult, BlobRef } from '@atproto/lexicon' import { lexicons } from '../../../../../lexicons' import { isObj, hasProp } from '../../../../../util' import { CID } from 'multiformats/cid' +import * as FmTealAlphaActorDefs from '../actor/defs' export interface ArtistView { /** MusicBrainz artist ID URI, formatted as mbid: */ @@ -29,6 +30,25 @@ export function validateArtistView(v: unknown): ValidationResult { return lexicons.validate('fm.teal.alpha.music.defs#artistView', v) } +export interface ArtistListenerView { + actor: FmTealAlphaActorDefs.MiniProfileView + /** Number of indexed listens by this actor for the artist */ + playCount: number + [k: string]: unknown +} + +export function isArtistListenerView(v: unknown): v is ArtistListenerView { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'fm.teal.alpha.music.defs#artistListenerView' + ) +} + +export function validateArtistListenerView(v: unknown): ValidationResult { + return lexicons.validate('fm.teal.alpha.music.defs#artistListenerView', v) +} + export interface AlbumView { /** MusicBrainz release ID URI, formatted as mbid: */ mbid: string diff --git a/packages/lexicons/src/types/fm/teal/alpha/music/getArtistListeners.ts b/packages/lexicons/src/types/fm/teal/alpha/music/getArtistListeners.ts new file mode 100644 index 0000000..771a890 --- /dev/null +++ b/packages/lexicons/src/types/fm/teal/alpha/music/getArtistListeners.ts @@ -0,0 +1,58 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import express from 'express' +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { lexicons } from '../../../../../lexicons' +import { isObj, hasProp } from '../../../../../util' +import { CID } from 'multiformats/cid' +import { HandlerAuth, HandlerPipeThrough } from '@atproto/xrpc-server' +import * as FmTealAlphaMusicDefs from './defs' + +export interface QueryParams { + /** MusicBrainz artist ID URI, formatted as mbid: */ + mbid?: string + /** Artist name fallback when no MusicBrainz ID is available */ + name?: string + /** Time period for the leaderboard */ + period: 'all' | '30days' | '7days' + /** Number of listeners to return */ + limit: number + /** Pagination cursor */ + cursor?: string +} + +export type InputSchema = undefined + +export interface OutputSchema { + listeners: FmTealAlphaMusicDefs.ArtistListenerView[] + /** Next page cursor */ + cursor?: string + [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 = { + auth: HA + params: QueryParams + input: HandlerInput + req: express.Request + res: express.Response + resetRouteRateLimits: () => Promise +} +export type Handler = ( + ctx: HandlerReqCtx, +) => Promise | HandlerOutput diff --git a/services/types/src/fm_teal/alpha/music.rs b/services/types/src/fm_teal/alpha/music.rs index 0356afa..28cbc97 100644 --- a/services/types/src/fm_teal/alpha/music.rs +++ b/services/types/src/fm_teal/alpha/music.rs @@ -7,6 +7,7 @@ pub mod get_album; pub mod get_artist; +pub mod get_artist_listeners; #[allow(unused_imports)] @@ -28,6 +29,7 @@ use jacquard_lexicon::schema::LexiconSchema; #[allow(unused_imports)] use jacquard_lexicon::validation::{ConstraintError, ValidationPath}; use serde::{Serialize, Deserialize}; +use crate::fm_teal::alpha::actor::MiniProfileView; use crate::fm_teal::alpha::music; #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic)] @@ -64,6 +66,18 @@ pub struct AlbumView { } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic)] +#[serde(rename_all = "camelCase", bound(deserialize = "S: Deserialize<'de> + BosStr"))] +pub struct ArtistListenerView { + ///The listener ranked on this artist leaderboard + pub actor: MiniProfileView, + ///Number of indexed listens by this actor for the artist + pub play_count: i64, + #[serde(flatten, default, skip_serializing_if = "Option::is_none")] + pub extra_data: Option>>, +} + + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic)] #[serde(rename_all = "camelCase", bound(deserialize = "S: Deserialize<'de> + BosStr"))] pub struct ArtistView { @@ -124,6 +138,21 @@ impl LexiconSchema for AlbumView { } } +impl LexiconSchema for ArtistListenerView { + fn nsid() -> &'static str { + "fm.teal.alpha.music.defs" + } + fn def_name() -> &'static str { + "artistListenerView" + } + fn lexicon_doc() -> LexiconDoc<'static> { + lexicon_doc_fm_teal_alpha_music_defs() + } + fn validate(&self) -> Result<(), ConstraintError> { + Ok(()) + } +} + impl LexiconSchema for ArtistView { fn nsid() -> &'static str { "fm.teal.alpha.music.defs" @@ -1305,4 +1334,4 @@ where extra_data: Some(extra_data), } } -} \ No newline at end of file +} diff --git a/services/types/src/fm_teal/alpha/music/get_artist_listeners.rs b/services/types/src/fm_teal/alpha/music/get_artist_listeners.rs new file mode 100644 index 0000000..38d8fcc --- /dev/null +++ b/services/types/src/fm_teal/alpha/music/get_artist_listeners.rs @@ -0,0 +1,218 @@ +// @generated by jacquard-lexicon. DO NOT EDIT. +// +// Lexicon: fm.teal.alpha.music.getArtistListeners +// +// This file was automatically generated from Lexicon schemas. +// Any manual changes will be overwritten on the next regeneration. + +#[allow(unused_imports)] +use alloc::collections::BTreeMap; + +use crate::fm_teal::alpha::music::ArtistListenerView; +#[allow(unused_imports)] +use core::marker::PhantomData; +use jacquard_common::deps::smol_str::SmolStr; +use jacquard_common::types::string::UriValue; +use jacquard_common::types::value::Data; +use jacquard_common::{BosStr, CowStr, DefaultStr, FromStaticStr}; +use jacquard_derive::IntoStatic; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic)] +#[serde( + rename_all = "camelCase", + bound(deserialize = "S: Deserialize<'de> + BosStr") +)] +pub struct GetArtistListeners { + #[serde(skip_serializing_if = "Option::is_none")] + pub cursor: Option, + ///Defaults to `50`. Min: 1. Max: 100. + #[serde(default = "_default_limit")] + #[serde(skip_serializing_if = "Option::is_none")] + pub limit: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub mbid: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + ///Defaults to `"all"`. + #[serde(default = "_default_period")] + #[serde(skip_serializing_if = "Option::is_none")] + pub period: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic)] +#[serde( + rename_all = "camelCase", + bound(deserialize = "S: Deserialize<'de> + BosStr") +)] +pub struct GetArtistListenersOutput { + ///Next page cursor + #[serde(skip_serializing_if = "Option::is_none")] + pub cursor: Option, + pub listeners: Vec>, + #[serde(flatten, default, skip_serializing_if = "Option::is_none")] + pub extra_data: Option>>, +} + +/// Response type for fm.teal.alpha.music.getArtistListeners +pub struct GetArtistListenersResponse; +impl jacquard_common::xrpc::XrpcResp for GetArtistListenersResponse { + const NSID: &'static str = "fm.teal.alpha.music.getArtistListeners"; + const ENCODING: &'static str = "application/json"; + type Output = GetArtistListenersOutput; + type Err = jacquard_common::xrpc::GenericError; +} + +impl jacquard_common::xrpc::XrpcRequest for GetArtistListeners { + const NSID: &'static str = "fm.teal.alpha.music.getArtistListeners"; + const METHOD: jacquard_common::xrpc::XrpcMethod = jacquard_common::xrpc::XrpcMethod::Query; + type Response = GetArtistListenersResponse; +} + +/// Endpoint type for fm.teal.alpha.music.getArtistListeners +pub struct GetArtistListenersRequest; +impl jacquard_common::xrpc::XrpcEndpoint for GetArtistListenersRequest { + const PATH: &'static str = "/xrpc/fm.teal.alpha.music.getArtistListeners"; + const METHOD: jacquard_common::xrpc::XrpcMethod = jacquard_common::xrpc::XrpcMethod::Query; + type Request = GetArtistListeners; + type Response = GetArtistListenersResponse; +} + +fn _default_limit() -> Option { + Some(50i64) +} + +fn _default_period() -> Option { + Some(S::from_static("all")) +} + +pub mod get_artist_listeners_state { + + pub use crate::builder_types::{IsSet, IsUnset, Set, Unset}; + #[allow(unused)] + use ::core::marker::PhantomData; + mod sealed { + pub trait Sealed {} + } + /// State trait tracking which required fields have been set + pub trait State: sealed::Sealed {} + /// Empty state - all required fields are unset + pub struct Empty(()); + impl sealed::Sealed for Empty {} + impl State for Empty {} + /// Marker types for field names + #[allow(non_camel_case_types)] + pub mod members {} +} + +/// Builder for constructing an instance of this type. +pub struct GetArtistListenersBuilder { + _state: PhantomData St>, + _fields: ( + Option, + Option, + Option>, + Option, + Option, + ), + _type: PhantomData S>, +} + +impl GetArtistListeners { + /// Create a new builder for this type. + pub fn new() -> GetArtistListenersBuilder { + GetArtistListenersBuilder::new() + } +} + +impl GetArtistListenersBuilder { + /// Create a new builder with all fields unset. + pub fn new() -> Self { + GetArtistListenersBuilder { + _state: PhantomData, + _fields: (None, None, None, None, None), + _type: PhantomData, + } + } +} + +impl GetArtistListenersBuilder { + /// Set the `cursor` field (optional) + pub fn cursor(mut self, value: impl Into>) -> Self { + self._fields.0 = value.into(); + self + } + /// Set the `cursor` field to an Option value (optional) + pub fn maybe_cursor(mut self, value: Option) -> Self { + self._fields.0 = value; + self + } +} + +impl GetArtistListenersBuilder { + /// Set the `limit` field (optional) + pub fn limit(mut self, value: impl Into>) -> Self { + self._fields.1 = value.into(); + self + } + /// Set the `limit` field to an Option value (optional) + pub fn maybe_limit(mut self, value: Option) -> Self { + self._fields.1 = value; + self + } +} + +impl GetArtistListenersBuilder { + /// Set the `mbid` field (optional) + pub fn mbid(mut self, value: impl Into>>) -> Self { + self._fields.2 = value.into(); + self + } + /// Set the `mbid` field to an Option value (optional) + pub fn maybe_mbid(mut self, value: Option>) -> Self { + self._fields.2 = value; + self + } +} + +impl GetArtistListenersBuilder { + /// Set the `name` field (optional) + pub fn name(mut self, value: impl Into>) -> Self { + self._fields.3 = value.into(); + self + } + /// Set the `name` field to an Option value (optional) + pub fn maybe_name(mut self, value: Option) -> Self { + self._fields.3 = value; + self + } +} + +impl GetArtistListenersBuilder { + /// Set the `period` field (optional) + pub fn period(mut self, value: impl Into>) -> Self { + self._fields.4 = value.into(); + self + } + /// Set the `period` field to an Option value (optional) + pub fn maybe_period(mut self, value: Option) -> Self { + self._fields.4 = value; + self + } +} + +impl GetArtistListenersBuilder +where + St: get_artist_listeners_state::State, +{ + /// Build the final struct. + pub fn build(self) -> GetArtistListeners { + GetArtistListeners { + cursor: self._fields.0, + limit: self._fields.1, + mbid: self._fields.2, + name: self._fields.3, + period: self._fields.4, + } + } +} diff --git a/todo.md b/todo.md index a45d9d4..1ff952a 100644 --- a/todo.md +++ b/todo.md @@ -7,6 +7,7 @@ This file is the working handoff for the Teal-native Teal clone. Keep it updated - Amethyst has a Teal-branded Teal-native shell with desktop navigation, mobile navigation, Home, searchable Explore, Notifications, Profile, and music detail views. - Amethyst uses the teal.fm landing-page brand system: DM Sans body type, serif `.fm` accent type, neutral `#262626` text, `#14b8a6` teal accents, pale lavender/ice-blue gradient chrome, and translucent white surfaces across the main shell and feed. - Aqua exposes Teal XRPC routes for cursor-paginated latest plays, individual plays, actor feeds, profiles, stats, indexed search, artist discographies, and albums with track lists plus cursor-paginated listens. +- Aqua exposes an artist listener leaderboard XRPC, and Amethyst artist pages show top listeners with all-time, 30-day, and 7-day views plus a full paginated listener leaderboard route. - Cadet consumes Teal records from Jetstream, stores a durable cursor in Redis with file fallback, and ingests create, update, and delete events for profiles and plays. - The public Amethyst feed uses only live Aqua XRPC data. There is no seeded, mocked, demo, or backup play feed. - Amethyst Home uses one inline Teal social composer. Signed-in users get their most recent indexed play attached automatically, can click the attached song to change it with recent plays or MusicBrainz search, write rich text, and publish `fm.teal.alpha.feed.social.post` records. @@ -50,6 +51,8 @@ This file is the working handoff for the Teal-native Teal clone. Keep it updated - [x] Fix persisted post liked-state hydration so posts already liked by the signed-in viewer still render as liked after refresh. - [x] Fix cover art rendering on social posts, including posts whose attached tracks only have MusicBrainz IDs or legacy track metadata. - [x] Prune Docker builder cache and redeploy the stable preview with rebuilt Aqua and Amethyst containers. +- [x] Add artist listener leaderboards to Aqua and Amethyst artist pages. +- [ ] Redeploy the stable preview and QA artist listener leaderboards against live indexed data. ## Next: New Lexicon Implementation