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

Add feed getStories endpoint with caching

+275
+27
apps/api/lexicons/feed/getStories.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "app.rocksky.feed.getStories", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "Get all currently playing tracks by users", 8 + "parameters": { 9 + "type": "params", 10 + "properties": { 11 + "size": { 12 + "type": "integer", 13 + "description": "The maximum number of stories to return.", 14 + "minimum": 1 15 + } 16 + } 17 + }, 18 + "output": { 19 + "encoding": "application/json", 20 + "schema": { 21 + "type": "ref", 22 + "ref": "app.rocksky.feed.defs#nowPlayingsView" 23 + } 24 + } 25 + } 26 + } 27 + }
+27
apps/api/pkl/defs/feed/getStories.pkl
··· 1 + amends "../../schema/lexicon.pkl" 2 + 3 + lexicon = 1 4 + id = "app.rocksky.feed.getStories" 5 + defs = new Mapping<String, Query> { 6 + ["main"] { 7 + type = "query" 8 + description = "Get all currently playing tracks by users" 9 + parameters { 10 + type = "params" 11 + properties { 12 + ["size"] = new IntegerType { 13 + type = "integer" 14 + description = "The maximum number of stories to return." 15 + minimum = 1 16 + } 17 + } 18 + } 19 + output { 20 + encoding = "application/json" 21 + schema = new Ref { 22 + type = "ref" 23 + ref = "app.rocksky.feed.defs#nowPlayingsView" 24 + } 25 + } 26 + } 27 + }
+12
apps/api/src/lexicon/index.ts
··· 48 48 import type * as AppRockskyFeedGetFeedGenerators from "./types/app/rocksky/feed/getFeedGenerators"; 49 49 import type * as AppRockskyFeedGetFeedSkeleton from "./types/app/rocksky/feed/getFeedSkeleton"; 50 50 import type * as AppRockskyFeedGetNowPlayings from "./types/app/rocksky/feed/getNowPlayings"; 51 + import type * as AppRockskyFeedGetStories from "./types/app/rocksky/feed/getStories"; 51 52 import type * as AppRockskyFeedSearch from "./types/app/rocksky/feed/search"; 52 53 import type * as AppRockskyGoogledriveDownloadFile from "./types/app/rocksky/googledrive/downloadFile"; 53 54 import type * as AppRockskyGoogledriveGetFile from "./types/app/rocksky/googledrive/getFile"; ··· 705 706 >, 706 707 ) { 707 708 const nsid = "app.rocksky.feed.getNowPlayings"; // @ts-ignore 709 + return this._server.xrpc.method(nsid, cfg); 710 + } 711 + 712 + getStories<AV extends AuthVerifier>( 713 + cfg: ConfigOf< 714 + AV, 715 + AppRockskyFeedGetStories.Handler<ExtractAuth<AV>>, 716 + AppRockskyFeedGetStories.HandlerReqCtx<ExtractAuth<AV>> 717 + >, 718 + ) { 719 + const nsid = "app.rocksky.feed.getStories"; // @ts-ignore 708 720 return this._server.xrpc.method(nsid, cfg); 709 721 } 710 722
+28
apps/api/src/lexicon/lexicons.ts
··· 2949 2949 }, 2950 2950 }, 2951 2951 }, 2952 + AppRockskyFeedGetStories: { 2953 + lexicon: 1, 2954 + id: "app.rocksky.feed.getStories", 2955 + defs: { 2956 + main: { 2957 + type: "query", 2958 + description: "Get all currently playing tracks by users", 2959 + parameters: { 2960 + type: "params", 2961 + properties: { 2962 + size: { 2963 + type: "integer", 2964 + description: "The maximum number of stories to return.", 2965 + minimum: 1, 2966 + }, 2967 + }, 2968 + }, 2969 + output: { 2970 + encoding: "application/json", 2971 + schema: { 2972 + type: "ref", 2973 + ref: "lex:app.rocksky.feed.defs#nowPlayingsView", 2974 + }, 2975 + }, 2976 + }, 2977 + }, 2978 + }, 2952 2979 AppRockskyFeedSearch: { 2953 2980 lexicon: 1, 2954 2981 id: "app.rocksky.feed.search", ··· 6187 6214 AppRockskyFeedGetFeedGenerators: "app.rocksky.feed.getFeedGenerators", 6188 6215 AppRockskyFeedGetFeedSkeleton: "app.rocksky.feed.getFeedSkeleton", 6189 6216 AppRockskyFeedGetNowPlayings: "app.rocksky.feed.getNowPlayings", 6217 + AppRockskyFeedGetStories: "app.rocksky.feed.getStories", 6190 6218 AppRockskyFeedSearch: "app.rocksky.feed.search", 6191 6219 AppRockskyGoogledriveDefs: "app.rocksky.googledrive.defs", 6192 6220 AppRockskyGoogledriveDownloadFile: "app.rocksky.googledrive.downloadFile",
+43
apps/api/src/lexicon/types/app/rocksky/feed/getStories.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 AppRockskyFeedDefs from "./defs"; 11 + 12 + export interface QueryParams { 13 + /** The maximum number of stories to return. */ 14 + size?: number; 15 + } 16 + 17 + export type InputSchema = undefined; 18 + export type OutputSchema = AppRockskyFeedDefs.NowPlayingsView; 19 + export type HandlerInput = undefined; 20 + 21 + export interface HandlerSuccess { 22 + encoding: "application/json"; 23 + body: OutputSchema; 24 + headers?: { [key: string]: string }; 25 + } 26 + 27 + export interface HandlerError { 28 + status: number; 29 + message?: string; 30 + } 31 + 32 + export type HandlerOutput = HandlerError | HandlerSuccess | HandlerPipeThrough; 33 + export type HandlerReqCtx<HA extends HandlerAuth = never> = { 34 + auth: HA; 35 + params: QueryParams; 36 + input: HandlerInput; 37 + req: express.Request; 38 + res: express.Response; 39 + resetRouteRateLimits: () => Promise<void>; 40 + }; 41 + export type Handler<HA extends HandlerAuth = never> = ( 42 + ctx: HandlerReqCtx<HA>, 43 + ) => Promise<HandlerOutput> | HandlerOutput;
+138
apps/api/src/xrpc/app/rocksky/feed/getStories.ts
··· 1 + import type { Context } from "context"; 2 + import { consola } from "consola"; 3 + import { desc, eq, sql } from "drizzle-orm"; 4 + import { Cache, Duration, Effect, pipe } from "effect"; 5 + import type { Server } from "lexicon"; 6 + import type { NowPlayingView } from "lexicon/types/app/rocksky/feed/defs"; 7 + import type { QueryParams } from "lexicon/types/app/rocksky/feed/getStories"; 8 + import albums from "schema/albums"; 9 + import artists from "schema/artists"; 10 + import scrobbles from "schema/scrobbles"; 11 + import tracks from "schema/tracks"; 12 + import users from "schema/users"; 13 + 14 + export default function (server: Server, ctx: Context) { 15 + const storiesCache = Cache.make({ 16 + capacity: 100, 17 + timeToLive: Duration.seconds(30), 18 + lookup: (params: QueryParams) => 19 + pipe( 20 + { params, ctx }, 21 + retrieve, 22 + Effect.map((data) => ({ data })), 23 + Effect.flatMap(presentation), 24 + Effect.retry({ times: 3 }), 25 + Effect.timeout("120 seconds"), 26 + ), 27 + }); 28 + 29 + const getNowPlayings = (params) => 30 + pipe( 31 + storiesCache, 32 + Effect.flatMap((cache) => cache.get(params)), 33 + Effect.catchAll((err) => { 34 + consola.error(err); 35 + return Effect.succeed({}); 36 + }), 37 + ); 38 + server.app.rocksky.feed.getStories({ 39 + handler: async ({ params }) => { 40 + const result = await Effect.runPromise(getNowPlayings(params)); 41 + return { 42 + encoding: "application/json", 43 + body: result, 44 + }; 45 + }, 46 + }); 47 + } 48 + 49 + const retrieve = ({ 50 + params, 51 + ctx, 52 + }: { 53 + params: QueryParams; 54 + ctx: Context; 55 + }): Effect.Effect<NowPlayingRecord[], Error, never> => { 56 + return Effect.tryPromise({ 57 + try: () => 58 + ctx.db 59 + .select({ 60 + xataId: scrobbles.id, 61 + trackId: tracks.id, 62 + title: tracks.title, 63 + artist: tracks.artist, 64 + albumArtist: tracks.albumArtist, 65 + album: tracks.album, 66 + albumArt: tracks.albumArt, 67 + handle: users.handle, 68 + did: users.did, 69 + avatar: users.avatar, 70 + uri: scrobbles.uri, 71 + trackUri: tracks.uri, 72 + artistUri: artists.uri, 73 + albumUri: albums.uri, 74 + timestamp: scrobbles.timestamp, 75 + }) 76 + .from(scrobbles) 77 + .leftJoin(artists, eq(scrobbles.artistId, artists.id)) 78 + .leftJoin(albums, eq(scrobbles.albumId, albums.id)) 79 + .leftJoin(tracks, eq(scrobbles.trackId, tracks.id)) 80 + .leftJoin(users, eq(scrobbles.userId, users.id)) 81 + .where( 82 + sql`scrobbles.xata_id IN ( 83 + SELECT DISTINCT ON (inner_s.user_id) inner_s.xata_id 84 + FROM scrobbles inner_s 85 + ORDER BY inner_s.user_id, inner_s.timestamp DESC, inner_s.xata_id DESC 86 + )`, 87 + ) 88 + .orderBy(desc(scrobbles.timestamp)) 89 + .limit(params.size || 20) 90 + .execute(), 91 + catch: (error) => 92 + new Error(`Failed to retrieve now playing songs: ${error}`), 93 + }); 94 + }; 95 + 96 + const presentation = ({ 97 + data, 98 + }: { 99 + data: NowPlayingRecord[]; 100 + }): Effect.Effect<{ nowPlayings: NowPlayingView[] }, never> => { 101 + return Effect.sync(() => ({ 102 + nowPlayings: data.map((record) => ({ 103 + album: record.album, 104 + albumArt: record.albumArt, 105 + albumArtist: record.albumArtist, 106 + albumUri: record.albumUri, 107 + artist: record.artist, 108 + artistUri: record.artistUri, 109 + avatar: record.avatar, 110 + createdAt: record.timestamp.toISOString(), 111 + did: record.did, 112 + handle: record.handle, 113 + id: record.trackId, 114 + title: record.title, 115 + trackId: record.trackId, 116 + trackUri: record.trackUri, 117 + uri: record.uri, 118 + })), 119 + })); 120 + }; 121 + 122 + type NowPlayingRecord = { 123 + xataId: string; 124 + trackId: string; 125 + title: string; 126 + artist: string; 127 + albumArtist: string; 128 + album: string; 129 + albumArt: string; 130 + handle: string; 131 + did: string; 132 + avatar: string; 133 + uri: string; 134 + trackUri: string; 135 + artistUri: string; 136 + albumUri: string; 137 + timestamp: Date; 138 + };