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

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 {