Your music, beautifully tracked. All yours. (coming soon) teal.fm
teal-fm atproto

Merge pull request #40 from teal-fm/natb-play-endpoints

Add implementations for getActorFeed and getPlay

authored by mmatt.net and committed by

GitHub f8bef4aa 903af4cf

+190 -11
+3
apps/aqua/src/index.ts
··· 11 11 import { getSessionAgent } from "./lib/auth"; 12 12 import { RichText } from "@atproto/api"; 13 13 import { sanitizeUrl } from "@braintree/sanitize-url"; 14 + import { getXrpcRouter } from "./xrpc/route"; 14 15 15 16 const HEAD = `<head> 16 17 <link rel="stylesheet" href="/latex.css"> ··· 23 24 app.use((c, next) => setupContext(c, db, logger, next)); 24 25 25 26 app.route("/oauth", getAuthRouter()); 27 + 28 + app.route("/xrpc", getXrpcRouter()); 26 29 27 30 app.get("/client-metadata.json", (c) => { 28 31 return c.json(atclient.clientMetadata);
+89
apps/aqua/src/xrpc/feed/getActorFeed.ts
··· 1 + import { TealContext } from "@/ctx"; 2 + import { db, tealSession, play } from "@teal/db"; 3 + import { eq, and, lt } from "drizzle-orm"; 4 + import { OutputSchema } from "@teal/lexicons/src/types/fm/teal/alpha/feed/getActorFeed"; 5 + 6 + export default async function getActorFeed(c: TealContext) { 7 + const params = c.req.query(); 8 + if (!params.authorDid) { 9 + throw new Error("authorDid is required"); 10 + } 11 + 12 + let limit = 20 13 + 14 + if(params.limit) { 15 + limit = Number(params.limit) 16 + if(limit > 50) throw new Error("Limit is over max allowed.") 17 + } 18 + 19 + // 'and' is here for typing reasons 20 + let whereClause = and(eq(play.authorDid, params.authorDid)); 21 + 22 + // Add cursor pagination if provided 23 + if (params.cursor) { 24 + const [cursorPlay] = await db 25 + .select({ createdAt: play.createdAt }) 26 + .from(play) 27 + .where(eq(play.uri, params.cursor)) 28 + .limit(1); 29 + 30 + if (!cursorPlay) { 31 + throw new Error("Cursor not found"); 32 + } 33 + 34 + whereClause = and(whereClause, lt(play.createdAt, cursorPlay.createdAt)); 35 + } 36 + 37 + const plays = await db 38 + .select() 39 + .from(play) 40 + .where(whereClause) 41 + .orderBy(play.createdAt) 42 + .limit(10); 43 + 44 + if (plays.length === 0) { 45 + throw new Error("Play not found"); 46 + } 47 + 48 + return { 49 + plays: plays.map( 50 + ({ 51 + uri, 52 + authorDid, 53 + createdAt, 54 + indexedAt, 55 + trackName, 56 + trackMbId, 57 + recordingMbId, 58 + duration, 59 + artistNames, 60 + artistMbIds, 61 + releaseName, 62 + releaseMbId, 63 + isrc, 64 + originUrl, 65 + musicServiceBaseDomain, 66 + submissionClientAgent, 67 + playedTime, 68 + }) => ({ 69 + uri, 70 + authorDid, 71 + createdAt, 72 + indexedAt, 73 + trackName, 74 + trackMbId, 75 + recordingMbId, 76 + duration, 77 + artistNames, 78 + artistMbIds, 79 + releaseName, 80 + releaseMbId, 81 + isrc, 82 + originUrl, 83 + musicServiceBaseDomain, 84 + submissionClientAgent, 85 + playedTime, 86 + }), 87 + ), 88 + } as OutputSchema; 89 + }
+51
apps/aqua/src/xrpc/feed/getPlay.ts
··· 1 + import { TealContext } from "@/ctx"; 2 + import { db, tealSession, play } from "@teal/db"; 3 + import { eq, and } from "drizzle-orm"; 4 + import { OutputSchema } from "@teal/lexicons/src/types/fm/teal/alpha/feed/getPlay"; 5 + 6 + export default async function getPlay(c: TealContext) { 7 + // do we have required query params? 8 + const params = c.req.query(); 9 + if (params.authorDid === undefined) { 10 + throw new Error("authorDid is required"); 11 + } 12 + if (!params.rkey) { 13 + throw new Error("rkey is required"); 14 + } 15 + 16 + let res = await db 17 + .select() 18 + .from(play) 19 + .where( 20 + and(eq(play.authorDid, params.authorDid), and(eq(play.uri, params.rkey))), 21 + ) 22 + .execute(); 23 + 24 + if (res.length === 0) { 25 + throw new Error("Play not found"); 26 + } 27 + res[0]; 28 + 29 + // return a PlayView 30 + return { 31 + play: { 32 + uri: res[0].uri, 33 + authorDid: res[0].authorDid, 34 + createdAt: res[0].createdAt, 35 + indexedAt: res[0].indexedAt, 36 + trackName: res[0].trackName, 37 + trackMbId: res[0].trackMbId, 38 + recordingMbId: res[0].recordingMbId, 39 + duration: res[0].duration, 40 + artistNames: res[0].artistNames, 41 + artistMbIds: res[0].artistMbIds, 42 + releaseName: res[0].releaseName, 43 + releaseMbId: res[0].releaseMbId, 44 + isrc: res[0].isrc, 45 + originUrl: res[0].originUrl, 46 + musicServiceBaseDomain: res[0].musicServiceBaseDomain, 47 + submissionClientAgent: res[0].submissionClientAgent, 48 + playedTime: res[0].playedTime, 49 + }, 50 + } as OutputSchema; 51 + }
+16
apps/aqua/src/xrpc/route.ts
··· 1 + import { EnvWithCtx } from "@/ctx"; 2 + import { Hono } from "hono"; 3 + import getPlay from "./feed/getPlay"; 4 + import getActorFeed from "./feed/getActorFeed"; 5 + 6 + // mount this on /xrpc 7 + const app = new Hono<EnvWithCtx>(); 8 + 9 + app.get("fm.teal.alpha.getPlay", async (c) => c.json(await getPlay(c))); 10 + app.get("fm.teal.alpha.feed.getActorFeed", async (c) => 11 + c.json(await getActorFeed(c)), 12 + ); 13 + 14 + export const getXrpcRouter = () => { 15 + return app; 16 + };
+8 -1
packages/db/schema.ts
··· 15 15 toDriver(value: TData): string { 16 16 return JSON.stringify(value); 17 17 }, 18 + // handle single value (no json array) as well 19 + fromDriver(value: string): TData { 20 + if (value[0] === "[") { 21 + return JSON.parse(value); 22 + } 23 + return [value] as TData; 24 + }, 18 25 })(); 19 26 20 27 // Tables ··· 91 98 originUrl: text(), 92 99 /** The base domain of the music service. e.g. music.apple.com, tidal.com, spotify.com. */ 93 100 musicServiceBaseDomain: text(), 94 - /** A user-agent style string specifying the user agent. e.g. tealtracker/0.0.1b */ 101 + /** A user-agent style string specifying the user agent. e.g. fm.teal.frontend/0.0.1b */ 95 102 submissionClientAgent: text(), 96 103 /** The unix timestamp of when the track was played */ 97 104 playedTime: text(),
+5 -1
packages/lexicons/real/fm/teal/alpha/feed/getActorFeed.json
··· 7 7 "type": "query", 8 8 "parameters": { 9 9 "type": "params", 10 - "required": ["cursor"], 10 + "required": ["authorDID"], 11 11 "properties": { 12 12 "authorDID": { 13 13 "type": "string", ··· 17 17 "cursor": { 18 18 "type": "string", 19 19 "description": "The cursor to start the query from" 20 + }, 21 + "limit": { 22 + "type": "integer", 23 + "description": "The upper limit of tracks to get per request. Default is 20, max is 50." 20 24 } 21 25 } 22 26 },
+2 -2
packages/lexicons/real/fm/teal/alpha/feed/play.json
··· 67 67 }, 68 68 "musicServiceBaseDomain": { 69 69 "type": "string", 70 - "description": "The base domain of the music service. e.g. music.apple.com, tidal.com, spotify.com. Defaults to 'local' if not provided." 70 + "description": "The base domain of the music service. e.g. music.apple.com, tidal.com, spotify.com. Defaults to 'local' if unavailable or not provided." 71 71 }, 72 72 "submissionClientAgent": { 73 73 "type": "string", 74 74 "maxLength": 256, 75 75 "maxGraphemes": 2560, 76 - "description": "A user-agent style string specifying the user agent. e.g. tealtracker/0.0.1b (Linux; Android 13; SM-A715F). Defaults to 'manual/unknown' if not provided." 76 + "description": "A metadata string specifying the user agent where the format is `<app-identifier>/<version> (<kernel/OS-base>; <platform/OS-version>; <device-model>)`. If string is provided, only `app-identifier` and `version` are required. `app-identifier` is recommended to be in reverse dns format. Defaults to 'manual/unknown' if unavailable or not provided." 77 77 }, 78 78 "playedTime": { 79 79 "type": "string",
+8 -3
packages/lexicons/src/lexicons.ts
··· 339 339 type: 'query', 340 340 parameters: { 341 341 type: 'params', 342 - required: ['cursor'], 342 + required: ['authorDID'], 343 343 properties: { 344 344 authorDID: { 345 345 type: 'string', ··· 349 349 cursor: { 350 350 type: 'string', 351 351 description: 'The cursor to start the query from', 352 + }, 353 + limit: { 354 + type: 'integer', 355 + description: 356 + 'The upper limit of tracks to get per request. Default is 20, max is 50.', 352 357 }, 353 358 }, 354 359 }, ··· 481 486 musicServiceBaseDomain: { 482 487 type: 'string', 483 488 description: 484 - "The base domain of the music service. e.g. music.apple.com, tidal.com, spotify.com. Defaults to 'local' if not provided.", 489 + "The base domain of the music service. e.g. music.apple.com, tidal.com, spotify.com. Defaults to 'local' if unavailable or not provided.", 485 490 }, 486 491 submissionClientAgent: { 487 492 type: 'string', 488 493 maxLength: 256, 489 494 maxGraphemes: 2560, 490 495 description: 491 - "A user-agent style string specifying the user agent. e.g. tealtracker/0.0.1b (Linux; Android 13; SM-A715F). Defaults to 'manual/unknown' if not provided.", 496 + "A metadata string specifying the user agent. e.g. com.example.frontend/0.0.1b (Linux; Android 13; SM-A715F). Defaults to 'manual/unknown' if unavailable or not provided.", 492 497 }, 493 498 playedTime: { 494 499 type: 'string',
+5 -2
packages/lexicons/src/types/fm/teal/alpha/feed/getActorFeed.ts
··· 11 11 12 12 export interface QueryParams { 13 13 /** The author's DID for the play */ 14 - authorDID?: string 14 + authorDID: string 15 15 /** The cursor to start the query from */ 16 - cursor: string 16 + cursor?: string 17 + /** The upper limit of tracks to get per request. Default is 20, max is 50. */ 18 + limit?: number 17 19 } 18 20 19 21 export type InputSchema = undefined ··· 43 45 input: HandlerInput 44 46 req: express.Request 45 47 res: express.Response 48 + resetRouteRateLimits: () => Promise<void> 46 49 } 47 50 export type Handler<HA extends HandlerAuth = never> = ( 48 51 ctx: HandlerReqCtx<HA>,
+1
packages/lexicons/src/types/fm/teal/alpha/feed/getPlay.ts
··· 43 43 input: HandlerInput 44 44 req: express.Request 45 45 res: express.Response 46 + resetRouteRateLimits: () => Promise<void> 46 47 } 47 48 export type Handler<HA extends HandlerAuth = never> = ( 48 49 ctx: HandlerReqCtx<HA>,
+2 -2
packages/lexicons/src/types/fm/teal/alpha/feed/play.ts
··· 27 27 isrc?: string 28 28 /** The URL associated with this track */ 29 29 originUrl?: string 30 - /** The base domain of the music service. e.g. music.apple.com, tidal.com, spotify.com. Defaults to 'local' if not provided. */ 30 + /** The base domain of the music service. e.g. music.apple.com, tidal.com, spotify.com. Defaults to 'local' if unavailable or not provided. */ 31 31 musicServiceBaseDomain?: string 32 - /** A user-agent style string specifying the user agent. e.g. tealtracker/0.0.1b (Linux; Android 13; SM-A715F). Defaults to 'manual/unknown' if not provided. */ 32 + /** A metadata string specifying the user agent. e.g. com.example.frontend/0.0.1b (Linux; Android 13; SM-A715F). Defaults to 'manual/unknown' if unavailable or not provided. */ 33 33 submissionClientAgent?: string 34 34 /** The unix timestamp of when the track was played */ 35 35 playedTime?: string