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

Configure Feed

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

Add feedView types and cursor pagination

Introduce feedItemView and feedView schemas and switch getFeed to use a
string cursor instead of numeric offset. Update generated types,
pkl/lexicon defs, and the XRPC handler to return FeedView (and add the
hydrate step + axios import) Add feedView types and cursor pagination

+245 -110
+21
apps/api/lexicons/feed/defs.json
··· 148 148 "format": "at-uri" 149 149 } 150 150 } 151 + }, 152 + "feedItemView": { 153 + "type": "object", 154 + "properties": { 155 + "scrobble": { 156 + "type": "ref", 157 + "ref": "app.rocksky.scrobble.defs#scrobbleViewBasic" 158 + } 159 + } 160 + }, 161 + "feedView": { 162 + "type": "object", 163 + "properties": { 164 + "feed": { 165 + "type": "array", 166 + "items": { 167 + "type": "ref", 168 + "ref": "app.rocksky.feed.defs#feedItemView" 169 + } 170 + } 171 + } 151 172 } 152 173 } 153 174 }
+5 -14
apps/api/lexicons/feed/getFeed.json
··· 21 21 "description": "The maximum number of scrobbles to return", 22 22 "minimum": 1 23 23 }, 24 - "offset": { 25 - "type": "integer", 26 - "description": "The offset for pagination", 27 - "minimum": 0 24 + "cursor": { 25 + "type": "string", 26 + "description": "The cursor for pagination" 28 27 } 29 28 } 30 29 }, 31 30 "output": { 32 31 "encoding": "application/json", 33 32 "schema": { 34 - "type": "object", 35 - "properties": { 36 - "scrobbles": { 37 - "type": "array", 38 - "items": { 39 - "type": "ref", 40 - "ref": "app.rocksky.scrobble.defs#scrobbleViewBasic" 41 - } 42 - } 43 - } 33 + "type": "ref", 34 + "ref": "app.rocksky.feed.defs#feedView" 44 35 } 45 36 } 46 37 }
+44 -22
apps/api/pkl/defs/feed/defs.pkl
··· 1 - amends "../../schema/lexicon.pkl" 1 + amends "../../schema/lexicon.pkl" 2 2 3 3 lexicon = 1 4 4 id = "app.rocksky.feed.defs" 5 5 defs = new Mapping<String, ObjectType> { 6 - ["searchResultsView"] = new ObjectType { 6 + ["searchResultsView"] = new ObjectType { 7 7 type = "object" 8 - properties { 8 + properties { 9 9 ["hits"] = new Array { 10 10 type = "array" 11 11 items = new Union { 12 12 type = "union" 13 - refs = List( 14 - "app.rocksky.song.defs#songViewBasic", 15 - "app.rocksky.album.defs#albumViewBasic", 16 - "app.rocksky.artist.defs#artistViewBasic", 17 - "app.rocksky.playlist.defs#playlistViewBasic", 18 - "app.rocksky.actor.defs#profileViewBasic" 13 + refs = 14 + List( 15 + "app.rocksky.song.defs#songViewBasic", 16 + "app.rocksky.album.defs#albumViewBasic", 17 + "app.rocksky.artist.defs#artistViewBasic", 18 + "app.rocksky.playlist.defs#playlistViewBasic", 19 + "app.rocksky.actor.defs#profileViewBasic" 19 20 ) 20 21 } 21 22 } ··· 32 33 type = "integer" 33 34 } 34 35 } 35 - 36 36 } 37 37 ["nowPlayingView"] = new ObjectType { 38 38 type = "object" ··· 91 91 } 92 92 } 93 93 } 94 - ["nowPlayingsView"] = new ObjectType { 94 + ["nowPlayingsView"] = new ObjectType { 95 95 type = "object" 96 - properties { 96 + properties { 97 97 ["nowPlayings"] = new Array { 98 98 type = "array" 99 99 items = new Ref { ··· 105 105 } 106 106 ["feedGeneratorsView"] = new ObjectType { 107 107 type = "object" 108 - properties { 108 + properties { 109 109 ["feeds"] = new Array { 110 110 type = "array" 111 111 items = new Ref { ··· 142 142 } 143 143 } 144 144 ["feedUriView"] = new ObjectType { 145 - type = "object" 146 - properties { 147 - ["uri"] = new StringType { 148 - type = "string" 149 - description = "The feed URI." 150 - format = "at-uri" 151 - } 152 - } 153 - } 145 + type = "object" 146 + properties { 147 + ["uri"] = new StringType { 148 + type = "string" 149 + description = "The feed URI." 150 + format = "at-uri" 151 + } 152 + } 153 + } 154 + 155 + ["feedItemView"] = new ObjectType { 156 + type = "object" 157 + properties { 158 + ["scrobble"] = new Ref { 159 + ref = "app.rocksky.scrobble.defs#scrobbleViewBasic" 160 + } 161 + } 162 + } 163 + 164 + ["feedView"] = new ObjectType { 165 + type = "object" 166 + properties { 167 + ["feed"] = new Array { 168 + type = "array" 169 + items = new Ref { 170 + type = "ref" 171 + ref = "app.rocksky.feed.defs#feedItemView" 172 + } 173 + } 174 + } 175 + } 154 176 }
+6 -14
apps/api/pkl/defs/feed/getFeed.pkl
··· 20 20 description = "The maximum number of scrobbles to return" 21 21 minimum = 1 22 22 } 23 - ["offset"] = new IntegerType { 24 - type = "integer" 25 - description = "The offset for pagination" 26 - minimum = 0 23 + ["cursor"] = new StringType { 24 + type = "string" 25 + description = "The cursor for pagination" 27 26 } 28 27 } 29 28 } 30 29 output { 31 30 encoding = "application/json" 32 - schema = new ObjectType { 33 - type = "object" 34 - properties = new Mapping<String, Array> { 35 - ["scrobbles"] = new Array { 36 - type = "array" 37 - items = new Ref { 38 - ref = "app.rocksky.scrobble.defs#scrobbleViewBasic" 39 - } 40 - } 41 - } 31 + schema = new Ref { 32 + type = "ref" 33 + ref = "app.rocksky.feed.defs#feedView" 42 34 } 43 35 } 44 36 }
+26 -14
apps/api/src/lexicon/lexicons.ts
··· 2324 2324 }, 2325 2325 }, 2326 2326 }, 2327 + feedItemView: { 2328 + type: "object", 2329 + properties: { 2330 + scrobble: { 2331 + type: "ref", 2332 + ref: "lex:app.rocksky.scrobble.defs#scrobbleViewBasic", 2333 + }, 2334 + }, 2335 + }, 2336 + feedView: { 2337 + type: "object", 2338 + properties: { 2339 + feed: { 2340 + type: "array", 2341 + items: { 2342 + type: "ref", 2343 + ref: "lex:app.rocksky.feed.defs#feedItemView", 2344 + }, 2345 + }, 2346 + }, 2347 + }, 2327 2348 }, 2328 2349 }, 2329 2350 AppRockskyFeedDescribeFeedGenerator: { ··· 2424 2445 description: "The maximum number of scrobbles to return", 2425 2446 minimum: 1, 2426 2447 }, 2427 - offset: { 2428 - type: "integer", 2429 - description: "The offset for pagination", 2430 - minimum: 0, 2448 + cursor: { 2449 + type: "string", 2450 + description: "The cursor for pagination", 2431 2451 }, 2432 2452 }, 2433 2453 }, 2434 2454 output: { 2435 2455 encoding: "application/json", 2436 2456 schema: { 2437 - type: "object", 2438 - properties: { 2439 - scrobbles: { 2440 - type: "array", 2441 - items: { 2442 - type: "ref", 2443 - ref: "lex:app.rocksky.scrobble.defs#scrobbleViewBasic", 2444 - }, 2445 - }, 2446 - }, 2457 + type: "ref", 2458 + ref: "lex:app.rocksky.feed.defs#feedView", 2447 2459 }, 2448 2460 }, 2449 2461 },
+35
apps/api/src/lexicon/types/app/rocksky/feed/defs.ts
··· 10 10 import type * as AppRockskyArtistDefs from "../artist/defs"; 11 11 import type * as AppRockskyPlaylistDefs from "../playlist/defs"; 12 12 import type * as AppRockskyActorDefs from "../actor/defs"; 13 + import type * as AppRockskyScrobbleDefs from "../scrobble/defs"; 13 14 14 15 export interface SearchResultsView { 15 16 hits?: ( ··· 143 144 export function validateFeedUriView(v: unknown): ValidationResult { 144 145 return lexicons.validate("app.rocksky.feed.defs#feedUriView", v); 145 146 } 147 + 148 + export interface FeedItemView { 149 + scrobble?: AppRockskyScrobbleDefs.ScrobbleViewBasic; 150 + [k: string]: unknown; 151 + } 152 + 153 + export function isFeedItemView(v: unknown): v is FeedItemView { 154 + return ( 155 + isObj(v) && 156 + hasProp(v, "$type") && 157 + v.$type === "app.rocksky.feed.defs#feedItemView" 158 + ); 159 + } 160 + 161 + export function validateFeedItemView(v: unknown): ValidationResult { 162 + return lexicons.validate("app.rocksky.feed.defs#feedItemView", v); 163 + } 164 + 165 + export interface FeedView { 166 + feed?: FeedItemView[]; 167 + [k: string]: unknown; 168 + } 169 + 170 + export function isFeedView(v: unknown): v is FeedView { 171 + return ( 172 + isObj(v) && 173 + hasProp(v, "$type") && 174 + v.$type === "app.rocksky.feed.defs#feedView" 175 + ); 176 + } 177 + 178 + export function validateFeedView(v: unknown): ValidationResult { 179 + return lexicons.validate("app.rocksky.feed.defs#feedView", v); 180 + }
+4 -9
apps/api/src/lexicon/types/app/rocksky/feed/getFeed.ts
··· 7 7 import { isObj, hasProp } from "../../../../util"; 8 8 import { CID } from "multiformats/cid"; 9 9 import type { HandlerAuth, HandlerPipeThrough } from "@atproto/xrpc-server"; 10 - import type * as AppRockskyScrobbleDefs from "../scrobble/defs"; 10 + import type * as AppRockskyFeedDefs from "./defs"; 11 11 12 12 export interface QueryParams { 13 13 /** The feed URI. */ 14 14 feed: string; 15 15 /** The maximum number of scrobbles to return */ 16 16 limit?: number; 17 - /** The offset for pagination */ 18 - offset?: number; 17 + /** The cursor for pagination */ 18 + cursor?: string; 19 19 } 20 20 21 21 export type InputSchema = undefined; 22 - 23 - export interface OutputSchema { 24 - scrobbles?: AppRockskyScrobbleDefs.ScrobbleViewBasic[]; 25 - [k: string]: unknown; 26 - } 27 - 22 + export type OutputSchema = AppRockskyFeedDefs.FeedView; 28 23 export type HandlerInput = undefined; 29 24 30 25 export interface HandlerSuccess {
+48 -21
apps/api/src/xrpc/app/rocksky/feed/getFeed.ts
··· 1 1 import type { Context } from "context"; 2 - import { desc, eq } from "drizzle-orm"; 2 + import { desc, eq, inArray } from "drizzle-orm"; 3 3 import { Effect, pipe } from "effect"; 4 4 import type { Server } from "lexicon"; 5 - import type { ScrobbleViewBasic } from "lexicon/types/app/rocksky/scrobble/defs"; 6 5 import type { QueryParams } from "lexicon/types/app/rocksky/feed/getFeed"; 6 + import type { FeedView } from "lexicon/types/app/rocksky/feed/defs"; 7 7 import * as R from "ramda"; 8 8 import tables from "schema"; 9 9 import type { SelectScrobble } from "schema/scrobbles"; 10 10 import type { SelectTrack } from "schema/tracks"; 11 11 import type { SelectUser } from "schema/users"; 12 + import axios from "axios"; 12 13 13 14 export default function (server: Server, ctx: Context) { 14 15 const getFeed = (params: QueryParams) => 15 16 pipe( 16 17 { params, ctx }, 17 18 retrieve, 19 + Effect.flatMap(hydrate), 18 20 Effect.flatMap(presentation), 19 21 Effect.retry({ times: 3 }), 20 22 Effect.timeout("10 seconds"), ··· 34 36 }); 35 37 } 36 38 37 - const retrieve = ({ 38 - params, 39 + const retrieve = ({ params, ctx }: { params: QueryParams; ctx: Context }) => { 40 + return Effect.tryPromise({ 41 + try: async () => { 42 + const [feed] = await ctx.db 43 + .select() 44 + .from(tables.feeds) 45 + .where(eq(tables.feeds.uri, params.feed)) 46 + .execute(); 47 + if (!feed) { 48 + throw new Error(`Feed not found`); 49 + } 50 + const feedUrl = `https://${feed.did.split("did:web:")[1]}`; 51 + const response = await axios.get<{ 52 + cusrsor: string; 53 + feed: { scrobble: string }[]; 54 + }>(`${feedUrl}/xrpc/app.rocksky.feed.getFeedSkeleton`, { 55 + params: { 56 + feed: feed.uri, 57 + }, 58 + }); 59 + return { uris: response.data.feed.map(({ scrobble }) => scrobble), ctx }; 60 + }, 61 + catch: (error) => new Error(`Failed to retrieve feed: ${error}`), 62 + }); 63 + }; 64 + 65 + const hydrate = ({ 66 + uris, 39 67 ctx, 40 68 }: { 41 - params: QueryParams; 69 + uris: string[]; 42 70 ctx: Context; 43 71 }): Effect.Effect<Scrobbles | undefined, Error> => { 44 72 return Effect.tryPromise({ ··· 48 76 .from(tables.scrobbles) 49 77 .leftJoin(tables.tracks, eq(tables.scrobbles.trackId, tables.tracks.id)) 50 78 .leftJoin(tables.users, eq(tables.scrobbles.userId, tables.users.id)) 79 + .where(inArray(tables.scrobbles.uri, uris)) 51 80 .orderBy(desc(tables.scrobbles.timestamp)) 52 - .offset(params.offset || 0) 53 - .limit(params.limit || 20) 54 81 .execute(), 55 82 56 - catch: (error) => new Error(`Failed to retrieve scrobbles: ${error}`), 83 + catch: (error) => new Error(`Failed to hydrate feed: ${error}`), 57 84 }); 58 85 }; 59 86 60 - const presentation = ( 61 - data: Scrobbles, 62 - ): Effect.Effect<{ scrobbles: ScrobbleViewBasic[] }, never> => { 87 + const presentation = (data: Scrobbles): Effect.Effect<FeedView, never> => { 63 88 return Effect.sync(() => ({ 64 - scrobbles: data.map(({ scrobbles, tracks, users }) => ({ 65 - ...R.omit(["albumArt", "id", "lyrics"])(tracks), 66 - cover: tracks.albumArt, 67 - date: scrobbles.timestamp.toISOString(), 68 - user: users.handle, 69 - userDisplayName: users.displayName, 70 - userAvatar: users.avatar, 71 - uri: scrobbles.uri, 72 - tags: [], 73 - id: scrobbles.id, 89 + feed: data.map(({ scrobbles, tracks, users }) => ({ 90 + scrobble: { 91 + ...R.omit(["albumArt", "id", "lyrics"])(tracks), 92 + cover: tracks.albumArt, 93 + date: scrobbles.timestamp.toISOString(), 94 + user: users.handle, 95 + userDisplayName: users.displayName, 96 + userAvatar: users.avatar, 97 + uri: scrobbles.uri, 98 + tags: [], 99 + id: scrobbles.id, 100 + }, 74 101 })), 75 102 })); 76 103 };
+23 -10
apps/feeds/src/lex/lexicons.ts
··· 2780 2780 }, 2781 2781 }, 2782 2782 }, 2783 + "feedItemView": { 2784 + "type": "object", 2785 + "properties": { 2786 + "scrobble": { 2787 + "type": "ref", 2788 + "ref": "lex:app.rocksky.scrobble.defs#scrobbleViewBasic", 2789 + }, 2790 + }, 2791 + }, 2792 + "feedView": { 2793 + "type": "object", 2794 + "properties": { 2795 + "feed": { 2796 + "type": "array", 2797 + "items": { 2798 + "type": "ref", 2799 + "ref": "lex:app.rocksky.feed.defs#feedItemView", 2800 + }, 2801 + }, 2802 + }, 2803 + }, 2783 2804 }, 2784 2805 }, 2785 2806 "AppRockskyFeedGetFeedGenerators": { ··· 2970 2991 "output": { 2971 2992 "encoding": "application/json", 2972 2993 "schema": { 2973 - "type": "object", 2974 - "properties": { 2975 - "scrobbles": { 2976 - "type": "array", 2977 - "items": { 2978 - "type": "ref", 2979 - "ref": "lex:app.rocksky.scrobble.defs#scrobbleViewBasic", 2980 - }, 2981 - }, 2982 - }, 2994 + "type": "ref", 2995 + "ref": "lex:app.rocksky.feed.defs#feedView", 2983 2996 }, 2984 2997 }, 2985 2998 },
+31
apps/feeds/src/lex/types/app/rocksky/feed/defs.ts
··· 8 8 import type * as AppRockskyArtistDefs from "../artist/defs.ts"; 9 9 import type * as AppRockskyPlaylistDefs from "../playlist/defs.ts"; 10 10 import type * as AppRockskyActorDefs from "../actor/defs.ts"; 11 + import type * as AppRockskyScrobbleDefs from "../scrobble/defs.ts"; 11 12 12 13 const is$typed = _is$typed, validate = _validate; 13 14 const id = "app.rocksky.feed.defs"; ··· 132 133 export function validateFeedUriView<V>(v: V) { 133 134 return validate<FeedUriView & V>(v, id, hashFeedUriView); 134 135 } 136 + 137 + export interface FeedItemView { 138 + $type?: "app.rocksky.feed.defs#feedItemView"; 139 + scrobble?: AppRockskyScrobbleDefs.ScrobbleViewBasic; 140 + } 141 + 142 + const hashFeedItemView = "feedItemView"; 143 + 144 + export function isFeedItemView<V>(v: V) { 145 + return is$typed(v, id, hashFeedItemView); 146 + } 147 + 148 + export function validateFeedItemView<V>(v: V) { 149 + return validate<FeedItemView & V>(v, id, hashFeedItemView); 150 + } 151 + 152 + export interface FeedView { 153 + $type?: "app.rocksky.feed.defs#feedView"; 154 + feed?: (FeedItemView)[]; 155 + } 156 + 157 + const hashFeedView = "feedView"; 158 + 159 + export function isFeedView<V>(v: V) { 160 + return is$typed(v, id, hashFeedView); 161 + } 162 + 163 + export function validateFeedView<V>(v: V) { 164 + return validate<FeedView & V>(v, id, hashFeedView); 165 + }
+2 -6
apps/feeds/src/lex/types/app/rocksky/feed/getFeed.ts
··· 1 1 /** 2 2 * GENERATED CODE - DO NOT MODIFY 3 3 */ 4 - import type * as AppRockskyScrobbleDefs from "../scrobble/defs.ts"; 4 + import type * as AppRockskyFeedDefs from "./defs.ts"; 5 5 6 6 export type QueryParams = { 7 7 /** The feed URI. */ ··· 12 12 offset?: number; 13 13 }; 14 14 export type InputSchema = undefined; 15 - 16 - export interface OutputSchema { 17 - scrobbles?: (AppRockskyScrobbleDefs.ScrobbleViewBasic)[]; 18 - } 19 - 15 + export type OutputSchema = AppRockskyFeedDefs.FeedView; 20 16 export type HandlerInput = void; 21 17 22 18 export interface HandlerSuccess {