diff --git a/apps/api/lexicons/charts/getTopArtists.json b/apps/api/lexicons/charts/getTopArtists.json new file mode 100644 index 00000000..c46ba3b4 --- /dev/null +++ b/apps/api/lexicons/charts/getTopArtists.json @@ -0,0 +1,50 @@ +{ + "lexicon": 1, + "id": "app.rocksky.charts.getTopArtists", + "defs": { + "main": { + "type": "query", + "description": "Get top artists", + "parameters": { + "type": "params", + "properties": { + "limit": { + "type": "integer", + "description": "The maximum number of artists to return", + "minimum": 1 + }, + "offset": { + "type": "integer", + "description": "The offset for pagination", + "minimum": 0 + }, + "startDate": { + "type": "string", + "description": "The start date to filter artists from (ISO 8601 format)", + "format": "datetime" + }, + "endDate": { + "type": "string", + "description": "The end date to filter artists to (ISO 8601 format)", + "format": "datetime" + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "properties": { + "artists": { + "type": "array", + "items": { + "type": "ref", + "ref": "app.rocksky.artist.defs#artistViewBasic" + } + } + } + } + } + } + } +} diff --git a/apps/api/lexicons/charts/getTopTracks.json b/apps/api/lexicons/charts/getTopTracks.json new file mode 100644 index 00000000..b84d1c4f --- /dev/null +++ b/apps/api/lexicons/charts/getTopTracks.json @@ -0,0 +1,50 @@ +{ + "lexicon": 1, + "id": "app.rocksky.charts.getTopTracks", + "defs": { + "main": { + "type": "query", + "description": "Get top tracks", + "parameters": { + "type": "params", + "properties": { + "limit": { + "type": "integer", + "description": "The maximum number of tracks to return", + "minimum": 1 + }, + "offset": { + "type": "integer", + "description": "The offset for pagination", + "minimum": 0 + }, + "startDate": { + "type": "string", + "description": "The start date to filter tracks from (ISO 8601 format)", + "format": "datetime" + }, + "endDate": { + "type": "string", + "description": "The end date to filter tracks to (ISO 8601 format)", + "format": "datetime" + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "properties": { + "tracks": { + "type": "array", + "items": { + "type": "ref", + "ref": "app.rocksky.song.defs#songViewBasic" + } + } + } + } + } + } + } +} diff --git a/apps/api/pkl/defs/charts/getTopArtists.pkl b/apps/api/pkl/defs/charts/getTopArtists.pkl new file mode 100644 index 00000000..c10a74a4 --- /dev/null +++ b/apps/api/pkl/defs/charts/getTopArtists.pkl @@ -0,0 +1,46 @@ +amends "../../schema/lexicon.pkl" + +lexicon = 1 +id = "app.rocksky.charts.getTopArtists" +defs = new Mapping { + ["main"] { + type = "query" + description = "Get top artists" + parameters = new Params { + properties { + ["limit"] = new IntegerType { + type = "integer" + description = "The maximum number of artists to return" + minimum = 1 + } + ["offset"] = new IntegerType { + type = "integer" + description = "The offset for pagination" + minimum = 0 + } + ["startDate"] = new StringType { + description = "The start date to filter artists from (ISO 8601 format)" + format = "datetime" + } + ["endDate"] = new StringType { + description = "The end date to filter artists to (ISO 8601 format)" + format = "datetime" + } + } + } + output { + encoding = "application/json" + schema = new ObjectType { + type = "object" + properties = new Mapping { + ["artists"] = new Array { + type = "array" + items = new Ref { + ref = "app.rocksky.artist.defs#artistViewBasic" + } + } + } + } + } + } +} diff --git a/apps/api/pkl/defs/charts/getTopTracks.pkl b/apps/api/pkl/defs/charts/getTopTracks.pkl new file mode 100644 index 00000000..9c40cd34 --- /dev/null +++ b/apps/api/pkl/defs/charts/getTopTracks.pkl @@ -0,0 +1,46 @@ +amends "../../schema/lexicon.pkl" + +lexicon = 1 +id = "app.rocksky.charts.getTopTracks" +defs = new Mapping { + ["main"] { + type = "query" + description = "Get top tracks" + parameters = new Params { + properties { + ["limit"] = new IntegerType { + type = "integer" + description = "The maximum number of tracks to return" + minimum = 1 + } + ["offset"] = new IntegerType { + type = "integer" + description = "The offset for pagination" + minimum = 0 + } + ["startDate"] = new StringType { + description = "The start date to filter tracks from (ISO 8601 format)" + format = "datetime" + } + ["endDate"] = new StringType { + description = "The end date to filter tracks to (ISO 8601 format)" + format = "datetime" + } + } + } + output { + encoding = "application/json" + schema = new ObjectType { + type = "object" + properties = new Mapping { + ["tracks"] = new Array { + type = "array" + items = new Ref { + ref = "app.rocksky.song.defs#songViewBasic" + } + } + } + } + } + } +} diff --git a/apps/api/src/lexicon/index.ts b/apps/api/src/lexicon/index.ts index d1a64b94..2eef5863 100644 --- a/apps/api/src/lexicon/index.ts +++ b/apps/api/src/lexicon/index.ts @@ -36,6 +36,8 @@ import type * as AppRockskyArtistGetArtistListeners from "./types/app/rocksky/ar import type * as AppRockskyArtistGetArtists from "./types/app/rocksky/artist/getArtists"; import type * as AppRockskyArtistGetArtistTracks from "./types/app/rocksky/artist/getArtistTracks"; import type * as AppRockskyChartsGetScrobblesChart from "./types/app/rocksky/charts/getScrobblesChart"; +import type * as AppRockskyChartsGetTopArtists from "./types/app/rocksky/charts/getTopArtists"; +import type * as AppRockskyChartsGetTopTracks from "./types/app/rocksky/charts/getTopTracks"; import type * as AppRockskyDropboxDownloadFile from "./types/app/rocksky/dropbox/downloadFile"; import type * as AppRockskyDropboxGetFiles from "./types/app/rocksky/dropbox/getFiles"; import type * as AppRockskyDropboxGetMetadata from "./types/app/rocksky/dropbox/getMetadata"; @@ -557,6 +559,28 @@ export class AppRockskyChartsNS { const nsid = "app.rocksky.charts.getScrobblesChart"; // @ts-ignore return this._server.xrpc.method(nsid, cfg); } + + getTopArtists( + cfg: ConfigOf< + AV, + AppRockskyChartsGetTopArtists.Handler>, + AppRockskyChartsGetTopArtists.HandlerReqCtx> + >, + ) { + const nsid = "app.rocksky.charts.getTopArtists"; // @ts-ignore + return this._server.xrpc.method(nsid, cfg); + } + + getTopTracks( + cfg: ConfigOf< + AV, + AppRockskyChartsGetTopTracks.Handler>, + AppRockskyChartsGetTopTracks.HandlerReqCtx> + >, + ) { + const nsid = "app.rocksky.charts.getTopTracks"; // @ts-ignore + return this._server.xrpc.method(nsid, cfg); + } } export class AppRockskyDropboxNS { diff --git a/apps/api/src/lexicon/lexicons.ts b/apps/api/src/lexicon/lexicons.ts index 129865e3..d39e5d2f 100644 --- a/apps/api/src/lexicon/lexicons.ts +++ b/apps/api/src/lexicon/lexicons.ts @@ -2246,6 +2246,109 @@ export const schemaDict = { }, }, }, + AppRockskyChartsGetTopArtists: { + lexicon: 1, + id: "app.rocksky.charts.getTopArtists", + defs: { + main: { + type: "query", + description: "Get top artists", + parameters: { + type: "params", + properties: { + limit: { + type: "integer", + description: "The maximum number of artists to return", + minimum: 1, + }, + offset: { + type: "integer", + description: "The offset for pagination", + minimum: 0, + }, + startDate: { + type: "string", + description: + "The start date to filter artists from (ISO 8601 format)", + format: "datetime", + }, + endDate: { + type: "string", + description: + "The end date to filter artists to (ISO 8601 format)", + format: "datetime", + }, + }, + }, + output: { + encoding: "application/json", + schema: { + type: "object", + properties: { + artists: { + type: "array", + items: { + type: "ref", + ref: "lex:app.rocksky.artist.defs#artistViewBasic", + }, + }, + }, + }, + }, + }, + }, + }, + AppRockskyChartsGetTopTracks: { + lexicon: 1, + id: "app.rocksky.charts.getTopTracks", + defs: { + main: { + type: "query", + description: "Get top tracks", + parameters: { + type: "params", + properties: { + limit: { + type: "integer", + description: "The maximum number of tracks to return", + minimum: 1, + }, + offset: { + type: "integer", + description: "The offset for pagination", + minimum: 0, + }, + startDate: { + type: "string", + description: + "The start date to filter tracks from (ISO 8601 format)", + format: "datetime", + }, + endDate: { + type: "string", + description: "The end date to filter tracks to (ISO 8601 format)", + format: "datetime", + }, + }, + }, + output: { + encoding: "application/json", + schema: { + type: "object", + properties: { + tracks: { + type: "array", + items: { + type: "ref", + ref: "lex:app.rocksky.song.defs#songViewBasic", + }, + }, + }, + }, + }, + }, + }, + }, AppRockskyDropboxDefs: { lexicon: 1, id: "app.rocksky.dropbox.defs", @@ -6069,6 +6172,8 @@ export const ids = { AppRockskyArtistGetArtistTracks: "app.rocksky.artist.getArtistTracks", AppRockskyChartsDefs: "app.rocksky.charts.defs", AppRockskyChartsGetScrobblesChart: "app.rocksky.charts.getScrobblesChart", + AppRockskyChartsGetTopArtists: "app.rocksky.charts.getTopArtists", + AppRockskyChartsGetTopTracks: "app.rocksky.charts.getTopTracks", AppRockskyDropboxDefs: "app.rocksky.dropbox.defs", AppRockskyDropboxDownloadFile: "app.rocksky.dropbox.downloadFile", AppRockskyDropboxGetFiles: "app.rocksky.dropbox.getFiles", diff --git a/apps/api/src/lexicon/types/app/rocksky/charts/getTopArtists.ts b/apps/api/src/lexicon/types/app/rocksky/charts/getTopArtists.ts new file mode 100644 index 00000000..fc00302b --- /dev/null +++ b/apps/api/src/lexicon/types/app/rocksky/charts/getTopArtists.ts @@ -0,0 +1,54 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import type express from "express"; +import { ValidationResult, BlobRef } from "@atproto/lexicon"; +import { lexicons } from "../../../../lexicons"; +import { isObj, hasProp } from "../../../../util"; +import { CID } from "multiformats/cid"; +import type { HandlerAuth, HandlerPipeThrough } from "@atproto/xrpc-server"; +import type * as AppRockskyArtistDefs from "../artist/defs"; + +export interface QueryParams { + /** The maximum number of artists to return */ + limit?: number; + /** The offset for pagination */ + offset?: number; + /** The start date to filter artists from (ISO 8601 format) */ + startDate?: string; + /** The end date to filter artists to (ISO 8601 format) */ + endDate?: string; +} + +export type InputSchema = undefined; + +export interface OutputSchema { + artists?: AppRockskyArtistDefs.ArtistViewBasic[]; + [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/apps/api/src/lexicon/types/app/rocksky/charts/getTopTracks.ts b/apps/api/src/lexicon/types/app/rocksky/charts/getTopTracks.ts new file mode 100644 index 00000000..aae1e8ce --- /dev/null +++ b/apps/api/src/lexicon/types/app/rocksky/charts/getTopTracks.ts @@ -0,0 +1,54 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import type express from "express"; +import { ValidationResult, BlobRef } from "@atproto/lexicon"; +import { lexicons } from "../../../../lexicons"; +import { isObj, hasProp } from "../../../../util"; +import { CID } from "multiformats/cid"; +import type { HandlerAuth, HandlerPipeThrough } from "@atproto/xrpc-server"; +import type * as AppRockskySongDefs from "../song/defs"; + +export interface QueryParams { + /** The maximum number of tracks to return */ + limit?: number; + /** The offset for pagination */ + offset?: number; + /** The start date to filter tracks from (ISO 8601 format) */ + startDate?: string; + /** The end date to filter tracks to (ISO 8601 format) */ + endDate?: string; +} + +export type InputSchema = undefined; + +export interface OutputSchema { + tracks?: AppRockskySongDefs.SongViewBasic[]; + [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/apps/api/src/xrpc/app/rocksky/charts/getTopArtists.ts b/apps/api/src/xrpc/app/rocksky/charts/getTopArtists.ts new file mode 100644 index 00000000..2ae0b3b5 --- /dev/null +++ b/apps/api/src/xrpc/app/rocksky/charts/getTopArtists.ts @@ -0,0 +1,173 @@ +import type { Context } from "context"; +import { consola } from "consola"; +import { count, desc, sql, and, gte, lte } from "drizzle-orm"; +import { Effect, pipe, Cache, Duration } from "effect"; +import type { Server } from "lexicon"; +import type { ArtistViewBasic } from "lexicon/types/app/rocksky/artist/defs"; +import type { QueryParams } from "lexicon/types/app/rocksky/charts/getTopArtists"; +import { deepCamelCaseKeys } from "lib"; +import tables from "schema"; + +export default function (server: Server, ctx: Context) { + const getTopArtistsCache = Cache.make({ + capacity: 100, + timeToLive: Duration.minutes(5), + lookup: (params: QueryParams) => + pipe( + { params, ctx }, + retrieve, + Effect.flatMap(presentation), + Effect.retry({ times: 3 }), + Effect.timeout("120 seconds"), + ), + }); + + const getTopArtists = (params: QueryParams) => + pipe( + getTopArtistsCache, + Effect.flatMap((cache) => cache.get(params)), + Effect.catchAll((err) => { + consola.error(err); + return Effect.succeed({ artists: [] }); + }), + ); + + server.app.rocksky.charts.getTopArtists({ + handler: async ({ params }) => { + const result = await Effect.runPromise(getTopArtists(params)); + return { + encoding: "application/json", + body: result, + }; + }, + }); +} + +const retrieve = ({ + params, + ctx, +}: { + params: QueryParams; + ctx: Context; +}): Effect.Effect<{ data: TopArtist[] }, Error> => { + return Effect.tryPromise({ + try: async () => { + const limit = params.limit || 50; + const offset = params.offset || 0; + + const dateConditions = []; + if (params.startDate) { + dateConditions.push( + gte(tables.scrobbles.timestamp, new Date(params.startDate)), + ); + } + if (params.endDate) { + dateConditions.push( + lte(tables.scrobbles.timestamp, new Date(params.endDate)), + ); + } + + const topArtistsQuery = ctx.db + .select({ + artistId: tables.scrobbles.artistId, + scrobbles: count(tables.scrobbles.id).as("scrobbles"), + }) + .from(tables.scrobbles) + .where(dateConditions.length > 0 ? and(...dateConditions) : undefined) + .groupBy(tables.scrobbles.artistId) + .orderBy(desc(sql`count(${tables.scrobbles.id})`)) + .limit(limit) + .offset(offset); + + const topArtistsData = await topArtistsQuery.execute(); + + if (topArtistsData.length === 0) { + return { data: [] }; + } + + const artistIds = topArtistsData + .map((a) => a.artistId) + .filter((id): id is string => id !== null); + + const artists = await ctx.db + .select({ + id: tables.artists.id, + name: tables.artists.name, + picture: tables.artists.picture, + sha256: tables.artists.sha256, + uri: tables.artists.uri, + genres: tables.artists.genres, + }) + .from(tables.artists) + .where(sql`${tables.artists.id} = ANY(${artistIds})`) + .execute(); + + const artistMap = new Map(artists.map((artist) => [artist.id, artist])); + + const uniqueListenersQuery = await ctx.db + .select({ + artistId: tables.scrobbles.artistId, + uniqueListeners: + sql`count(DISTINCT ${tables.scrobbles.userId})`.as( + "unique_listeners", + ), + }) + .from(tables.scrobbles) + .where( + and( + sql`${tables.scrobbles.artistId} = ANY(${artistIds})`, + dateConditions.length > 0 ? and(...dateConditions) : undefined, + ), + ) + .groupBy(tables.scrobbles.artistId) + .execute(); + + const listenersMap = new Map( + uniqueListenersQuery.map((item) => [ + item.artistId, + Number(item.uniqueListeners), + ]), + ); + + const result: TopArtist[] = topArtistsData + .map((item) => { + const artist = artistMap.get(item.artistId!); + if (!artist) return null; + + return { + id: artist.id, + name: artist.name, + picture: artist.picture, + sha256: artist.sha256, + uri: artist.uri, + play_count: Number(item.scrobbles), + unique_listeners: listenersMap.get(item.artistId!) || 0, + tags: artist.genres || [], + }; + }) + .filter((item): item is TopArtist => item !== null); + + return { data: result }; + }, + catch: (error) => new Error(`Failed to retrieve top artists: ${error}`), + }); +}; + +const presentation = ({ + data, +}: { + data: TopArtist[]; +}): Effect.Effect<{ artists: ArtistViewBasic[] }, never> => { + return Effect.sync(() => ({ artists: deepCamelCaseKeys(data) })); +}; + +type TopArtist = { + id: string; + name: string; + picture: string | null; + sha256: string; + uri: string | null; + play_count: number; + unique_listeners: number; + tags: string[]; +}; diff --git a/apps/api/src/xrpc/app/rocksky/charts/getTopTracks.ts b/apps/api/src/xrpc/app/rocksky/charts/getTopTracks.ts new file mode 100644 index 00000000..e596360e --- /dev/null +++ b/apps/api/src/xrpc/app/rocksky/charts/getTopTracks.ts @@ -0,0 +1,200 @@ +import type { Context } from "context"; +import { consola } from "consola"; +import { count, desc, eq, sql, and, gte, lte } from "drizzle-orm"; +import { Effect, pipe, Cache, Duration } from "effect"; +import type { Server } from "lexicon"; +import type { SongViewBasic } from "lexicon/types/app/rocksky/song/defs"; +import type { QueryParams } from "lexicon/types/app/rocksky/charts/getTopTracks"; +import { deepCamelCaseKeys } from "lib"; +import tables from "schema"; + +export default function (server: Server, ctx: Context) { + const getTopTracksCache = Cache.make({ + capacity: 100, + timeToLive: Duration.minutes(5), + lookup: (params: QueryParams) => + pipe( + { params, ctx }, + retrieve, + Effect.flatMap(presentation), + Effect.retry({ times: 3 }), + Effect.timeout("120 seconds"), + ), + }); + + const getTopTracks = (params: QueryParams) => + pipe( + getTopTracksCache, + Effect.flatMap((cache) => cache.get(params)), + Effect.catchAll((err) => { + consola.error(err); + return Effect.succeed({ tracks: [] }); + }), + ); + + server.app.rocksky.charts.getTopTracks({ + handler: async ({ params }) => { + const result = await Effect.runPromise(getTopTracks(params)); + return { + encoding: "application/json", + body: result, + }; + }, + }); +} + +const retrieve = ({ + params, + ctx, +}: { + params: QueryParams; + ctx: Context; +}): Effect.Effect<{ data: TopTrack[] }, Error> => { + return Effect.tryPromise({ + try: async () => { + const limit = params.limit || 50; + const offset = params.offset || 0; + + const dateConditions = []; + if (params.startDate) { + dateConditions.push( + gte(tables.scrobbles.timestamp, new Date(params.startDate)), + ); + } + if (params.endDate) { + dateConditions.push( + lte(tables.scrobbles.timestamp, new Date(params.endDate)), + ); + } + + const topTracksQuery = ctx.db + .select({ + trackId: tables.scrobbles.trackId, + scrobbles: count(tables.scrobbles.id).as("scrobbles"), + }) + .from(tables.scrobbles) + .where(dateConditions.length > 0 ? and(...dateConditions) : undefined) + .groupBy(tables.scrobbles.trackId) + .orderBy(desc(sql`count(${tables.scrobbles.id})`)) + .limit(limit) + .offset(offset); + + const topTracksData = await topTracksQuery.execute(); + + if (topTracksData.length === 0) { + return { data: [] }; + } + + const trackIds = topTracksData + .map((t) => t.trackId) + .filter((id): id is string => id !== null); + + const tracks = await ctx.db + .select({ + id: tables.tracks.id, + title: tables.tracks.title, + artist: tables.tracks.artist, + albumArtist: tables.tracks.albumArtist, + albumArt: tables.tracks.albumArt, + uri: tables.tracks.uri, + album: tables.tracks.album, + duration: tables.tracks.duration, + trackNumber: tables.tracks.trackNumber, + discNumber: tables.tracks.discNumber, + albumUri: tables.tracks.albumUri, + artistUri: tables.tracks.artistUri, + sha256: tables.tracks.sha256, + genre: tables.tracks.genre, + createdAt: tables.tracks.createdAt, + }) + .from(tables.tracks) + .where(sql`${tables.tracks.id} = ANY(${trackIds})`) + .execute(); + + const trackMap = new Map(tracks.map((track) => [track.id, track])); + + const uniqueListenersQuery = await ctx.db + .select({ + trackId: tables.scrobbles.trackId, + uniqueListeners: + sql`count(DISTINCT ${tables.scrobbles.userId})`.as( + "unique_listeners", + ), + }) + .from(tables.scrobbles) + .where( + and( + sql`${tables.scrobbles.trackId} = ANY(${trackIds})`, + dateConditions.length > 0 ? and(...dateConditions) : undefined, + ), + ) + .groupBy(tables.scrobbles.trackId) + .execute(); + + const listenersMap = new Map( + uniqueListenersQuery.map((item) => [ + item.trackId, + Number(item.uniqueListeners), + ]), + ); + + const result: TopTrack[] = topTracksData + .map((item) => { + const track = trackMap.get(item.trackId!); + if (!track) return null; + + return { + id: track.id, + title: track.title, + artist: track.artist, + album_artist: track.albumArtist, + album_art: track.albumArt, + uri: track.uri, + album: track.album, + duration: track.duration, + track_number: track.trackNumber, + disc_number: track.discNumber, + play_count: Number(item.scrobbles), + unique_listeners: listenersMap.get(item.trackId!) || 0, + album_uri: track.albumUri, + artist_uri: track.artistUri, + sha256: track.sha256, + tags: track.genre ? [track.genre] : [], + created_at: track.createdAt.toISOString(), + }; + }) + .filter((item): item is TopTrack => item !== null); + + return { data: result }; + }, + catch: (error) => new Error(`Failed to retrieve top tracks: ${error}`), + }); +}; + +const presentation = ({ + data, +}: { + data: TopTrack[]; +}): Effect.Effect<{ tracks: SongViewBasic[] }, never> => { + return Effect.sync(() => ({ tracks: deepCamelCaseKeys(data) })); +}; + +type TopTrack = { + id: string; + title: string; + artist: string; + album_artist: string; + album_art: string | null; + uri: string | null; + album: string; + duration: number; + track_number: number | null; + disc_number: number | null; + play_count: number; + unique_listeners: number; + album_uri: string | null; + artist_uri: string | null; + sha256: string; + tags: string[]; + created_at: string; +}; diff --git a/apps/api/src/xrpc/index.ts b/apps/api/src/xrpc/index.ts index 56fe8547..5fc034a1 100644 --- a/apps/api/src/xrpc/index.ts +++ b/apps/api/src/xrpc/index.ts @@ -83,6 +83,8 @@ import unfollowAccount from "./app/rocksky/graph/unfollowAccount"; import getActorNeighbours from "./app/rocksky/actor/getActorNeighbours"; import getActorCompatibility from "./app/rocksky/actor/getActorCompatibility"; import matchSong from "./app/rocksky/song/matchSong"; +import getTopArtists from "./app/rocksky/charts/getTopArtists"; +import getTopTracks from "./app/rocksky/charts/getTopTracks"; export default function (server: Server, ctx: Context) { // app.rocksky @@ -169,6 +171,8 @@ export default function (server: Server, ctx: Context) { getActorNeighbours(server, ctx); getActorCompatibility(server, ctx); matchSong(server, ctx); + getTopArtists(server, ctx); + getTopTracks(server, ctx); return server; } diff --git a/apps/web/src/api/library.ts b/apps/web/src/api/library.ts index cd38d530..1a465e5e 100644 --- a/apps/web/src/api/library.ts +++ b/apps/web/src/api/library.ts @@ -218,3 +218,43 @@ export const getTracksByGenre = async ( ); return response.data; }; + +export const getTopArtists = async ( + offset = 0, + limit = 20, + startDate?: Date, + endDate?: Date, +) => { + const response = await client.get<{ artists: Artist[] }>( + "/xrpc/app.rocksky.artist.getArtists", + { + params: { + limit, + offset, + startDate: startDate?.toISOString(), + endDate: endDate?.toISOString(), + }, + }, + ); + return response.data; +}; + +export const getTopTracks = async ( + offset = 0, + limit = 20, + startDate?: Date, + endDate?: Date, +) => { + const response = await client.get<{ tracks: Track[] }>( + "/xrpc/app.rocksky.song.getSongs", + { + params: { + limit, + offset, + startDate: startDate?.toISOString(), + endDate: endDate?.toISOString(), + }, + }, + ); + return response.data; +}; diff --git a/apps/web/src/components/ScrobblesAreaChart/ScrobblesAreaChart.tsx b/apps/web/src/components/ScrobblesAreaChart/ScrobblesAreaChart.tsx index 863dd581..e80beb02 100644 --- a/apps/web/src/components/ScrobblesAreaChart/ScrobblesAreaChart.tsx +++ b/apps/web/src/components/ScrobblesAreaChart/ScrobblesAreaChart.tsx @@ -57,7 +57,7 @@ function ScrobblesAreaChart() { useEffect(() => { const fetchScrobblesChart = async () => { - if (pathname === "/") { + if (pathname === "/" || pathname === "/charts") { return; } @@ -105,6 +105,7 @@ function ScrobblesAreaChart() { const chartData = pathname === "/" || + pathname === "/charts" || pathname.startsWith("/dropbox") || (pathname.startsWith("/googledrive") && getScrobblesChart().length > 0) ? getScrobblesChart() diff --git a/apps/web/src/hooks/useLibrary.tsx b/apps/web/src/hooks/useLibrary.tsx index 968d0dde..cb9431c9 100644 --- a/apps/web/src/hooks/useLibrary.tsx +++ b/apps/web/src/hooks/useLibrary.tsx @@ -11,6 +11,8 @@ import { getArtistTracks, getLovedTracks, getSongByUri, + getTopArtists, + getTopTracks, getTracks, getTracksByGenre, } from "../api/library"; @@ -221,3 +223,36 @@ export const useTracksByGenreInfiniteQuery = (genre: string, limit = 20) => enabled: !!genre, initialPageParam: 0, }); + +export const useTopTracksQuery = ( + offset = 0, + limit = 20, + startDate?: Date, + endDate?: Date, +) => + useQuery({ + queryKey: ["top-tracks", offset, limit, startDate, endDate], + queryFn: () => getTopTracks(offset, limit, startDate, endDate), + select: (data) => + // eslint-disable-next-line @typescript-eslint/no-explicit-any + data?.tracks.map((x) => ({ + ...x, + scrobbles: x.playCount, + })), + }); + +export const useTopArtistsQuery = ( + offset = 0, + limit = 20, + startDate?: Date, + endDate?: Date, +) => + useQuery({ + queryKey: ["top-artists", offset, limit, startDate, endDate], + queryFn: () => getTopArtists(offset, limit, startDate, endDate), + select: (data) => + data?.artists.map((x) => ({ + ...x, + scrobbles: x.playCount, + })), + }); diff --git a/apps/web/src/layouts/Navbar/Navbar.tsx b/apps/web/src/layouts/Navbar/Navbar.tsx index 85c081a1..399de651 100644 --- a/apps/web/src/layouts/Navbar/Navbar.tsx +++ b/apps/web/src/layouts/Navbar/Navbar.tsx @@ -44,6 +44,27 @@ export const Code = styled.div` border-radius: 5px; `; +const AnimatedLink = styled.span` + position: relative; + display: inline-block; + cursor: pointer; + + &::after { + content: ""; + position: absolute; + bottom: -6px; + left: 0; + width: 0; + height: 2px; + background-color: var(--color-text); + transition: width 0.3s ease; + } + + &:hover::after { + width: 100%; + } +`; + function Navbar() { const [isOpen, setIsOpen] = useState(false); const [{ darkMode }, setTheme] = useAtom(themeAtom); @@ -113,6 +134,18 @@ function Navbar() { +
+
+ + + Charts + + +
{profile && jwt && ( )} > -