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

Configure Feed

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

Add auth and likes to feed endpoint

Use ctx.authVerifier and pass DID to retrieve so the handler can
determine per-track liked state. Query lovedTracks to build likes counts
and liked flags, and include likesCount and liked in the presentation.
Switch feed fetch to http://localhost:8002 when PUBLIC_URL contains
"localhost", and emit createdAt/updatedAt ISO timestamps.

+66 -11
+66 -11
apps/api/src/xrpc/app/rocksky/feed/getFeed.ts
··· 10 10 import type { SelectTrack } from "schema/tracks"; 11 11 import type { SelectUser } from "schema/users"; 12 12 import axios from "axios"; 13 + import { HandlerAuth } from "@atproto/xrpc-server"; 14 + import { env } from "lib/env"; 13 15 14 16 export default function (server: Server, ctx: Context) { 15 - const getFeed = (params: QueryParams) => 17 + const getFeed = (params: QueryParams, auth: HandlerAuth) => 16 18 pipe( 17 - { params, ctx }, 19 + { params, ctx, did: auth.credentials?.did }, 18 20 retrieve, 19 21 Effect.flatMap(hydrate), 20 22 Effect.flatMap(presentation), ··· 26 28 }), 27 29 ); 28 30 server.app.rocksky.feed.getFeed({ 29 - handler: async ({ params }) => { 30 - const result = await Effect.runPromise(getFeed(params)); 31 + auth: ctx.authVerifier, 32 + handler: async ({ params, auth }) => { 33 + const result = await Effect.runPromise(getFeed(params, auth)); 31 34 return { 32 35 encoding: "application/json", 33 36 body: result, ··· 36 39 }); 37 40 } 38 41 39 - const retrieve = ({ params, ctx }: { params: QueryParams; ctx: Context }) => { 42 + const retrieve = ({ 43 + params, 44 + ctx, 45 + did, 46 + }: { 47 + params: QueryParams; 48 + ctx: Context; 49 + did?: string; 50 + }) => { 40 51 return Effect.tryPromise({ 41 52 try: async () => { 42 53 const [feed] = await ctx.db ··· 47 58 if (!feed) { 48 59 throw new Error(`Feed not found`); 49 60 } 50 - const feedUrl = `https://${feed.did.split("did:web:")[1]}`; 61 + const feedUrl = env.PUBLIC_URL.includes("localhost") 62 + ? "http://localhost:8002" 63 + : `https://${feed.did.split("did:web:")[1]}`; 51 64 const response = await axios.get<{ 52 65 cusrsor: string; 53 66 feed: { scrobble: string }[]; ··· 58 71 cursor: params.cursor, 59 72 }, 60 73 }); 61 - return { uris: response.data.feed.map(({ scrobble }) => scrobble), ctx }; 74 + return { 75 + uris: response.data.feed.map(({ scrobble }) => scrobble), 76 + ctx, 77 + did, 78 + }; 62 79 }, 63 80 catch: (error) => new Error(`Failed to retrieve feed: ${error}`), 64 81 }); ··· 67 84 const hydrate = ({ 68 85 uris, 69 86 ctx, 87 + did, 70 88 }: { 71 89 uris: string[]; 72 90 ctx: Context; 91 + did?: string; 73 92 }): Effect.Effect<Scrobbles | undefined, Error> => { 74 93 return Effect.tryPromise({ 75 - try: () => 76 - ctx.db 94 + try: async () => { 95 + const scrobbles = await ctx.db 77 96 .select() 78 97 .from(tables.scrobbles) 79 98 .leftJoin(tables.tracks, eq(tables.scrobbles.trackId, tables.tracks.id)) 80 99 .leftJoin(tables.users, eq(tables.scrobbles.userId, tables.users.id)) 81 100 .where(inArray(tables.scrobbles.uri, uris)) 82 101 .orderBy(desc(tables.scrobbles.timestamp)) 83 - .execute(), 102 + .execute(); 103 + 104 + const trackIds = scrobbles.map((row) => row.tracks?.id).filter(Boolean); 105 + 106 + const likes = await ctx.db 107 + .select() 108 + .from(tables.lovedTracks) 109 + .leftJoin(tables.users, eq(tables.lovedTracks.userId, tables.users.id)) 110 + .where(inArray(tables.lovedTracks.trackId, trackIds)) 111 + .execute(); 112 + 113 + const likesMap = new Map<string, { count: number; liked: boolean }>(); 114 + 115 + for (const trackId of trackIds) { 116 + const trackLikes = likes.filter( 117 + (l) => l.loved_tracks.trackId === trackId, 118 + ); 119 + likesMap.set(trackId, { 120 + count: trackLikes.length, 121 + liked: trackLikes.some((l) => l.users.did === did), 122 + }); 123 + } 124 + 125 + const result = scrobbles.map((row) => ({ 126 + ...row, 127 + likesCount: likesMap.get(row.tracks?.id)?.count ?? 0, 128 + liked: likesMap.get(row.tracks?.id)?.liked ?? false, 129 + })); 130 + 131 + return result; 132 + }, 84 133 85 134 catch: (error) => new Error(`Failed to hydrate feed: ${error}`), 86 135 }); ··· 88 137 89 138 const presentation = (data: Scrobbles): Effect.Effect<FeedView, never> => { 90 139 return Effect.sync(() => ({ 91 - feed: data.map(({ scrobbles, tracks, users }) => ({ 140 + feed: data.map(({ scrobbles, tracks, users, likesCount, liked }) => ({ 92 141 scrobble: { 93 142 ...R.omit(["albumArt", "id", "lyrics"])(tracks), 94 143 cover: tracks.albumArt, ··· 98 147 userAvatar: users.avatar, 99 148 uri: scrobbles.uri, 100 149 tags: [], 150 + likesCount, 151 + liked, 152 + createdAt: scrobbles.createdAt.toISOString(), 153 + updatedAt: scrobbles.updatedAt.toISOString(), 101 154 id: scrobbles.id, 102 155 }, 103 156 })), ··· 108 161 scrobbles: SelectScrobble; 109 162 tracks: SelectTrack; 110 163 users: SelectUser; 164 + likesCount: number; 165 + liked: boolean; 111 166 }[];