A decentralized music tracking and discovery platform built on AT Protocol 🎵 rocksky.app
spotify atproto lastfm musicbrainz scrobbling listenbrainz
97
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add API client and types; rename Android package

Switch API usage to a central axios client and app.rocksky xrpc
endpoints. Add many API modules (dropbox, googledrive, graph,
apikeys, beta, likes, shouts, etc.) and corresponding TypeScript
types (Album, Artist, Track, Profile, Scrobble, ApiKey, etc.). Update
hooks and UI to use new response fields (createdAt, albumArt,
albumUri, etc.). Add eas.json and EAS projectId; update Gradle/Kotlin
package and applicationId to app.rocksky.

+1096 -150
+2 -2
apps/app/android/app/build.gradle
··· 87 87 buildToolsVersion rootProject.ext.buildToolsVersion 88 88 compileSdk rootProject.ext.compileSdkVersion 89 89 90 - namespace 'com.anonymous.rocksky' 90 + namespace 'app.rocksky' 91 91 defaultConfig { 92 - applicationId 'com.anonymous.rocksky' 92 + applicationId 'app.rocksky' 93 93 minSdkVersion rootProject.ext.minSdkVersion 94 94 targetSdkVersion rootProject.ext.targetSdkVersion 95 95 versionCode 1
+1 -1
apps/app/android/app/src/main/java/com/anonymous/rocksky/MainActivity.kt apps/app/android/app/src/main/java/app/rocksky/MainActivity.kt
··· 1 - package com.anonymous.rocksky 1 + package app.rocksky 2 2 import expo.modules.splashscreen.SplashScreenManager 3 3 4 4 import android.os.Build
+1 -1
apps/app/android/app/src/main/java/com/anonymous/rocksky/MainApplication.kt apps/app/android/app/src/main/java/app/rocksky/MainApplication.kt
··· 1 - package com.anonymous.rocksky 1 + package app.rocksky 2 2 3 3 import android.app.Application 4 4 import android.content.res.Configuration
+4 -1
apps/app/app.config.js
··· 9 9 newArchEnabled: true, 10 10 extra: { 11 11 storybookEnabled: process.env.STORYBOOK_ENABLED, 12 + eas: { 13 + projectId: "b11aecfd-7217-4707-b0a8-6ad121c51bd4" 14 + } 12 15 }, 13 16 ios: { 14 17 supportsTablet: true, ··· 19 22 backgroundColor: "#ffffff", 20 23 }, 21 24 edgeToEdgeEnabled: true, 22 - package: "com.anonymous.rocksky", 25 + package: "app.rocksky", 23 26 }, 24 27 web: { 25 28 bundler: "metro",
+1
apps/app/bun.lock
··· 1 1 { 2 2 "lockfileVersion": 1, 3 + "configVersion": 0, 3 4 "workspaces": { 4 5 "": { 5 6 "name": "rocksky",
+9
apps/app/eas.json
··· 1 + { 2 + "build": { 3 + "production": { 4 + "android": { 5 + "buildType": "app-bundle" 6 + } 7 + } 8 + } 9 + }
+44
apps/app/src/api/apikeys.ts
··· 1 + import axios from "axios"; 2 + import { API_URL } from "../consts"; 3 + import { ApiKey } from "../types/apikey"; 4 + 5 + const getHeaders = () => ({ 6 + authorization: `Bearer ${localStorage.getItem("token")}`, 7 + }); 8 + 9 + export const createApiKey = async (name: string, description?: string) => { 10 + return await axios.post<ApiKey>( 11 + `${API_URL}/apikeys`, 12 + { name, description }, 13 + { headers: getHeaders() }, 14 + ); 15 + }; 16 + 17 + export const getApiKeys = async (offset = 0, size = 20) => { 18 + return await axios.get<ApiKey[]>(`${API_URL}/apikeys`, { 19 + headers: getHeaders(), 20 + params: { 21 + offset, 22 + size, 23 + }, 24 + }); 25 + }; 26 + 27 + export const deleteApiKey = async (id: string) => { 28 + return await axios.delete(`${API_URL}/apikeys/${id}`, { 29 + headers: getHeaders(), 30 + }); 31 + }; 32 + 33 + export const updateApiKey = async ( 34 + id: string, 35 + enabled: boolean, 36 + name?: string, 37 + description?: string, 38 + ) => { 39 + return await axios.put<ApiKey>( 40 + `${API_URL}/apikeys/${id}`, 41 + { name, description, enabled }, 42 + { headers: getHeaders() }, 43 + ); 44 + };
+31
apps/app/src/api/beta.ts
··· 1 + import axios from "axios"; 2 + import { API_URL } from "../consts"; 3 + 4 + const getHeaders = () => ({ 5 + authorization: `Bearer ${localStorage.getItem("token")}`, 6 + }); 7 + 8 + export const joinBeta = async (email: string, platform: string) => { 9 + switch (platform) { 10 + case "spotify": 11 + return await axios.post( 12 + `${API_URL}/spotify/join`, 13 + { email }, 14 + { headers: getHeaders() }, 15 + ); 16 + case "google": 17 + return await axios.post( 18 + `${API_URL}/googledrive/join`, 19 + { email }, 20 + { headers: getHeaders() }, 21 + ); 22 + case "dropbox": 23 + return await axios.post( 24 + `${API_URL}/dropbox/join`, 25 + { email }, 26 + { headers: getHeaders() }, 27 + ); 28 + default: 29 + return; 30 + } 31 + };
+24 -10
apps/app/src/api/charts.ts
··· 1 - import axios from "axios"; 2 - import { API_URL } from "../consts"; 1 + import { client } from "."; 3 2 4 3 export const getScrobblesChart = () => { 5 4 return []; 6 5 }; 7 6 8 7 export const getSongChart = async (uri: string) => { 9 - const response = await axios.get( 10 - `${API_URL}/public/scrobbleschart?songuri=${uri}`, 8 + const response = await client.get( 9 + "/xrpc/app.rocksky.charts.getScrobblesChart", 10 + { params: { songuri: uri } }, 11 11 ); 12 12 if (response.status !== 200) { 13 13 return []; ··· 16 16 }; 17 17 18 18 export const getArtistChart = async (uri: string) => { 19 - const response = await axios.get( 20 - `${API_URL}/public/scrobbleschart?artisturi=${uri}`, 19 + const response = await client.get( 20 + "/xrpc/app.rocksky.charts.getScrobblesChart", 21 + { params: { artisturi: uri } }, 21 22 ); 22 23 if (response.status !== 200) { 23 24 return []; ··· 26 27 }; 27 28 28 29 export const getAlbumChart = async (uri: string) => { 29 - const response = await axios.get( 30 - `${API_URL}/public/scrobbleschart?albumuri=${uri}`, 30 + const response = await client.get( 31 + "/xrpc/app.rocksky.charts.getScrobblesChart", 32 + { params: { albumuri: uri } }, 31 33 ); 32 34 if (response.status !== 200) { 33 35 return []; ··· 36 38 }; 37 39 38 40 export const getProfileChart = async (did: string) => { 39 - const response = await axios.get( 40 - `${API_URL}/public/scrobbleschart?did=${did}`, 41 + const response = await client.get( 42 + "/xrpc/app.rocksky.charts.getScrobblesChart", 43 + { params: { did } }, 44 + ); 45 + if (response.status !== 200) { 46 + return []; 47 + } 48 + return response.data; 49 + }; 50 + 51 + export const getGenreChart = async (genre: string) => { 52 + const response = await client.get( 53 + "/xrpc/app.rocksky.charts.getScrobblesChart", 54 + { params: { genre } }, 41 55 ); 42 56 if (response.status !== 200) { 43 57 return [];
+73
apps/app/src/api/dropbox.ts
··· 1 + import axios from "axios"; 2 + import { API_URL } from "../consts"; 3 + import { client } from "."; 4 + 5 + export const getFiles = async (id?: string) => { 6 + const response = await client.get<{ 7 + parentDirectory: { 8 + id: string; 9 + name: string; 10 + path: string; 11 + fileId: string; 12 + }; 13 + directory: { 14 + id: string; 15 + name: string; 16 + path: string; 17 + fileId: string; 18 + }; 19 + directories: { 20 + id: string; 21 + name: string; 22 + fileId: string; 23 + path: string; 24 + parentId?: string; 25 + }[]; 26 + files: { 27 + id: string; 28 + name: string; 29 + fileId: string; 30 + directoryId: string; 31 + trackId: string; 32 + }[]; 33 + }>("/xrpc/app.rocksky.dropbox.getFiles", { 34 + headers: { 35 + Authorization: `Bearer ${localStorage.getItem("token")}`, 36 + }, 37 + params: { 38 + at: id, 39 + }, 40 + }); 41 + return response.data; 42 + }; 43 + 44 + export const getFile = async (id: string) => { 45 + const response = await client.get<{ 46 + ".tag": string; 47 + id: string; 48 + name: string; 49 + path_display: string; 50 + }>("/xrpc/app.rocksky.dropbox.getFiles", { 51 + headers: { 52 + Authorization: `Bearer ${localStorage.getItem("token")}`, 53 + }, 54 + params: { 55 + path: id, 56 + }, 57 + }); 58 + return response.data; 59 + }; 60 + 61 + export const getTemporaryLink = async (id: string) => { 62 + const response = await axios.get<{ 63 + link: string; 64 + }>(`${API_URL}/dropbox/temporary-link`, { 65 + headers: { 66 + Authorization: `Bearer ${localStorage.getItem("token")}`, 67 + }, 68 + params: { 69 + path: id, 70 + }, 71 + }); 72 + return response.data; 73 + };
+171 -22
apps/app/src/api/feed.ts
··· 1 - import axios from "axios"; 2 - import { API_URL } from "../consts"; 1 + import { client } from "."; 2 + 3 + export const getScrobbleByUri = async (uri: string) => { 4 + if (uri.includes("app.rocksky.song")) { 5 + return null; 6 + } 7 + const response = await client.get("/xrpc/app.rocksky.scrobble.getScrobble", { 8 + params: { uri }, 9 + }); 10 + 11 + if (response.status !== 200) { 12 + return null; 13 + } 14 + 15 + return { 16 + id: response.data?.id, 17 + title: response.data?.title, 18 + artist: response.data?.artist, 19 + albumArtist: response.data?.albumArtist, 20 + album: response.data?.album, 21 + cover: response.data?.cover, 22 + tags: response.data?.tags, 23 + artistUri: response.data?.artistUri, 24 + albumUri: response.data?.albumUri, 25 + listeners: response.data?.listeners || 1, 26 + scrobbles: response.data?.scrobbles || 1, 27 + lyrics: response.data?.lyrics, 28 + spotifyLink: response.data?.spotifyLink, 29 + composer: response.data?.composer, 30 + uri: response.data?.uri, 31 + artists: response.data?.artists, 32 + }; 33 + }; 34 + 35 + export const getFeedGenerators = async () => { 36 + const response = await client.get<{ 37 + feeds: { 38 + id: string; 39 + name: string; 40 + uri: string; 41 + description: string; 42 + did: string; 43 + avatar?: string; 44 + creator: { 45 + avatar?: string; 46 + displayName: string; 47 + handle: string; 48 + did: string; 49 + id: string; 50 + }; 51 + }[]; 52 + }>("/xrpc/app.rocksky.feed.getFeedGenerators"); 53 + if (response.status !== 200) { 54 + return null; 55 + } 56 + return response.data; 57 + }; 58 + 59 + export const getFeed = async (uri: string, limit?: number, cursor?: string) => { 60 + const response = await client.get<{ 61 + feed: { 62 + scrobble: { 63 + title: string; 64 + artist: string; 65 + albumArtist: string; 66 + album: string; 67 + trackNumber: number; 68 + duration: number; 69 + mbId: string | null; 70 + youtubeLink: string | null; 71 + spotifyLink: string | null; 72 + appleMusicLink: string | null; 73 + tidalLink: string | null; 74 + sha256: string; 75 + discNumber: number; 76 + composer: string | null; 77 + genre: string | null; 78 + label: string | null; 79 + copyrightMessage: string | null; 80 + uri: string; 81 + albumUri: string; 82 + artistUri: string; 83 + trackUri: string; 84 + xataVersion: number; 85 + cover: string; 86 + date: string; 87 + user: string; 88 + userDisplayName: string; 89 + userAvatar: string; 90 + tags: string[]; 91 + likesCount: number; 92 + liked: boolean; 93 + id: string; 94 + }; 95 + }[]; 96 + cursor?: string; 97 + }>("/xrpc/app.rocksky.feed.getFeed", { 98 + params: { 99 + feed: uri, 100 + limit, 101 + cursor, 102 + }, 103 + headers: { 104 + Authorization: localStorage.getItem("token") 105 + ? `Bearer ${localStorage.getItem("token")}` 106 + : undefined, 107 + }, 108 + }); 3 109 4 - export const getFeed = () => { 5 - return []; 110 + if (response.status !== 200) { 111 + return { songs: [], cursor: undefined }; 112 + } 113 + 114 + return { 115 + songs: response.data.feed.map(({ scrobble }) => scrobble), 116 + cursor: response.data.cursor, 117 + }; 6 118 }; 7 119 8 - export const getFeedByUri = async (uri: string) => { 9 - const response = await axios.get(`${API_URL}/users/${uri}`); 120 + export const getScrobbles = async ( 121 + did: string, 122 + following: boolean = false, 123 + offset: number = 0, 124 + limit: number = 50, 125 + ) => { 126 + const response = await client.get<{ 127 + scrobbles: { 128 + title: string; 129 + artist: string; 130 + albumArtist: string; 131 + album: string; 132 + trackNumber: number; 133 + duration: number; 134 + mbId: string | null; 135 + youtubeLink: string | null; 136 + spotifyLink: string | null; 137 + appleMusicLink: string | null; 138 + tidalLink: string | null; 139 + sha256: string; 140 + discNumber: number; 141 + composer: string | null; 142 + genre: string | null; 143 + label: string | null; 144 + copyrightMessage: string | null; 145 + uri: string; 146 + albumUri: string; 147 + artistUri: string; 148 + trackUri: string; 149 + xataVersion: number; 150 + cover: string; 151 + date: string; 152 + user: string; 153 + userDisplayName: string; 154 + userAvatar: string; 155 + tags: string[]; 156 + likesCount: number; 157 + liked: boolean; 158 + id: string; 159 + }[]; 160 + }>("/xrpc/app.rocksky.scrobble.getScrobbles", { 161 + params: { 162 + did, 163 + following, 164 + offset, 165 + limit, 166 + }, 167 + headers: { 168 + Authorization: localStorage.getItem("token") 169 + ? `Bearer ${localStorage.getItem("token")}` 170 + : undefined, 171 + }, 172 + }); 10 173 11 174 if (response.status !== 200) { 12 - return null; 175 + return { scrobbles: [] }; 13 176 } 14 177 15 178 return { 16 - id: response.data.track_id?.xata_id, 17 - title: response.data.track_id?.title, 18 - artist: response.data.track_id?.artist, 19 - albumArtist: response.data.track_id?.album_artist, 20 - album: response.data.track_id?.album, 21 - cover: response.data.track_id?.album_art, 22 - tags: [], 23 - artistUri: response.data.track_id?.artist_uri, 24 - albumUri: response.data.track_id?.album_uri, 25 - listeners: response.data.listeners || 1, 26 - scrobbles: response.data.scrobbles || 1, 27 - lyrics: response.data.track_id?.lyrics, 28 - spotifyLink: response.data.track_id?.spotify_link, 29 - composer: response.data.track_id?.composer, 30 - uri: response.data.track_id?.uri, 179 + scrobbles: response.data.scrobbles, 31 180 }; 32 181 };
+57
apps/app/src/api/googledrive.ts
··· 1 + import { client } from "."; 2 + 3 + export const getFiles = async (parent_id?: string) => { 4 + const response = await client.get<{ 5 + parentDirectory: { 6 + id: string; 7 + name: string; 8 + path: string; 9 + fileId: string; 10 + }; 11 + directory: { 12 + id: string; 13 + name: string; 14 + path: string; 15 + fileId: string; 16 + }; 17 + directories: { 18 + id: string; 19 + name: string; 20 + fileId: string; 21 + path: string; 22 + parentId?: string; 23 + }[]; 24 + files: { 25 + id: string; 26 + name: string; 27 + fileId: string; 28 + directoryId: string; 29 + trackId: string; 30 + }[]; 31 + }>("/xrpc/app.rocksky.googledrive.getFiles", { 32 + headers: { 33 + Authorization: `Bearer ${localStorage.getItem("token")}`, 34 + }, 35 + params: { 36 + at: parent_id, 37 + }, 38 + }); 39 + return response.data; 40 + }; 41 + 42 + export const getFile = async (id: string) => { 43 + const response = await client.get<{ 44 + id: string; 45 + mimeType: string; 46 + name: string; 47 + parents: string[]; 48 + }>("/xrpc/app.rocksky.googledrive.getFile", { 49 + headers: { 50 + Authorization: `Bearer ${localStorage.getItem("token")}`, 51 + }, 52 + params: { 53 + id, 54 + }, 55 + }); 56 + return response.data; 57 + };
+72
apps/app/src/api/graph.ts
··· 1 + import { client } from "."; 2 + 3 + export const getFollows = async ( 4 + actor: string, 5 + limit: number, 6 + dids?: string[], 7 + cursor?: string, 8 + ) => { 9 + const response = await client.get("/xrpc/app.rocksky.graph.getFollows", { 10 + params: { actor, limit: limit > 0 ? limit : 1, dids, cursor }, 11 + }); 12 + 13 + return response.data; 14 + }; 15 + 16 + export const getKnownFollowers = async ( 17 + actor: string, 18 + limit: number, 19 + cursor?: string, 20 + ) => { 21 + const response = await client.get( 22 + "/xrpc/app.rocksky.graph.getKnownFollowers", 23 + { 24 + params: { actor, limit: limit > 0 ? limit : 1, cursor }, 25 + }, 26 + ); 27 + 28 + return response.data; 29 + }; 30 + 31 + export const getFollowers = async ( 32 + actor: string, 33 + limit: number, 34 + dids?: string[], 35 + cursor?: string, 36 + ) => { 37 + const response = await client.get("/xrpc/app.rocksky.graph.getFollowers", { 38 + params: { actor, limit: limit > 0 ? limit : 1, dids, cursor }, 39 + }); 40 + 41 + return response.data; 42 + }; 43 + 44 + export const followAccount = async (account: string) => { 45 + const response = await client.post( 46 + "/xrpc/app.rocksky.graph.followAccount", 47 + undefined, 48 + { 49 + params: { account }, 50 + headers: { 51 + Authorization: `Bearer ${localStorage.getItem("token")}`, 52 + }, 53 + }, 54 + ); 55 + 56 + return response.data; 57 + }; 58 + 59 + export const unfollowAccount = async (account: string) => { 60 + const response = await client.post( 61 + "/xrpc/app.rocksky.graph.unfollowAccount", 62 + undefined, 63 + { 64 + params: { account }, 65 + headers: { 66 + Authorization: `Bearer ${localStorage.getItem("token")}`, 67 + }, 68 + }, 69 + ); 70 + 71 + return response.data; 72 + };
+6
apps/app/src/api/index.ts
··· 1 + import axios from "axios"; 2 + import { API_URL } from "../consts"; 3 + 4 + export const client = axios.create({ 5 + baseURL: API_URL, 6 + });
+199 -50
apps/app/src/api/library.ts
··· 1 - import axios from "axios"; 2 - import { API_URL } from "../consts"; 1 + import { client } from "."; 2 + import { Album } from "../types/album"; 3 + import { Artist } from "../types/artist"; 4 + import { Track } from "../types/track"; 3 5 4 6 export const getSongByUri = async (uri: string) => { 5 - const response = await axios.get(`${API_URL}/users/${uri}`); 7 + if (uri.includes("app.rocksky.scrobble")) { 8 + return null; 9 + } 10 + 11 + const response = await client.get("/xrpc/app.rocksky.song.getSong", { 12 + params: { uri }, 13 + }); 6 14 return { 7 15 id: response.data?.id, 8 16 title: response.data?.title, 9 17 artist: response.data?.artist, 10 - albumArtist: response.data?.album_artist, 18 + albumArtist: response.data?.albumArtist, 11 19 album: response.data?.album, 12 - cover: response.data?.album_art, 13 - tags: [], 14 - artistUri: response.data?.artist_uri, 15 - albumUri: response.data?.album_uri, 16 - listeners: response.data?.listeners || 1, 17 - scrobbles: response.data?.scrobbles || 1, 20 + cover: response.data?.albumArt, 21 + tags: response.data?.tags, 22 + artistUri: response.data?.artistUri, 23 + albumUri: response.data?.albumUri, 24 + listeners: response.data?.uniqueListeners || 1, 25 + scrobbles: response.data?.playCount || 1, 18 26 lyrics: response.data?.lyrics, 19 - spotifyLink: response.data?.spotify_link, 27 + spotifyLink: response.data?.spotifyLink, 20 28 composer: response.data?.composer, 21 29 uri: response.data?.uri, 30 + artists: response.data?.artists, 22 31 }; 23 32 }; 24 33 ··· 30 39 id: string; 31 40 title: string; 32 41 artist: string; 33 - album_artist: string; 34 - album_art: string; 42 + albumArtist: string; 43 + albumArt: string; 35 44 uri: string; 36 - play_count: number; 37 - album_uri?: string; 38 - artist_uri?: string; 45 + playCount: number; 46 + albumUri?: string; 47 + artistUri?: string; 39 48 }[] 40 49 > => { 41 - const response = await axios.get( 42 - `${API_URL}/users/${uri}/tracks?size=${limit}`, 50 + const response = await client.get( 51 + "/xrpc/app.rocksky.artist.getArtistTracks", 52 + { params: { uri, limit } }, 43 53 ); 44 - return response.data; 54 + return response.data.tracks; 45 55 }; 46 56 47 57 export const getArtistAlbums = async ( ··· 52 62 id: string; 53 63 title: string; 54 64 artist: string; 55 - album_art: string; 56 - artist_uri: string; 65 + albumArt: string; 66 + artistUri: string; 57 67 uri: string; 58 68 }[] 59 69 > => { 60 - const response = await axios.get( 61 - `${API_URL}/users/${uri}/albums?size=${limit}`, 70 + const response = await client.get( 71 + "/xrpc/app.rocksky.artist.getArtistAlbums", 72 + { params: { uri, limit } }, 73 + ); 74 + return response.data.albums; 75 + }; 76 + 77 + export const getArtists = async ( 78 + did: string, 79 + offset = 0, 80 + limit = 30, 81 + startDate?: Date, 82 + endDate?: Date, 83 + ) => { 84 + const response = await client.get<{ artists: Artist[] }>( 85 + "/xrpc/app.rocksky.actor.getActorArtists", 86 + { 87 + params: { 88 + did, 89 + limit, 90 + offset, 91 + startDate: startDate?.toISOString(), 92 + endDate: endDate?.toISOString(), 93 + }, 94 + }, 95 + ); 96 + return response.data; 97 + }; 98 + 99 + export const getAlbums = async ( 100 + did: string, 101 + offset = 0, 102 + limit = 12, 103 + startDate?: Date, 104 + endDate?: Date, 105 + ) => { 106 + const response = await client.get("/xrpc/app.rocksky.actor.getActorAlbums", { 107 + params: { 108 + did, 109 + limit, 110 + offset, 111 + startDate: startDate?.toISOString(), 112 + endDate: endDate?.toISOString(), 113 + }, 114 + }); 115 + return response.data; 116 + }; 117 + 118 + export const getTracks = async ( 119 + did: string, 120 + offset = 0, 121 + limit = 20, 122 + startDate?: Date, 123 + endDate?: Date, 124 + ) => { 125 + const response = await client.get("/xrpc/app.rocksky.actor.getActorSongs", { 126 + params: { 127 + did, 128 + limit, 129 + offset, 130 + startDate: startDate?.toISOString(), 131 + endDate: endDate?.toISOString(), 132 + }, 133 + }); 134 + return response.data; 135 + }; 136 + 137 + export const getLovedTracks = async (did: string, offset = 0, limit = 20) => { 138 + const response = await client.get( 139 + "/xrpc/app.rocksky.actor.getActorLovedSongs", 140 + { 141 + params: { did, limit, offset }, 142 + }, 62 143 ); 144 + return response.data.tracks; 145 + }; 146 + 147 + export const getAlbum = async (did: string, rkey: string) => { 148 + const response = await client.get("/xrpc/app.rocksky.album.getAlbum", { 149 + params: { uri: `at://${did}/app.rocksky.album/${rkey}` }, 150 + }); 151 + return response.data; 152 + }; 153 + 154 + export const getArtist = async (did: string, rkey: string) => { 155 + const response = await client.get("/xrpc/app.rocksky.artist.getArtist", { 156 + params: { uri: `at://${did}/app.rocksky.artist/${rkey}` }, 157 + }); 63 158 return response.data; 64 159 }; 65 160 66 - export const getArtists = async (did: string, offset = 0, limit = 30) => { 67 - const response = await axios.get( 68 - `${API_URL}/users/${did}/artists?size=${limit}&offset=${offset}`, 161 + export const getArtistListeners = async (uri: string, limit: number) => { 162 + const response = await client.get( 163 + "/xrpc/app.rocksky.artist.getArtistListeners", 164 + { params: { uri, limit } }, 69 165 ); 70 - // eslint-disable-next-line @typescript-eslint/no-explicit-any 71 - return response.data.map((x: any) => ({ ...x, scrobbles: x.play_count })); 166 + return response.data; 72 167 }; 73 168 74 - export const getAlbums = async (did: string, offset = 0, limit = 12) => { 75 - const response = await axios.get( 76 - `${API_URL}/users/${did}/albums?size=${limit}&offset=${offset}`, 169 + export const getAlbumsByGenre = async ( 170 + genre: string, 171 + offset = 0, 172 + limit = 20, 173 + ) => { 174 + const response = await client.get<{ albums: Album[] }>( 175 + "/xrpc/app.rocksky.album.getAlbums", 176 + { 177 + params: { 178 + genre, 179 + limit, 180 + offset, 181 + }, 182 + }, 77 183 ); 78 - // eslint-disable-next-line @typescript-eslint/no-explicit-any 79 - return response.data.map((x: any) => ({ 80 - ...x, 81 - scrobbles: x.play_count, 82 - })); 184 + return response.data; 83 185 }; 84 186 85 - export const getTracks = async (did: string, offset = 0, limit = 20) => { 86 - const response = await axios.get( 87 - `${API_URL}/users/${did}/tracks?size=${limit}&offset=${offset}`, 187 + export const getArtistsByGenre = async ( 188 + genre: string, 189 + offset = 0, 190 + limit = 20, 191 + ) => { 192 + const response = await client.get<{ artists: Artist[] }>( 193 + "/xrpc/app.rocksky.artist.getArtists", 194 + { 195 + params: { 196 + genre, 197 + limit, 198 + offset, 199 + }, 200 + }, 88 201 ); 89 - // eslint-disable-next-line @typescript-eslint/no-explicit-any 90 - return response.data.map((x: any) => ({ ...x, scrobbles: x.play_count })); 202 + return response.data; 91 203 }; 92 204 93 - export const getLovedTracks = async (did: string, offset = 0, limit = 20) => { 94 - const response = await axios.get( 95 - `${API_URL}/users/${did}/likes?size=${limit}&offset=${offset}`, 205 + export const getTracksByGenre = async ( 206 + genre: string, 207 + offset = 0, 208 + limit = 20, 209 + ) => { 210 + const response = await client.get<{ tracks: Track[] }>( 211 + "/xrpc/app.rocksky.song.getSongs", 212 + { 213 + params: { 214 + genre, 215 + limit, 216 + offset, 217 + }, 218 + }, 96 219 ); 97 220 return response.data; 98 221 }; 99 222 100 - export const getAlbum = async (did: string, rkey: string) => { 101 - const response = await axios.get( 102 - `${API_URL}/users/${did}/app.rocksky.album/${rkey}`, 223 + export const getTopArtists = async ( 224 + offset = 0, 225 + limit = 20, 226 + startDate?: Date, 227 + endDate?: Date, 228 + ) => { 229 + const response = await client.get<{ artists: Artist[] }>( 230 + "/xrpc/app.rocksky.charts.getTopArtists", 231 + { 232 + params: { 233 + limit, 234 + offset, 235 + startDate: startDate?.toISOString(), 236 + endDate: endDate?.toISOString(), 237 + }, 238 + }, 103 239 ); 104 240 return response.data; 105 241 }; 106 242 107 - export const getArtist = async (did: string, rkey: string) => { 108 - const response = await axios.get( 109 - `${API_URL}/users/${did}/app.rocksky.artist/${rkey}`, 243 + export const getTopTracks = async ( 244 + offset = 0, 245 + limit = 20, 246 + startDate?: Date, 247 + endDate?: Date, 248 + ) => { 249 + const response = await client.get<{ tracks: Track[] }>( 250 + "/xrpc/app.rocksky.charts.getTopTracks", 251 + { 252 + params: { 253 + limit, 254 + offset, 255 + startDate: startDate?.toISOString(), 256 + endDate: endDate?.toISOString(), 257 + }, 258 + }, 110 259 ); 111 260 return response.data; 112 261 };
+35
apps/app/src/api/likes.ts
··· 1 + import axios from "axios"; 2 + import { API_URL } from "../consts"; 3 + 4 + export const like = async (uri: string) => { 5 + const response = await axios.post( 6 + `${API_URL}/users/${uri.replace("at://", "")}/likes`, 7 + {}, 8 + { 9 + headers: { 10 + "Content-Type": "application/json", 11 + Authorization: `Bearer ${localStorage.getItem("token")}`, 12 + }, 13 + }, 14 + ); 15 + return response.data; 16 + }; 17 + 18 + export const unlike = async (uri: string) => { 19 + const response = await axios.delete( 20 + `${API_URL}/users/${uri.replace("at://", "")}/likes`, 21 + { 22 + headers: { 23 + Authorization: `Bearer ${localStorage.getItem("token")}`, 24 + }, 25 + }, 26 + ); 27 + return response.data; 28 + }; 29 + 30 + export const getLikes = async (uri: string) => { 31 + const response = await axios.get( 32 + `${API_URL}/users/${uri.replace("at://", "")}/likes`, 33 + ); 34 + return response.data; 35 + };
+13 -7
apps/app/src/api/playlists.ts
··· 1 - import axios from "axios"; 2 - import { API_URL } from "../consts"; 1 + import { client } from "."; 3 2 4 3 export const getPlaylists = async ( 5 4 did: string, ··· 16 15 trackCount: number; 17 16 }[] 18 17 > => { 19 - const response = await axios.get(`${API_URL}/users/${did}/playlists`); 20 - return response.data; 18 + const response = await client.get( 19 + "/xrpc/app.rocksky.actor.getActorPlaylists", 20 + { 21 + params: { did }, 22 + }, 23 + ); 24 + return response.data.playlists; 21 25 }; 22 26 23 27 export const getPlaylist = async ( ··· 56 60 discNumber: number; 57 61 }[]; 58 62 }> => { 59 - const response = await axios.get( 60 - `${API_URL}/users/${did}/app.rocksky.playlist/${rkey}`, 61 - ); 63 + const response = await client.get("/xrpc/app.rocksky.playlist.getPlaylist", { 64 + params: { 65 + uri: `at://${did}/app.rocksky.playlist/${rkey}`, 66 + }, 67 + }); 62 68 return response.data; 63 69 };
+41 -6
apps/app/src/api/profile.ts
··· 1 - import axios from "axios"; 2 - import { API_URL } from "../consts"; 1 + import { client } from "."; 2 + import { Compatibility } from "../types/compatibility"; 3 + import { Neighbour } from "../types/neighbour"; 4 + import { Profile } from "../types/profile"; 3 5 import { Scrobble } from "../types/scrobble"; 4 6 5 7 export const getProfileByDid = async (did: string) => { 6 - const response = await axios.get(`${API_URL}/users/${did}`); 8 + const response = await client.get<Profile>( 9 + "/xrpc/app.rocksky.actor.getProfile", 10 + { 11 + params: { did }, 12 + }, 13 + ); 7 14 return response.data; 8 15 }; 9 16 10 17 export const getProfileStatsByDid = async (did: string) => { 11 - const response = await axios.get(`${API_URL}/users/${did}/stats`); 18 + const response = await client.get("/xrpc/app.rocksky.stats.getStats", { 19 + params: { did }, 20 + }); 12 21 return response.data; 13 22 }; 14 23 ··· 17 26 offset = 0, 18 27 size = 10, 19 28 ): Promise<Scrobble[]> => { 20 - const response = await axios.get<Scrobble[]>( 21 - `${API_URL}/users/${did}/scrobbles?size=${size}&offset=${offset}`, 29 + const response = await client.get<{ scrobbles: Scrobble[] }>( 30 + "/xrpc/app.rocksky.actor.getActorScrobbles", 31 + { 32 + params: { did, offset, limit: size }, 33 + }, 34 + ); 35 + return response.data.scrobbles || []; 36 + }; 37 + 38 + export const getActorNeighbours = async (did: string) => { 39 + const response = await client.get<{ neighbours: Neighbour[] }>( 40 + "/xrpc/app.rocksky.actor.getActorNeighbours", 41 + { 42 + params: { did }, 43 + }, 44 + ); 45 + return response.data; 46 + }; 47 + 48 + export const getActorCompatibility = async (did: string) => { 49 + const response = await client.get<{ compatibility: Compatibility | null }>( 50 + "/xrpc/app.rocksky.actor.getActorCompatibility", 51 + { 52 + params: { did }, 53 + headers: { 54 + Authorization: `Bearer ${localStorage.getItem("token")}`, 55 + }, 56 + }, 22 57 ); 23 58 return response.data; 24 59 };
+6 -4
apps/app/src/api/search.ts
··· 1 - import axios from "axios"; 2 - import { API_URL } from "../consts"; 1 + import { client } from "."; 3 2 import { SearchResponse } from "../types/search"; 4 3 5 4 export const search = async (query: string) => { 6 - const response = await axios.get<SearchResponse>( 7 - `${API_URL}/search?q=${query}&size=100`, 5 + const response = await client.get<SearchResponse>( 6 + "/xrpc/app.rocksky.feed.search", 7 + { 8 + params: { query, size: 100 }, 9 + }, 8 10 ); 9 11 return response.data; 10 12 };
+86
apps/app/src/api/shouts.ts
··· 1 + import axios from "axios"; 2 + import { API_URL } from "../consts"; 3 + 4 + export const shout = async (uri: string, message: string) => { 5 + const response = await axios.post( 6 + `${API_URL}/users/${uri.replace("at://", "")}/shouts`, 7 + { message }, 8 + { 9 + headers: { 10 + "Content-Type": "application/json", 11 + Authorization: `Bearer ${localStorage.getItem("token")}`, 12 + }, 13 + }, 14 + ); 15 + return response.data; 16 + }; 17 + 18 + export const getShouts = async (uri: string) => { 19 + const response = await axios.get( 20 + `${API_URL}/users/${uri.replace("at://", "")}/shouts`, 21 + { 22 + headers: { 23 + Authorization: `Bearer ${localStorage.getItem("token")}`, 24 + }, 25 + }, 26 + ); 27 + return response.data; 28 + }; 29 + 30 + export const reply = async (uri: string, message: string) => { 31 + const response = await axios.post( 32 + `${API_URL}/users/${uri.replace("at://", "")}/replies`, 33 + { message }, 34 + { 35 + headers: { 36 + "Content-Type": "application/json", 37 + Authorization: `Bearer ${localStorage.getItem("token")}`, 38 + }, 39 + }, 40 + ); 41 + return response.data; 42 + }; 43 + 44 + export const getReplies = async (uri: string) => { 45 + const response = await axios.get( 46 + `${API_URL}/users/${uri.replace("at://", "")}/replies`, 47 + ); 48 + return response.data; 49 + }; 50 + 51 + export const reportShout = async (uri: string) => { 52 + const response = await axios.post( 53 + `${API_URL}/users/${uri.replace("at://", "")}/report`, 54 + {}, 55 + { 56 + headers: { 57 + Authorization: `Bearer ${localStorage.getItem("token")}`, 58 + }, 59 + }, 60 + ); 61 + return response.data; 62 + }; 63 + 64 + export const deleteShout = async (uri: string) => { 65 + const response = await axios.delete( 66 + `${API_URL}/users/${uri.replace("at://", "")}`, 67 + { 68 + headers: { 69 + Authorization: `Bearer ${localStorage.getItem("token")}`, 70 + }, 71 + }, 72 + ); 73 + return response.data; 74 + }; 75 + 76 + export const cancelReport = async (uri: string) => { 77 + const response = await axios.delete( 78 + `${API_URL}/users/${uri.replace("at://", "")}/report`, 79 + { 80 + headers: { 81 + Authorization: `Bearer ${localStorage.getItem("token")}`, 82 + }, 83 + }, 84 + ); 85 + return response.data; 86 + };
+2 -2
apps/app/src/components/Avatar/AvatarWithData.tsx
··· 26 26 {!isLoading && data && ( 27 27 <Avatar 28 28 avatar={data.avatar} 29 - name={data.display_name} 29 + name={data.displayName} 30 30 handle={`@${data.handle}`} 31 - scrobblingSince={dayjs(data.xata_createdat).format("DD MMM YYYY")} 31 + scrobblingSince={dayjs(data.createdAt).format("DD MMM YYYY")} 32 32 did={data.did} 33 33 onOpenBlueskyProfile={(handle: string) => { 34 34 Linking.openURL(
+44 -15
apps/app/src/hooks/useFeed.tsx
··· 1 1 import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; 2 - import { getFeedByUri } from "../api/feed"; 3 - import { API_URL } from "../consts"; 2 + import { getScrobbleByUri, getFeed, getScrobbles } from "../api/feed"; 3 + import { client } from "../api"; 4 4 5 5 export const useFeedQuery = (size = 114) => 6 6 useQuery({ 7 7 queryKey: ["feed"], 8 8 queryFn: () => 9 - fetch(`${API_URL}/public/scrobbles?size=${size}`, { 10 - method: "GET", 11 - }).then((res) => res.json()), 9 + client 10 + .get(`/xrpc/app.rocksky.scrobble.getScrobbles`, { 11 + params: { limit: size }, 12 + }) 13 + .then((res) => res.data), 12 14 refetchInterval: 5000, 13 15 placeholderData: (prev) => prev, 14 16 }); ··· 17 19 useInfiniteQuery({ 18 20 queryKey: ["infiniteFeed"], 19 21 queryFn: async ({ pageParam = 0 }) => { 20 - const data = await fetch( 21 - `${API_URL}/public/scrobbles?size=${size}&offset=${pageParam * size}`, 22 - { 23 - method: "GET", 24 - }, 25 - ).then((res) => res.json()); 22 + const data = await client 23 + .get(`/xrpc/app.rocksky.scrobble.getScrobbles`, { 24 + params: { limit: size, offset: pageParam * size }, 25 + }) 26 + .then((res) => res.data); 26 27 return { 27 - feed: data, 28 + feed: data.scrobbles ?? data, 28 29 nextOffset: pageParam + 1, 29 30 }; 30 31 }, ··· 35 36 refetchOnMount: false, 36 37 }); 37 38 38 - export const useFeedByUriQuery = (uri: string) => 39 + export const useScrobbleByUriQuery = (uri: string) => 40 + useQuery({ 41 + queryKey: ["scrobble", uri], 42 + queryFn: () => getScrobbleByUri(uri), 43 + enabled: !!uri, 44 + }); 45 + 46 + /** @deprecated Use useScrobbleByUriQuery instead */ 47 + export const useFeedByUriQuery = useScrobbleByUriQuery; 48 + 49 + export const useFeedGeneratorQuery = ( 50 + uri: string, 51 + limit?: number, 52 + cursor?: string, 53 + ) => 54 + useQuery({ 55 + queryKey: ["feedGenerator", uri, limit, cursor], 56 + queryFn: () => getFeed(uri, limit, cursor), 57 + enabled: !!uri, 58 + }); 59 + 60 + export const useScrobblesQuery = ( 61 + did: string, 62 + following = false, 63 + offset = 0, 64 + limit = 50, 65 + ) => 39 66 useQuery({ 40 - queryKey: ["feed", uri], 41 - queryFn: () => getFeedByUri(uri), 67 + queryKey: ["scrobbles", did, following, offset, limit], 68 + queryFn: () => getScrobbles(did, following, offset, limit), 69 + enabled: !!did, 70 + placeholderData: (prev) => prev, 42 71 });
+9 -7
apps/app/src/hooks/useLibrary.tsx
··· 45 45 queryKey: ["infiniteArtists", did], 46 46 queryFn: async ({ pageParam = 0 }) => { 47 47 const data = await getArtists(did, pageParam * limit, limit); 48 + const artists = data.artists ?? data; 48 49 return { 49 - artists: data, 50 + artists, 50 51 nextOffset: pageParam + 1, 51 52 }; 52 53 }, ··· 57 58 initialPageParam: 0, 58 59 placeholderData: (prev) => prev, 59 60 refetchOnMount: false, 60 - staleTime: 5 * 60 * 1000, // 5 minutes 61 + staleTime: 5 * 60 * 1000, 61 62 }); 62 63 63 64 export const useAlbumsQuery = (did: string, offset = 0, limit = 12) => ··· 73 74 queryKey: ["infiniteAlbums", did], 74 75 queryFn: async ({ pageParam = 0 }) => { 75 76 const data = await getAlbums(did, pageParam * limit, limit); 77 + const albums = data.albums ?? data; 76 78 return { 77 - albums: data, 79 + albums, 78 80 nextOffset: pageParam + 1, 79 81 }; 80 82 }, ··· 85 87 initialPageParam: 0, 86 88 placeholderData: (prev) => prev, 87 89 refetchOnMount: false, 88 - staleTime: 5 * 60 * 1000, // 5 minutes 90 + staleTime: 5 * 60 * 1000, 89 91 }); 90 92 91 93 export const useTracksQuery = (did: string, offset = 0, limit = 20) => ··· 101 103 queryKey: ["infiniteTracks", did], 102 104 queryFn: async ({ pageParam = 0 }) => { 103 105 const data = await getTracks(did, pageParam * limit, limit); 106 + const tracks = data.tracks ?? data; 104 107 return { 105 - tracks: data, 108 + tracks, 106 109 nextOffset: pageParam + 1, 107 110 }; 108 111 }, 109 112 getNextPageParam: (lastPage) => { 110 - // If we got fewer items than requested, we're at the end 111 113 return lastPage.tracks.length < limit ? undefined : lastPage.nextOffset; 112 114 }, 113 115 enabled: !!did, 114 116 initialPageParam: 0, 115 117 placeholderData: (prev) => prev, 116 118 refetchOnMount: false, 117 - staleTime: 5 * 60 * 1000, // 5 minutes 119 + staleTime: 5 * 60 * 1000, 118 120 }); 119 121 120 122 export const useLovedTracksQuery = (did: string, offset = 0, limit = 20) =>
+3 -3
apps/app/src/screens/Library/Scrobbles/ScrobblesWithData.tsx
··· 46 46 id: scrobble.id, 47 47 title: scrobble.title, 48 48 artist: scrobble.artist, 49 - image: scrobble.album_art!, 50 - listeningDate: dayjs.utc(scrobble.created_at).local().fromNow(), 49 + image: scrobble.albumArt!, 50 + listeningDate: dayjs.utc(scrobble.createdAt).local().fromNow(), 51 51 uri: scrobble.uri, 52 - albumUri: scrobble.album_uri, 52 + albumUri: scrobble.albumUri, 53 53 })), 54 54 ); 55 55 }, [data]);
+3 -3
apps/app/src/screens/Profile/Overview/RecentTracks/RecentTracksWithData.tsx
··· 21 21 id: scrobble.id, 22 22 title: scrobble.title, 23 23 artist: scrobble.artist, 24 - image: scrobble.album_art!, 25 - listeningDate: dayjs.utc(scrobble.created_at).local().fromNow(), 24 + image: scrobble.albumArt!, 25 + listeningDate: dayjs.utc(scrobble.createdAt).local().fromNow(), 26 26 uri: scrobble.uri, 27 - albumUri: scrobble.album_uri, 27 + albumUri: scrobble.albumUri, 28 28 })) ?? [] 29 29 } 30 30 onSeeAll={() => {
+8 -9
apps/app/src/screens/Profile/Overview/TopArtists/TopArtistsWithData.tsx
··· 11 11 const did = useAtomValue(didAtom); 12 12 const navigation = useNavigation<NavigationProp<RootStackParamList>>(); 13 13 const { data } = useArtistsQuery(did!); 14 + const artists = data?.artists ?? (Array.isArray(data) ? data : []); 14 15 return ( 15 16 <TopArtists 16 - artists={ 17 - data?.map((artist: any, index: number) => ({ 18 - id: artist.id, 19 - rank: index + 1, 20 - name: artist.name, 21 - image: artist.picture, 22 - uri: artist.uri, 23 - })) ?? [] 24 - } 17 + artists={artists.map((artist: any, index: number) => ({ 18 + id: artist.id, 19 + rank: index + 1, 20 + name: artist.name, 21 + image: artist.picture, 22 + uri: artist.uri, 23 + }))} 25 24 onSeeAll={() => { 26 25 navigation.navigate("UserLibrary", { handle, tab: 1 }); 27 26 }}
+13
apps/app/src/types/album.ts
··· 1 + export type Album = { 2 + id: string; 3 + uri: string; 4 + title: string; 5 + artist: string; 6 + artistUri: string; 7 + year: number; 8 + albumArt: string; 9 + releaseDate: string; 10 + sha256: string; 11 + playCount: number; 12 + uniqueListeners: number; 13 + };
+9
apps/app/src/types/apikey.ts
··· 1 + export type ApiKey = { 2 + id: string; 3 + name: string; 4 + description?: string; 5 + apiKey: string; 6 + sharedSecret: string; 7 + enabled: boolean; 8 + createdAt: string; 9 + };
+10
apps/app/src/types/artist.ts
··· 1 + export type Artist = { 2 + id: string; 3 + name: string; 4 + picture: string; 5 + playCount: number; 6 + sha256: string; 7 + tags: string[] | null; 8 + uniqueListeners: number; 9 + uri: string; 10 + };
+17
apps/app/src/types/compatibility.ts
··· 1 + export type Compatibility = { 2 + compatibilityLevel: number; 3 + compatibilityPercentage: number; 4 + sharedArtists: number; 5 + topSharedArtists: string[]; 6 + topSharedDetailedArtists: { 7 + id: string; 8 + name: string; 9 + picture: string; 10 + uri: string; 11 + user1Rank: number; 12 + user2Rank: number; 13 + weight: number; 14 + }[]; 15 + user1ArtistCount: number; 16 + user2ArtistCount: number; 17 + };
+5
apps/app/src/types/file.ts
··· 1 + export type File = { 2 + id: string; 3 + name: string; 4 + tag: string; 5 + };
+17
apps/app/src/types/neighbour.ts
··· 1 + export type Neighbour = { 2 + id: string; 3 + avatar: string; 4 + did: string; 5 + displayName: string; 6 + handle: string; 7 + sharedArtistsCount: number; 8 + similarityScore: number; 9 + topSharedArtistNames: string[]; 10 + topSharedArtistsDetails: { 11 + id: string; 12 + name: string; 13 + picture: string; 14 + uri: string; 15 + }[]; 16 + userId: string; 17 + };
+54
apps/app/src/types/profile.ts
··· 1 + export type Profile = { 2 + id: string; 3 + did: string; 4 + handle: string; 5 + displayName: string; 6 + avatar: string; 7 + createdAt: string; 8 + spotifyUser: { 9 + id: string; 10 + xataVersion: number; 11 + email: string; 12 + userId: string; 13 + isBetaUser: boolean; 14 + spotifyAppId: string; 15 + createdAt: string; 16 + updatedAt: string; 17 + }; 18 + spotifyToken: { 19 + id: string; 20 + xataVersion: number; 21 + userId: string; 22 + spotifyAppId: string; 23 + createdAt: string; 24 + updatedAt: string; 25 + }; 26 + spotifyConnected: boolean; 27 + googledrive: { 28 + id: string; 29 + email: string; 30 + isBetaUser: boolean; 31 + userId: string; 32 + xataVersion: number; 33 + createdAt: string; 34 + updatedAt: string; 35 + }; 36 + dropbox: { 37 + id: string; 38 + email: string; 39 + isBetaUser: boolean; 40 + userId: string; 41 + xataVersion: number; 42 + createdAt: string; 43 + updatedAt: string; 44 + }; 45 + googleDrive: { 46 + id: string; 47 + email: string; 48 + isBetaUser: boolean; 49 + userId: string; 50 + xataVersion: number; 51 + createdAt: string; 52 + updatedAt: string; 53 + }; 54 + };
+7 -7
apps/app/src/types/scrobble.ts
··· 1 1 export type Scrobble = { 2 2 id: string; 3 - track_id: string; 3 + trackId: string; 4 4 title: string; 5 5 artist: string; 6 6 album: string; 7 - album_art?: string; 8 - album_artist: string; 7 + albumArt?: string; 8 + albumArtist: string; 9 9 handle: string; 10 - track_uri: string; 11 - album_uri: string; 12 - artist_uri: string; 10 + trackUri: string; 11 + albumUri: string; 12 + artistUri: string; 13 13 uri: string; 14 - created_at: string; 14 + createdAt: string; 15 15 };
+19
apps/app/src/types/track.ts
··· 1 + export type Track = { 2 + id: string; 3 + uri: string; 4 + uniqueListeners: number; 5 + playCount: number; 6 + title: string; 7 + artist: string; 8 + artistUri: string; 9 + album: string; 10 + albumUri: string; 11 + albumArt: string; 12 + albumArtist: string; 13 + copyrightMessage: string; 14 + discNumber: number; 15 + duration: number; 16 + sha256: string; 17 + track_number: number; 18 + created_at: string; 19 + };