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

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