A decentralized music tracking and discovery platform built on AT Protocol 🎵
listenbrainz spotify atproto lastfm musicbrainz scrobbling

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.

Changed files
+66 -11
apps
api
src
xrpc
app
rocksky
feed
+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 }[];