A decentralized music tracking and discovery platform built on AT Protocol 🎵

Add charts API and web UI for top artists/tracks

+1382 -8
+50
apps/api/lexicons/charts/getTopArtists.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "app.rocksky.charts.getTopArtists", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "Get top artists", 8 + "parameters": { 9 + "type": "params", 10 + "properties": { 11 + "limit": { 12 + "type": "integer", 13 + "description": "The maximum number of artists to return", 14 + "minimum": 1 15 + }, 16 + "offset": { 17 + "type": "integer", 18 + "description": "The offset for pagination", 19 + "minimum": 0 20 + }, 21 + "startDate": { 22 + "type": "string", 23 + "description": "The start date to filter artists from (ISO 8601 format)", 24 + "format": "datetime" 25 + }, 26 + "endDate": { 27 + "type": "string", 28 + "description": "The end date to filter artists to (ISO 8601 format)", 29 + "format": "datetime" 30 + } 31 + } 32 + }, 33 + "output": { 34 + "encoding": "application/json", 35 + "schema": { 36 + "type": "object", 37 + "properties": { 38 + "artists": { 39 + "type": "array", 40 + "items": { 41 + "type": "ref", 42 + "ref": "app.rocksky.artist.defs#artistViewBasic" 43 + } 44 + } 45 + } 46 + } 47 + } 48 + } 49 + } 50 + }
+50
apps/api/lexicons/charts/getTopTracks.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "app.rocksky.charts.getTopTracks", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "Get top tracks", 8 + "parameters": { 9 + "type": "params", 10 + "properties": { 11 + "limit": { 12 + "type": "integer", 13 + "description": "The maximum number of tracks to return", 14 + "minimum": 1 15 + }, 16 + "offset": { 17 + "type": "integer", 18 + "description": "The offset for pagination", 19 + "minimum": 0 20 + }, 21 + "startDate": { 22 + "type": "string", 23 + "description": "The start date to filter tracks from (ISO 8601 format)", 24 + "format": "datetime" 25 + }, 26 + "endDate": { 27 + "type": "string", 28 + "description": "The end date to filter tracks to (ISO 8601 format)", 29 + "format": "datetime" 30 + } 31 + } 32 + }, 33 + "output": { 34 + "encoding": "application/json", 35 + "schema": { 36 + "type": "object", 37 + "properties": { 38 + "tracks": { 39 + "type": "array", 40 + "items": { 41 + "type": "ref", 42 + "ref": "app.rocksky.song.defs#songViewBasic" 43 + } 44 + } 45 + } 46 + } 47 + } 48 + } 49 + } 50 + }
+46
apps/api/pkl/defs/charts/getTopArtists.pkl
··· 1 + amends "../../schema/lexicon.pkl" 2 + 3 + lexicon = 1 4 + id = "app.rocksky.charts.getTopArtists" 5 + defs = new Mapping<String, Query> { 6 + ["main"] { 7 + type = "query" 8 + description = "Get top artists" 9 + parameters = new Params { 10 + properties { 11 + ["limit"] = new IntegerType { 12 + type = "integer" 13 + description = "The maximum number of artists to return" 14 + minimum = 1 15 + } 16 + ["offset"] = new IntegerType { 17 + type = "integer" 18 + description = "The offset for pagination" 19 + minimum = 0 20 + } 21 + ["startDate"] = new StringType { 22 + description = "The start date to filter artists from (ISO 8601 format)" 23 + format = "datetime" 24 + } 25 + ["endDate"] = new StringType { 26 + description = "The end date to filter artists to (ISO 8601 format)" 27 + format = "datetime" 28 + } 29 + } 30 + } 31 + output { 32 + encoding = "application/json" 33 + schema = new ObjectType { 34 + type = "object" 35 + properties = new Mapping<String, Array> { 36 + ["artists"] = new Array { 37 + type = "array" 38 + items = new Ref { 39 + ref = "app.rocksky.artist.defs#artistViewBasic" 40 + } 41 + } 42 + } 43 + } 44 + } 45 + } 46 + }
+46
apps/api/pkl/defs/charts/getTopTracks.pkl
··· 1 + amends "../../schema/lexicon.pkl" 2 + 3 + lexicon = 1 4 + id = "app.rocksky.charts.getTopTracks" 5 + defs = new Mapping<String, Query> { 6 + ["main"] { 7 + type = "query" 8 + description = "Get top tracks" 9 + parameters = new Params { 10 + properties { 11 + ["limit"] = new IntegerType { 12 + type = "integer" 13 + description = "The maximum number of tracks to return" 14 + minimum = 1 15 + } 16 + ["offset"] = new IntegerType { 17 + type = "integer" 18 + description = "The offset for pagination" 19 + minimum = 0 20 + } 21 + ["startDate"] = new StringType { 22 + description = "The start date to filter tracks from (ISO 8601 format)" 23 + format = "datetime" 24 + } 25 + ["endDate"] = new StringType { 26 + description = "The end date to filter tracks to (ISO 8601 format)" 27 + format = "datetime" 28 + } 29 + } 30 + } 31 + output { 32 + encoding = "application/json" 33 + schema = new ObjectType { 34 + type = "object" 35 + properties = new Mapping<String, Array> { 36 + ["tracks"] = new Array { 37 + type = "array" 38 + items = new Ref { 39 + ref = "app.rocksky.song.defs#songViewBasic" 40 + } 41 + } 42 + } 43 + } 44 + } 45 + } 46 + }
+24
apps/api/src/lexicon/index.ts
··· 36 36 import type * as AppRockskyArtistGetArtists from "./types/app/rocksky/artist/getArtists"; 37 37 import type * as AppRockskyArtistGetArtistTracks from "./types/app/rocksky/artist/getArtistTracks"; 38 38 import type * as AppRockskyChartsGetScrobblesChart from "./types/app/rocksky/charts/getScrobblesChart"; 39 + import type * as AppRockskyChartsGetTopArtists from "./types/app/rocksky/charts/getTopArtists"; 40 + import type * as AppRockskyChartsGetTopTracks from "./types/app/rocksky/charts/getTopTracks"; 39 41 import type * as AppRockskyDropboxDownloadFile from "./types/app/rocksky/dropbox/downloadFile"; 40 42 import type * as AppRockskyDropboxGetFiles from "./types/app/rocksky/dropbox/getFiles"; 41 43 import type * as AppRockskyDropboxGetMetadata from "./types/app/rocksky/dropbox/getMetadata"; ··· 555 557 >, 556 558 ) { 557 559 const nsid = "app.rocksky.charts.getScrobblesChart"; // @ts-ignore 560 + return this._server.xrpc.method(nsid, cfg); 561 + } 562 + 563 + getTopArtists<AV extends AuthVerifier>( 564 + cfg: ConfigOf< 565 + AV, 566 + AppRockskyChartsGetTopArtists.Handler<ExtractAuth<AV>>, 567 + AppRockskyChartsGetTopArtists.HandlerReqCtx<ExtractAuth<AV>> 568 + >, 569 + ) { 570 + const nsid = "app.rocksky.charts.getTopArtists"; // @ts-ignore 571 + return this._server.xrpc.method(nsid, cfg); 572 + } 573 + 574 + getTopTracks<AV extends AuthVerifier>( 575 + cfg: ConfigOf< 576 + AV, 577 + AppRockskyChartsGetTopTracks.Handler<ExtractAuth<AV>>, 578 + AppRockskyChartsGetTopTracks.HandlerReqCtx<ExtractAuth<AV>> 579 + >, 580 + ) { 581 + const nsid = "app.rocksky.charts.getTopTracks"; // @ts-ignore 558 582 return this._server.xrpc.method(nsid, cfg); 559 583 } 560 584 }
+105
apps/api/src/lexicon/lexicons.ts
··· 2246 2246 }, 2247 2247 }, 2248 2248 }, 2249 + AppRockskyChartsGetTopArtists: { 2250 + lexicon: 1, 2251 + id: "app.rocksky.charts.getTopArtists", 2252 + defs: { 2253 + main: { 2254 + type: "query", 2255 + description: "Get top artists", 2256 + parameters: { 2257 + type: "params", 2258 + properties: { 2259 + limit: { 2260 + type: "integer", 2261 + description: "The maximum number of artists to return", 2262 + minimum: 1, 2263 + }, 2264 + offset: { 2265 + type: "integer", 2266 + description: "The offset for pagination", 2267 + minimum: 0, 2268 + }, 2269 + startDate: { 2270 + type: "string", 2271 + description: 2272 + "The start date to filter artists from (ISO 8601 format)", 2273 + format: "datetime", 2274 + }, 2275 + endDate: { 2276 + type: "string", 2277 + description: 2278 + "The end date to filter artists to (ISO 8601 format)", 2279 + format: "datetime", 2280 + }, 2281 + }, 2282 + }, 2283 + output: { 2284 + encoding: "application/json", 2285 + schema: { 2286 + type: "object", 2287 + properties: { 2288 + artists: { 2289 + type: "array", 2290 + items: { 2291 + type: "ref", 2292 + ref: "lex:app.rocksky.artist.defs#artistViewBasic", 2293 + }, 2294 + }, 2295 + }, 2296 + }, 2297 + }, 2298 + }, 2299 + }, 2300 + }, 2301 + AppRockskyChartsGetTopTracks: { 2302 + lexicon: 1, 2303 + id: "app.rocksky.charts.getTopTracks", 2304 + defs: { 2305 + main: { 2306 + type: "query", 2307 + description: "Get top tracks", 2308 + parameters: { 2309 + type: "params", 2310 + properties: { 2311 + limit: { 2312 + type: "integer", 2313 + description: "The maximum number of tracks to return", 2314 + minimum: 1, 2315 + }, 2316 + offset: { 2317 + type: "integer", 2318 + description: "The offset for pagination", 2319 + minimum: 0, 2320 + }, 2321 + startDate: { 2322 + type: "string", 2323 + description: 2324 + "The start date to filter tracks from (ISO 8601 format)", 2325 + format: "datetime", 2326 + }, 2327 + endDate: { 2328 + type: "string", 2329 + description: "The end date to filter tracks to (ISO 8601 format)", 2330 + format: "datetime", 2331 + }, 2332 + }, 2333 + }, 2334 + output: { 2335 + encoding: "application/json", 2336 + schema: { 2337 + type: "object", 2338 + properties: { 2339 + tracks: { 2340 + type: "array", 2341 + items: { 2342 + type: "ref", 2343 + ref: "lex:app.rocksky.song.defs#songViewBasic", 2344 + }, 2345 + }, 2346 + }, 2347 + }, 2348 + }, 2349 + }, 2350 + }, 2351 + }, 2249 2352 AppRockskyDropboxDefs: { 2250 2353 lexicon: 1, 2251 2354 id: "app.rocksky.dropbox.defs", ··· 6069 6172 AppRockskyArtistGetArtistTracks: "app.rocksky.artist.getArtistTracks", 6070 6173 AppRockskyChartsDefs: "app.rocksky.charts.defs", 6071 6174 AppRockskyChartsGetScrobblesChart: "app.rocksky.charts.getScrobblesChart", 6175 + AppRockskyChartsGetTopArtists: "app.rocksky.charts.getTopArtists", 6176 + AppRockskyChartsGetTopTracks: "app.rocksky.charts.getTopTracks", 6072 6177 AppRockskyDropboxDefs: "app.rocksky.dropbox.defs", 6073 6178 AppRockskyDropboxDownloadFile: "app.rocksky.dropbox.downloadFile", 6074 6179 AppRockskyDropboxGetFiles: "app.rocksky.dropbox.getFiles",
+54
apps/api/src/lexicon/types/app/rocksky/charts/getTopArtists.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import type express from "express"; 5 + import { ValidationResult, BlobRef } from "@atproto/lexicon"; 6 + import { lexicons } from "../../../../lexicons"; 7 + import { isObj, hasProp } from "../../../../util"; 8 + import { CID } from "multiformats/cid"; 9 + import type { HandlerAuth, HandlerPipeThrough } from "@atproto/xrpc-server"; 10 + import type * as AppRockskyArtistDefs from "../artist/defs"; 11 + 12 + export interface QueryParams { 13 + /** The maximum number of artists to return */ 14 + limit?: number; 15 + /** The offset for pagination */ 16 + offset?: number; 17 + /** The start date to filter artists from (ISO 8601 format) */ 18 + startDate?: string; 19 + /** The end date to filter artists to (ISO 8601 format) */ 20 + endDate?: string; 21 + } 22 + 23 + export type InputSchema = undefined; 24 + 25 + export interface OutputSchema { 26 + artists?: AppRockskyArtistDefs.ArtistViewBasic[]; 27 + [k: string]: unknown; 28 + } 29 + 30 + export type HandlerInput = undefined; 31 + 32 + export interface HandlerSuccess { 33 + encoding: "application/json"; 34 + body: OutputSchema; 35 + headers?: { [key: string]: string }; 36 + } 37 + 38 + export interface HandlerError { 39 + status: number; 40 + message?: string; 41 + } 42 + 43 + export type HandlerOutput = HandlerError | HandlerSuccess | HandlerPipeThrough; 44 + export type HandlerReqCtx<HA extends HandlerAuth = never> = { 45 + auth: HA; 46 + params: QueryParams; 47 + input: HandlerInput; 48 + req: express.Request; 49 + res: express.Response; 50 + resetRouteRateLimits: () => Promise<void>; 51 + }; 52 + export type Handler<HA extends HandlerAuth = never> = ( 53 + ctx: HandlerReqCtx<HA>, 54 + ) => Promise<HandlerOutput> | HandlerOutput;
+54
apps/api/src/lexicon/types/app/rocksky/charts/getTopTracks.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import type express from "express"; 5 + import { ValidationResult, BlobRef } from "@atproto/lexicon"; 6 + import { lexicons } from "../../../../lexicons"; 7 + import { isObj, hasProp } from "../../../../util"; 8 + import { CID } from "multiformats/cid"; 9 + import type { HandlerAuth, HandlerPipeThrough } from "@atproto/xrpc-server"; 10 + import type * as AppRockskySongDefs from "../song/defs"; 11 + 12 + export interface QueryParams { 13 + /** The maximum number of tracks to return */ 14 + limit?: number; 15 + /** The offset for pagination */ 16 + offset?: number; 17 + /** The start date to filter tracks from (ISO 8601 format) */ 18 + startDate?: string; 19 + /** The end date to filter tracks to (ISO 8601 format) */ 20 + endDate?: string; 21 + } 22 + 23 + export type InputSchema = undefined; 24 + 25 + export interface OutputSchema { 26 + tracks?: AppRockskySongDefs.SongViewBasic[]; 27 + [k: string]: unknown; 28 + } 29 + 30 + export type HandlerInput = undefined; 31 + 32 + export interface HandlerSuccess { 33 + encoding: "application/json"; 34 + body: OutputSchema; 35 + headers?: { [key: string]: string }; 36 + } 37 + 38 + export interface HandlerError { 39 + status: number; 40 + message?: string; 41 + } 42 + 43 + export type HandlerOutput = HandlerError | HandlerSuccess | HandlerPipeThrough; 44 + export type HandlerReqCtx<HA extends HandlerAuth = never> = { 45 + auth: HA; 46 + params: QueryParams; 47 + input: HandlerInput; 48 + req: express.Request; 49 + res: express.Response; 50 + resetRouteRateLimits: () => Promise<void>; 51 + }; 52 + export type Handler<HA extends HandlerAuth = never> = ( 53 + ctx: HandlerReqCtx<HA>, 54 + ) => Promise<HandlerOutput> | HandlerOutput;
+173
apps/api/src/xrpc/app/rocksky/charts/getTopArtists.ts
··· 1 + import type { Context } from "context"; 2 + import { consola } from "consola"; 3 + import { count, desc, sql, and, gte, lte } from "drizzle-orm"; 4 + import { Effect, pipe, Cache, Duration } from "effect"; 5 + import type { Server } from "lexicon"; 6 + import type { ArtistViewBasic } from "lexicon/types/app/rocksky/artist/defs"; 7 + import type { QueryParams } from "lexicon/types/app/rocksky/charts/getTopArtists"; 8 + import { deepCamelCaseKeys } from "lib"; 9 + import tables from "schema"; 10 + 11 + export default function (server: Server, ctx: Context) { 12 + const getTopArtistsCache = Cache.make({ 13 + capacity: 100, 14 + timeToLive: Duration.minutes(5), 15 + lookup: (params: QueryParams) => 16 + pipe( 17 + { params, ctx }, 18 + retrieve, 19 + Effect.flatMap(presentation), 20 + Effect.retry({ times: 3 }), 21 + Effect.timeout("120 seconds"), 22 + ), 23 + }); 24 + 25 + const getTopArtists = (params: QueryParams) => 26 + pipe( 27 + getTopArtistsCache, 28 + Effect.flatMap((cache) => cache.get(params)), 29 + Effect.catchAll((err) => { 30 + consola.error(err); 31 + return Effect.succeed({ artists: [] }); 32 + }), 33 + ); 34 + 35 + server.app.rocksky.charts.getTopArtists({ 36 + handler: async ({ params }) => { 37 + const result = await Effect.runPromise(getTopArtists(params)); 38 + return { 39 + encoding: "application/json", 40 + body: result, 41 + }; 42 + }, 43 + }); 44 + } 45 + 46 + const retrieve = ({ 47 + params, 48 + ctx, 49 + }: { 50 + params: QueryParams; 51 + ctx: Context; 52 + }): Effect.Effect<{ data: TopArtist[] }, Error> => { 53 + return Effect.tryPromise({ 54 + try: async () => { 55 + const limit = params.limit || 50; 56 + const offset = params.offset || 0; 57 + 58 + const dateConditions = []; 59 + if (params.startDate) { 60 + dateConditions.push( 61 + gte(tables.scrobbles.timestamp, new Date(params.startDate)), 62 + ); 63 + } 64 + if (params.endDate) { 65 + dateConditions.push( 66 + lte(tables.scrobbles.timestamp, new Date(params.endDate)), 67 + ); 68 + } 69 + 70 + const topArtistsQuery = ctx.db 71 + .select({ 72 + artistId: tables.scrobbles.artistId, 73 + scrobbles: count(tables.scrobbles.id).as("scrobbles"), 74 + }) 75 + .from(tables.scrobbles) 76 + .where(dateConditions.length > 0 ? and(...dateConditions) : undefined) 77 + .groupBy(tables.scrobbles.artistId) 78 + .orderBy(desc(sql`count(${tables.scrobbles.id})`)) 79 + .limit(limit) 80 + .offset(offset); 81 + 82 + const topArtistsData = await topArtistsQuery.execute(); 83 + 84 + if (topArtistsData.length === 0) { 85 + return { data: [] }; 86 + } 87 + 88 + const artistIds = topArtistsData 89 + .map((a) => a.artistId) 90 + .filter((id): id is string => id !== null); 91 + 92 + const artists = await ctx.db 93 + .select({ 94 + id: tables.artists.id, 95 + name: tables.artists.name, 96 + picture: tables.artists.picture, 97 + sha256: tables.artists.sha256, 98 + uri: tables.artists.uri, 99 + genres: tables.artists.genres, 100 + }) 101 + .from(tables.artists) 102 + .where(sql`${tables.artists.id} = ANY(${artistIds})`) 103 + .execute(); 104 + 105 + const artistMap = new Map(artists.map((artist) => [artist.id, artist])); 106 + 107 + const uniqueListenersQuery = await ctx.db 108 + .select({ 109 + artistId: tables.scrobbles.artistId, 110 + uniqueListeners: 111 + sql<number>`count(DISTINCT ${tables.scrobbles.userId})`.as( 112 + "unique_listeners", 113 + ), 114 + }) 115 + .from(tables.scrobbles) 116 + .where( 117 + and( 118 + sql`${tables.scrobbles.artistId} = ANY(${artistIds})`, 119 + dateConditions.length > 0 ? and(...dateConditions) : undefined, 120 + ), 121 + ) 122 + .groupBy(tables.scrobbles.artistId) 123 + .execute(); 124 + 125 + const listenersMap = new Map( 126 + uniqueListenersQuery.map((item) => [ 127 + item.artistId, 128 + Number(item.uniqueListeners), 129 + ]), 130 + ); 131 + 132 + const result: TopArtist[] = topArtistsData 133 + .map((item) => { 134 + const artist = artistMap.get(item.artistId!); 135 + if (!artist) return null; 136 + 137 + return { 138 + id: artist.id, 139 + name: artist.name, 140 + picture: artist.picture, 141 + sha256: artist.sha256, 142 + uri: artist.uri, 143 + play_count: Number(item.scrobbles), 144 + unique_listeners: listenersMap.get(item.artistId!) || 0, 145 + tags: artist.genres || [], 146 + }; 147 + }) 148 + .filter((item): item is TopArtist => item !== null); 149 + 150 + return { data: result }; 151 + }, 152 + catch: (error) => new Error(`Failed to retrieve top artists: ${error}`), 153 + }); 154 + }; 155 + 156 + const presentation = ({ 157 + data, 158 + }: { 159 + data: TopArtist[]; 160 + }): Effect.Effect<{ artists: ArtistViewBasic[] }, never> => { 161 + return Effect.sync(() => ({ artists: deepCamelCaseKeys(data) })); 162 + }; 163 + 164 + type TopArtist = { 165 + id: string; 166 + name: string; 167 + picture: string | null; 168 + sha256: string; 169 + uri: string | null; 170 + play_count: number; 171 + unique_listeners: number; 172 + tags: string[]; 173 + };
+200
apps/api/src/xrpc/app/rocksky/charts/getTopTracks.ts
··· 1 + import type { Context } from "context"; 2 + import { consola } from "consola"; 3 + import { count, desc, eq, sql, and, gte, lte } from "drizzle-orm"; 4 + import { Effect, pipe, Cache, Duration } from "effect"; 5 + import type { Server } from "lexicon"; 6 + import type { SongViewBasic } from "lexicon/types/app/rocksky/song/defs"; 7 + import type { QueryParams } from "lexicon/types/app/rocksky/charts/getTopTracks"; 8 + import { deepCamelCaseKeys } from "lib"; 9 + import tables from "schema"; 10 + 11 + export default function (server: Server, ctx: Context) { 12 + const getTopTracksCache = Cache.make({ 13 + capacity: 100, 14 + timeToLive: Duration.minutes(5), 15 + lookup: (params: QueryParams) => 16 + pipe( 17 + { params, ctx }, 18 + retrieve, 19 + Effect.flatMap(presentation), 20 + Effect.retry({ times: 3 }), 21 + Effect.timeout("120 seconds"), 22 + ), 23 + }); 24 + 25 + const getTopTracks = (params: QueryParams) => 26 + pipe( 27 + getTopTracksCache, 28 + Effect.flatMap((cache) => cache.get(params)), 29 + Effect.catchAll((err) => { 30 + consola.error(err); 31 + return Effect.succeed({ tracks: [] }); 32 + }), 33 + ); 34 + 35 + server.app.rocksky.charts.getTopTracks({ 36 + handler: async ({ params }) => { 37 + const result = await Effect.runPromise(getTopTracks(params)); 38 + return { 39 + encoding: "application/json", 40 + body: result, 41 + }; 42 + }, 43 + }); 44 + } 45 + 46 + const retrieve = ({ 47 + params, 48 + ctx, 49 + }: { 50 + params: QueryParams; 51 + ctx: Context; 52 + }): Effect.Effect<{ data: TopTrack[] }, Error> => { 53 + return Effect.tryPromise({ 54 + try: async () => { 55 + const limit = params.limit || 50; 56 + const offset = params.offset || 0; 57 + 58 + const dateConditions = []; 59 + if (params.startDate) { 60 + dateConditions.push( 61 + gte(tables.scrobbles.timestamp, new Date(params.startDate)), 62 + ); 63 + } 64 + if (params.endDate) { 65 + dateConditions.push( 66 + lte(tables.scrobbles.timestamp, new Date(params.endDate)), 67 + ); 68 + } 69 + 70 + const topTracksQuery = ctx.db 71 + .select({ 72 + trackId: tables.scrobbles.trackId, 73 + scrobbles: count(tables.scrobbles.id).as("scrobbles"), 74 + }) 75 + .from(tables.scrobbles) 76 + .where(dateConditions.length > 0 ? and(...dateConditions) : undefined) 77 + .groupBy(tables.scrobbles.trackId) 78 + .orderBy(desc(sql`count(${tables.scrobbles.id})`)) 79 + .limit(limit) 80 + .offset(offset); 81 + 82 + const topTracksData = await topTracksQuery.execute(); 83 + 84 + if (topTracksData.length === 0) { 85 + return { data: [] }; 86 + } 87 + 88 + const trackIds = topTracksData 89 + .map((t) => t.trackId) 90 + .filter((id): id is string => id !== null); 91 + 92 + const tracks = await ctx.db 93 + .select({ 94 + id: tables.tracks.id, 95 + title: tables.tracks.title, 96 + artist: tables.tracks.artist, 97 + albumArtist: tables.tracks.albumArtist, 98 + albumArt: tables.tracks.albumArt, 99 + uri: tables.tracks.uri, 100 + album: tables.tracks.album, 101 + duration: tables.tracks.duration, 102 + trackNumber: tables.tracks.trackNumber, 103 + discNumber: tables.tracks.discNumber, 104 + albumUri: tables.tracks.albumUri, 105 + artistUri: tables.tracks.artistUri, 106 + sha256: tables.tracks.sha256, 107 + genre: tables.tracks.genre, 108 + createdAt: tables.tracks.createdAt, 109 + }) 110 + .from(tables.tracks) 111 + .where(sql`${tables.tracks.id} = ANY(${trackIds})`) 112 + .execute(); 113 + 114 + const trackMap = new Map(tracks.map((track) => [track.id, track])); 115 + 116 + const uniqueListenersQuery = await ctx.db 117 + .select({ 118 + trackId: tables.scrobbles.trackId, 119 + uniqueListeners: 120 + sql<number>`count(DISTINCT ${tables.scrobbles.userId})`.as( 121 + "unique_listeners", 122 + ), 123 + }) 124 + .from(tables.scrobbles) 125 + .where( 126 + and( 127 + sql`${tables.scrobbles.trackId} = ANY(${trackIds})`, 128 + dateConditions.length > 0 ? and(...dateConditions) : undefined, 129 + ), 130 + ) 131 + .groupBy(tables.scrobbles.trackId) 132 + .execute(); 133 + 134 + const listenersMap = new Map( 135 + uniqueListenersQuery.map((item) => [ 136 + item.trackId, 137 + Number(item.uniqueListeners), 138 + ]), 139 + ); 140 + 141 + const result: TopTrack[] = topTracksData 142 + .map((item) => { 143 + const track = trackMap.get(item.trackId!); 144 + if (!track) return null; 145 + 146 + return { 147 + id: track.id, 148 + title: track.title, 149 + artist: track.artist, 150 + album_artist: track.albumArtist, 151 + album_art: track.albumArt, 152 + uri: track.uri, 153 + album: track.album, 154 + duration: track.duration, 155 + track_number: track.trackNumber, 156 + disc_number: track.discNumber, 157 + play_count: Number(item.scrobbles), 158 + unique_listeners: listenersMap.get(item.trackId!) || 0, 159 + album_uri: track.albumUri, 160 + artist_uri: track.artistUri, 161 + sha256: track.sha256, 162 + tags: track.genre ? [track.genre] : [], 163 + created_at: track.createdAt.toISOString(), 164 + }; 165 + }) 166 + .filter((item): item is TopTrack => item !== null); 167 + 168 + return { data: result }; 169 + }, 170 + catch: (error) => new Error(`Failed to retrieve top tracks: ${error}`), 171 + }); 172 + }; 173 + 174 + const presentation = ({ 175 + data, 176 + }: { 177 + data: TopTrack[]; 178 + }): Effect.Effect<{ tracks: SongViewBasic[] }, never> => { 179 + return Effect.sync(() => ({ tracks: deepCamelCaseKeys(data) })); 180 + }; 181 + 182 + type TopTrack = { 183 + id: string; 184 + title: string; 185 + artist: string; 186 + album_artist: string; 187 + album_art: string | null; 188 + uri: string | null; 189 + album: string; 190 + duration: number; 191 + track_number: number | null; 192 + disc_number: number | null; 193 + play_count: number; 194 + unique_listeners: number; 195 + album_uri: string | null; 196 + artist_uri: string | null; 197 + sha256: string; 198 + tags: string[]; 199 + created_at: string; 200 + };
+4
apps/api/src/xrpc/index.ts
··· 83 83 import getActorNeighbours from "./app/rocksky/actor/getActorNeighbours"; 84 84 import getActorCompatibility from "./app/rocksky/actor/getActorCompatibility"; 85 85 import matchSong from "./app/rocksky/song/matchSong"; 86 + import getTopArtists from "./app/rocksky/charts/getTopArtists"; 87 + import getTopTracks from "./app/rocksky/charts/getTopTracks"; 86 88 87 89 export default function (server: Server, ctx: Context) { 88 90 // app.rocksky ··· 169 171 getActorNeighbours(server, ctx); 170 172 getActorCompatibility(server, ctx); 171 173 matchSong(server, ctx); 174 + getTopArtists(server, ctx); 175 + getTopTracks(server, ctx); 172 176 173 177 return server; 174 178 }
+40
apps/web/src/api/library.ts
··· 218 218 ); 219 219 return response.data; 220 220 }; 221 + 222 + export const getTopArtists = async ( 223 + offset = 0, 224 + limit = 20, 225 + startDate?: Date, 226 + endDate?: Date, 227 + ) => { 228 + const response = await client.get<{ artists: Artist[] }>( 229 + "/xrpc/app.rocksky.artist.getArtists", 230 + { 231 + params: { 232 + limit, 233 + offset, 234 + startDate: startDate?.toISOString(), 235 + endDate: endDate?.toISOString(), 236 + }, 237 + }, 238 + ); 239 + return response.data; 240 + }; 241 + 242 + export const getTopTracks = async ( 243 + offset = 0, 244 + limit = 20, 245 + startDate?: Date, 246 + endDate?: Date, 247 + ) => { 248 + const response = await client.get<{ tracks: Track[] }>( 249 + "/xrpc/app.rocksky.song.getSongs", 250 + { 251 + params: { 252 + limit, 253 + offset, 254 + startDate: startDate?.toISOString(), 255 + endDate: endDate?.toISOString(), 256 + }, 257 + }, 258 + ); 259 + return response.data; 260 + };
+2 -1
apps/web/src/components/ScrobblesAreaChart/ScrobblesAreaChart.tsx
··· 57 57 58 58 useEffect(() => { 59 59 const fetchScrobblesChart = async () => { 60 - if (pathname === "/") { 60 + if (pathname === "/" || pathname === "/charts") { 61 61 return; 62 62 } 63 63 ··· 105 105 106 106 const chartData = 107 107 pathname === "/" || 108 + pathname === "/charts" || 108 109 pathname.startsWith("/dropbox") || 109 110 (pathname.startsWith("/googledrive") && getScrobblesChart().length > 0) 110 111 ? getScrobblesChart()
+35
apps/web/src/hooks/useLibrary.tsx
··· 11 11 getArtistTracks, 12 12 getLovedTracks, 13 13 getSongByUri, 14 + getTopArtists, 15 + getTopTracks, 14 16 getTracks, 15 17 getTracksByGenre, 16 18 } from "../api/library"; ··· 221 223 enabled: !!genre, 222 224 initialPageParam: 0, 223 225 }); 226 + 227 + export const useTopTracksQuery = ( 228 + offset = 0, 229 + limit = 20, 230 + startDate?: Date, 231 + endDate?: Date, 232 + ) => 233 + useQuery({ 234 + queryKey: ["top-tracks", offset, limit, startDate, endDate], 235 + queryFn: () => getTopTracks(offset, limit, startDate, endDate), 236 + select: (data) => 237 + // eslint-disable-next-line @typescript-eslint/no-explicit-any 238 + data?.tracks.map((x) => ({ 239 + ...x, 240 + scrobbles: x.playCount, 241 + })), 242 + }); 243 + 244 + export const useTopArtistsQuery = ( 245 + offset = 0, 246 + limit = 20, 247 + startDate?: Date, 248 + endDate?: Date, 249 + ) => 250 + useQuery({ 251 + queryKey: ["top-artists", offset, limit, startDate, endDate], 252 + queryFn: () => getTopArtists(offset, limit, startDate, endDate), 253 + select: (data) => 254 + data?.artists.map((x) => ({ 255 + ...x, 256 + scrobbles: x.playCount, 257 + })), 258 + });
+34 -7
apps/web/src/layouts/Navbar/Navbar.tsx
··· 44 44 border-radius: 5px; 45 45 `; 46 46 47 + const AnimatedLink = styled.span` 48 + position: relative; 49 + display: inline-block; 50 + cursor: pointer; 51 + 52 + &::after { 53 + content: ""; 54 + position: absolute; 55 + bottom: -6px; 56 + left: 0; 57 + width: 0; 58 + height: 2px; 59 + background-color: var(--color-text); 60 + transition: width 0.3s ease; 61 + } 62 + 63 + &:hover::after { 64 + width: 100%; 65 + } 66 + `; 67 + 47 68 function Navbar() { 48 69 const [isOpen, setIsOpen] = useState(false); 49 70 const [{ darkMode }, setTheme] = useAtom(themeAtom); ··· 111 132 <h2 className="text-[var(--color-primary)] text-[26px] font-bold"> 112 133 Rocksky 113 134 </h2> 135 + </Link> 136 + </div> 137 + <div className="flex-1"></div> 138 + <div> 139 + <Link 140 + to="/charts" 141 + className="text-[var(--color-text)] text-[16px] opacity-90 hover:opacity-100" 142 + style={{ textDecoration: "none" }} 143 + > 144 + <AnimatedLink> 145 + <b>Charts</b> 146 + </AnimatedLink> 114 147 </Link> 115 148 </div> 116 149 ··· 297 330 </div> 298 331 )} 299 332 > 300 - <button 301 - style={{ 302 - border: "none", 303 - backgroundColor: "transparent", 304 - cursor: "pointer", 305 - }} 306 - > 333 + <button className="ml-[15px] border-none bg-transparent cursor-pointer"> 307 334 <Avatar 308 335 src={profile.avatar} 309 336 name={profile.displayName}
+73
apps/web/src/pages/charts/Charts.tsx
··· 1 + import { HeadingMedium } from "baseui/typography"; 2 + import Main from "../../layouts/Main"; 3 + import { Tab } from "baseui/tabs-motion/tab"; 4 + import { Tabs } from "baseui/tabs-motion"; 5 + import React, { useState } from "react"; 6 + import Realtime from "./realtime"; 7 + import Weekly from "./weekly"; 8 + 9 + function Charts() { 10 + const [activeKey, setActiveKey] = useState<React.Key>("0"); 11 + return ( 12 + <Main> 13 + <div className="mt-[60px] mb-[100px]"> 14 + <HeadingMedium 15 + marginTop="0px" 16 + marginBottom={"35px"} 17 + className="!text-[var(--color-text)]" 18 + > 19 + Charts 20 + </HeadingMedium> 21 + 22 + <Tabs 23 + activeKey={activeKey} 24 + onChange={({ activeKey }) => { 25 + setActiveKey(activeKey); 26 + }} 27 + overrides={{ 28 + TabHighlight: { 29 + style: { 30 + backgroundColor: "var(--color-purple)", 31 + }, 32 + }, 33 + TabBorder: { 34 + style: { 35 + display: "none", 36 + }, 37 + }, 38 + }} 39 + activateOnFocus 40 + > 41 + <Tab 42 + title="Real time" 43 + overrides={{ 44 + Tab: { 45 + style: { 46 + color: "var(--color-text)", 47 + backgroundColor: "var(--color-background) !important", 48 + }, 49 + }, 50 + }} 51 + > 52 + <Realtime /> 53 + </Tab> 54 + <Tab 55 + title="Weekly" 56 + overrides={{ 57 + Tab: { 58 + style: { 59 + color: "var(--color-text)", 60 + backgroundColor: "var(--color-background) !important", 61 + }, 62 + }, 63 + }} 64 + > 65 + <Weekly /> 66 + </Tab> 67 + </Tabs> 68 + </div> 69 + </Main> 70 + ); 71 + } 72 + 73 + export default Charts;
+3
apps/web/src/pages/charts/index.tsx
··· 1 + import Charts from "./Charts"; 2 + 3 + export default Charts;
+244
apps/web/src/pages/charts/realtime/Realtime.tsx
··· 1 + import { 2 + useTopArtistsQuery, 3 + useTopTracksQuery, 4 + } from "../../../hooks/useLibrary"; 5 + import { Link } from "@tanstack/react-router"; 6 + import { TableBuilder, TableBuilderColumn } from "baseui/table-semantic"; 7 + import Artist from "../../../components/Icons/Artist"; 8 + 9 + type Row = { 10 + id: string; 11 + title: string; 12 + artist: string; 13 + albumArtist: string; 14 + albumArt: string; 15 + albumUri?: string; 16 + artistUri?: string; 17 + uri: string; 18 + scrobbles: number; 19 + index: number; 20 + }; 21 + 22 + type ArtistRow = { 23 + id: string; 24 + name: string; 25 + picture: string; 26 + uri: string; 27 + scrobbles: number; 28 + index: number; 29 + }; 30 + 31 + function Realtime() { 32 + const { data: artists } = useTopArtistsQuery(); 33 + const { data: tracks } = useTopTracksQuery(); 34 + return ( 35 + <> 36 + <div className="flex"> 37 + <div className="flex-1"> 38 + <h3>Top Tracks</h3> 39 + <TableBuilder 40 + data={tracks?.map((x, index) => ({ 41 + id: x.id, 42 + title: x.title, 43 + artist: x.artist, 44 + albumArtist: x.albumArtist, 45 + albumArt: x.albumArt, 46 + uri: x.uri, 47 + scrobbles: x.scrobbles, 48 + albumUri: x.albumUri, 49 + artistUri: x.artistUri, 50 + index, 51 + }))} 52 + divider="clean" 53 + overrides={{ 54 + TableHeadRow: { 55 + style: { 56 + display: "none", 57 + }, 58 + }, 59 + TableBodyCell: { 60 + style: { 61 + verticalAlign: "center", 62 + }, 63 + }, 64 + TableBodyRow: { 65 + style: { 66 + backgroundColor: "var(--color-background)", 67 + ":hover": { 68 + backgroundColor: "var(--color-menu-hover)", 69 + }, 70 + }, 71 + }, 72 + TableEmptyMessage: { 73 + style: { 74 + backgroundColor: "var(--color-background)", 75 + }, 76 + }, 77 + Table: { 78 + style: { 79 + backgroundColor: "var(--color-background)", 80 + }, 81 + }, 82 + }} 83 + > 84 + <TableBuilderColumn header="Name"> 85 + {(row: Row) => ( 86 + <div className="flex flex-row items-center"> 87 + <div> 88 + <div className="text-[var(--color-text)] mr-[20px]"> 89 + {row.index + 1} 90 + </div> 91 + </div> 92 + {row.albumUri && ( 93 + <Link 94 + to={ 95 + `/${row.albumUri?.split("at://")[1].replace("app.rocksky.", "")}` as string 96 + } 97 + > 98 + {!!row.albumArt && ( 99 + <img 100 + src={row.albumArt} 101 + alt={row.title} 102 + className="w-[60px] h-[60px] mr-[20px] rounded-[5px]" 103 + key={row.id} 104 + /> 105 + )} 106 + {!row.albumArt && ( 107 + <div className="w-[60px] h-[60px] rounded-[5px] bg-[rgba(243, 243, 243, 0.725)]" /> 108 + )} 109 + </Link> 110 + )} 111 + {!row.albumUri && ( 112 + <div> 113 + {!!row.albumArt && ( 114 + <img 115 + src={row.albumArt} 116 + alt={row.title} 117 + className="w-[60px] h-[60px] mr-[20px] rounded-[5px]" 118 + key={row.id} 119 + /> 120 + )} 121 + {!row.albumArt && ( 122 + <div className="w-[60px] h-[60px] rounded-[5px] bg-[rgba(243, 243, 243, 0.725)]" /> 123 + )} 124 + </div> 125 + )} 126 + <div className="flex flex-col"> 127 + <Link 128 + to={ 129 + `/${row.uri?.split("at://")[1]?.replace("app.rocksky.", "")}` as string 130 + } 131 + className="!text-[var(--color-text)] no-underline" 132 + > 133 + {row.title} 134 + </Link> 135 + {row.artistUri && ( 136 + <Link 137 + to={ 138 + `/${row.artistUri?.split("at://")[1]?.replace("app.rocksky.", "")}` as string 139 + } 140 + className="!text-[var(--color-text-muted)] no-underline" 141 + > 142 + {row.albumArtist} 143 + </Link> 144 + )} 145 + {!row.artistUri && ( 146 + <div className="!text-[var(--color-text-muted)]"> 147 + {row.albumArtist} 148 + </div> 149 + )} 150 + </div> 151 + </div> 152 + )} 153 + </TableBuilderColumn> 154 + </TableBuilder> 155 + </div> 156 + <div className="flex-1"> 157 + <h3>Top Artists</h3> 158 + <TableBuilder 159 + data={artists?.map((x, index) => ({ 160 + id: x.id, 161 + name: x.name, 162 + picture: x.picture, 163 + uri: x.uri, 164 + scrobbles: x.scrobbles, 165 + index, 166 + }))} 167 + divider="clean" 168 + overrides={{ 169 + TableHeadRow: { 170 + style: { 171 + display: "none", 172 + }, 173 + }, 174 + TableBodyCell: { 175 + style: { 176 + verticalAlign: "middle", 177 + }, 178 + }, 179 + TableBodyRow: { 180 + style: { 181 + backgroundColor: "var(--color-background)", 182 + ":hover": { 183 + backgroundColor: "var(--color-menu-hover)", 184 + }, 185 + }, 186 + }, 187 + TableEmptyMessage: { 188 + style: { 189 + backgroundColor: "var(--color-background)", 190 + }, 191 + }, 192 + Table: { 193 + style: { 194 + backgroundColor: "var(--color-background)", 195 + }, 196 + }, 197 + }} 198 + > 199 + <TableBuilderColumn header="Name"> 200 + {(row: ArtistRow) => ( 201 + <div className="flex flex-row items-center"> 202 + <div> 203 + <div className="mr-[20px] text-[var(--color-text)]"> 204 + {row.index + 1} 205 + </div> 206 + </div> 207 + <a 208 + href={`/${row.uri?.split("at://")[1]?.replace("app.rocksky.", "")}`} 209 + > 210 + {!!row.picture && ( 211 + <img 212 + src={row.picture} 213 + alt={row.name} 214 + className="w-[60px] h-[60px] rounded-full mr-[20px]" 215 + key={row.id} 216 + /> 217 + )} 218 + {!row.picture && ( 219 + <div className="w-[60px] h-[60px] rounded-full bg-[rgba(243, 243, 243, 0.725)] flex justify-center items-center mr-[20px]"> 220 + <div className="h-[30px] w-[30px]"> 221 + <Artist color="rgba(66, 87, 108, 0.65)" /> 222 + </div> 223 + </div> 224 + )} 225 + </a> 226 + <div> 227 + <a 228 + href={`/${row.uri?.split("at://")[1]?.replace("app.rocksky.", "")}`} 229 + className="no-underline !text-[var(--color-text)]" 230 + > 231 + {row.name} 232 + </a> 233 + </div> 234 + </div> 235 + )} 236 + </TableBuilderColumn> 237 + </TableBuilder> 238 + </div> 239 + </div> 240 + </> 241 + ); 242 + } 243 + 244 + export default Realtime;
+3
apps/web/src/pages/charts/realtime/index.tsx
··· 1 + import Realtime from "./Realtime"; 2 + 3 + export default Realtime;
+112
apps/web/src/pages/charts/weekly/Weekly.tsx
··· 1 + import { TableBuilder, TableBuilderColumn } from "baseui/table-semantic"; 2 + import { useTopArtistsQuery } from "../../../hooks/useLibrary"; 3 + import { Link } from "@tanstack/react-router"; 4 + import Artist from "../../../components/Icons/Artist"; 5 + import { getLastDays } from "../../../lib/date"; 6 + 7 + type ArtistRow = { 8 + id: string; 9 + name: string; 10 + picture: string; 11 + uri: string; 12 + scrobbles: number; 13 + index: number; 14 + }; 15 + 16 + function Weekly() { 17 + const { data: artists } = useTopArtistsQuery(0, 20, ...getLastDays(7)); 18 + return ( 19 + <> 20 + <TableBuilder 21 + data={artists?.map((x, index) => ({ 22 + id: x.id, 23 + name: x.name, 24 + picture: x.picture, 25 + uri: x.uri, 26 + scrobbles: x.scrobbles, 27 + index, 28 + }))} 29 + divider="clean" 30 + overrides={{ 31 + TableHeadRow: { 32 + style: { 33 + display: "none", 34 + }, 35 + }, 36 + TableBodyCell: { 37 + style: { 38 + verticalAlign: "middle", 39 + }, 40 + }, 41 + TableBodyRow: { 42 + style: { 43 + backgroundColor: "var(--color-background)", 44 + ":hover": { 45 + backgroundColor: "var(--color-menu-hover)", 46 + }, 47 + }, 48 + }, 49 + TableEmptyMessage: { 50 + style: { 51 + backgroundColor: "var(--color-background)", 52 + }, 53 + }, 54 + Table: { 55 + style: { 56 + backgroundColor: "var(--color-background)", 57 + }, 58 + }, 59 + }} 60 + > 61 + <TableBuilderColumn header="Name"> 62 + {(row: ArtistRow) => ( 63 + <div className="flex flex-row items-center"> 64 + <div> 65 + <div className="mr-[20px] text-[var(--color-text)]"> 66 + {row.index + 1} 67 + </div> 68 + </div> 69 + <Link 70 + to="/$did/artist/$rkey" 71 + params={{ 72 + did: row.uri?.split("at://")[1]?.split("/")[0] || "", 73 + rkey: row.uri?.split("/").pop() || "", 74 + }} 75 + > 76 + {!!row.picture && ( 77 + <img 78 + src={row.picture} 79 + alt={row.name} 80 + className="w-[60px] h-[60px] rounded-full mr-[20px]" 81 + key={row.id} 82 + /> 83 + )} 84 + {!row.picture && ( 85 + <div className="w-[60px] h-[60px] rounded-full bg-[rgba(243, 243, 243, 0.725)] flex justify-center items-center mr-[20px]"> 86 + <div className="h-[30px] w-[30px]"> 87 + <Artist color="rgba(66, 87, 108, 0.65)" /> 88 + </div> 89 + </div> 90 + )} 91 + </Link> 92 + <div> 93 + <Link 94 + to="/$did/artist/$rkey" 95 + params={{ 96 + did: row.uri?.split("at://")[1]?.split("/")[0] || "", 97 + rkey: row.uri?.split("/").pop() || "", 98 + }} 99 + className="no-underline !text-[var(--color-text)]" 100 + > 101 + {row.name} 102 + </Link> 103 + </div> 104 + </div> 105 + )} 106 + </TableBuilderColumn> 107 + </TableBuilder> 108 + </> 109 + ); 110 + } 111 + 112 + export default Weekly;
+3
apps/web/src/pages/charts/weekly/index.tsx
··· 1 + import Weekly from "./Weekly"; 2 + 3 + export default Weekly;
+21
apps/web/src/routeTree.gen.ts
··· 11 11 import { Route as rootRouteImport } from './routes/__root' 12 12 import { Route as ScrobbleRouteImport } from './routes/scrobble' 13 13 import { Route as LoadingRouteImport } from './routes/loading' 14 + import { Route as ChartsRouteImport } from './routes/charts' 14 15 import { Route as ApikeysRouteImport } from './routes/apikeys' 15 16 import { Route as IndexRouteImport } from './routes/index' 16 17 import { Route as GoogledriveIndexRouteImport } from './routes/googledrive/index' ··· 41 42 const LoadingRoute = LoadingRouteImport.update({ 42 43 id: '/loading', 43 44 path: '/loading', 45 + getParentRoute: () => rootRouteImport, 46 + } as any) 47 + const ChartsRoute = ChartsRouteImport.update({ 48 + id: '/charts', 49 + path: '/charts', 44 50 getParentRoute: () => rootRouteImport, 45 51 } as any) 46 52 const ApikeysRoute = ApikeysRouteImport.update({ ··· 152 158 export interface FileRoutesByFullPath { 153 159 '/': typeof IndexRoute 154 160 '/apikeys': typeof ApikeysRoute 161 + '/charts': typeof ChartsRoute 155 162 '/loading': typeof LoadingRoute 156 163 '/scrobble': typeof ScrobbleRoute 157 164 '/dropbox/$id': typeof DropboxIdRoute ··· 177 184 export interface FileRoutesByTo { 178 185 '/': typeof IndexRoute 179 186 '/apikeys': typeof ApikeysRoute 187 + '/charts': typeof ChartsRoute 180 188 '/loading': typeof LoadingRoute 181 189 '/scrobble': typeof ScrobbleRoute 182 190 '/dropbox/$id': typeof DropboxIdRoute ··· 203 211 __root__: typeof rootRouteImport 204 212 '/': typeof IndexRoute 205 213 '/apikeys': typeof ApikeysRoute 214 + '/charts': typeof ChartsRoute 206 215 '/loading': typeof LoadingRoute 207 216 '/scrobble': typeof ScrobbleRoute 208 217 '/dropbox/$id': typeof DropboxIdRoute ··· 230 239 fullPaths: 231 240 | '/' 232 241 | '/apikeys' 242 + | '/charts' 233 243 | '/loading' 234 244 | '/scrobble' 235 245 | '/dropbox/$id' ··· 255 265 to: 256 266 | '/' 257 267 | '/apikeys' 268 + | '/charts' 258 269 | '/loading' 259 270 | '/scrobble' 260 271 | '/dropbox/$id' ··· 280 291 | '__root__' 281 292 | '/' 282 293 | '/apikeys' 294 + | '/charts' 283 295 | '/loading' 284 296 | '/scrobble' 285 297 | '/dropbox/$id' ··· 306 318 export interface RootRouteChildren { 307 319 IndexRoute: typeof IndexRoute 308 320 ApikeysRoute: typeof ApikeysRoute 321 + ChartsRoute: typeof ChartsRoute 309 322 LoadingRoute: typeof LoadingRoute 310 323 ScrobbleRoute: typeof ScrobbleRoute 311 324 DropboxIdRoute: typeof DropboxIdRoute ··· 343 356 path: '/loading' 344 357 fullPath: '/loading' 345 358 preLoaderRoute: typeof LoadingRouteImport 359 + parentRoute: typeof rootRouteImport 360 + } 361 + '/charts': { 362 + id: '/charts' 363 + path: '/charts' 364 + fullPath: '/charts' 365 + preLoaderRoute: typeof ChartsRouteImport 346 366 parentRoute: typeof rootRouteImport 347 367 } 348 368 '/apikeys': { ··· 498 518 const rootRouteChildren: RootRouteChildren = { 499 519 IndexRoute: IndexRoute, 500 520 ApikeysRoute: ApikeysRoute, 521 + ChartsRoute: ChartsRoute, 501 522 LoadingRoute: LoadingRoute, 502 523 ScrobbleRoute: ScrobbleRoute, 503 524 DropboxIdRoute: DropboxIdRoute,
+6
apps/web/src/routes/charts.tsx
··· 1 + import { createFileRoute } from "@tanstack/react-router"; 2 + import ChartsPage from "../pages/charts"; 3 + 4 + export const Route = createFileRoute("/charts")({ 5 + component: ChartsPage, 6 + });