···1111import { getSessionAgent } from "./lib/auth";
1212import { RichText } from "@atproto/api";
1313import { sanitizeUrl } from "@braintree/sanitize-url";
1414+import { getXrpcRouter } from "./xrpc/route";
14151516const HEAD = `<head>
1617 <link rel="stylesheet" href="/latex.css">
···2324app.use((c, next) => setupContext(c, db, logger, next));
24252526app.route("/oauth", getAuthRouter());
2727+2828+app.route("/xrpc", getXrpcRouter());
26292730app.get("/client-metadata.json", (c) => {
2831 return c.json(atclient.clientMetadata);
+89
apps/aqua/src/xrpc/feed/getActorFeed.ts
···11+import { TealContext } from "@/ctx";
22+import { db, tealSession, play } from "@teal/db";
33+import { eq, and, lt } from "drizzle-orm";
44+import { OutputSchema } from "@teal/lexicons/src/types/fm/teal/alpha/feed/getActorFeed";
55+66+export default async function getActorFeed(c: TealContext) {
77+ const params = c.req.query();
88+ if (!params.authorDid) {
99+ throw new Error("authorDid is required");
1010+ }
1111+1212+ let limit = 20
1313+1414+ if(params.limit) {
1515+ limit = Number(params.limit)
1616+ if(limit > 50) throw new Error("Limit is over max allowed.")
1717+ }
1818+1919+ // 'and' is here for typing reasons
2020+ let whereClause = and(eq(play.authorDid, params.authorDid));
2121+2222+ // Add cursor pagination if provided
2323+ if (params.cursor) {
2424+ const [cursorPlay] = await db
2525+ .select({ createdAt: play.createdAt })
2626+ .from(play)
2727+ .where(eq(play.uri, params.cursor))
2828+ .limit(1);
2929+3030+ if (!cursorPlay) {
3131+ throw new Error("Cursor not found");
3232+ }
3333+3434+ whereClause = and(whereClause, lt(play.createdAt, cursorPlay.createdAt));
3535+ }
3636+3737+ const plays = await db
3838+ .select()
3939+ .from(play)
4040+ .where(whereClause)
4141+ .orderBy(play.createdAt)
4242+ .limit(10);
4343+4444+ if (plays.length === 0) {
4545+ throw new Error("Play not found");
4646+ }
4747+4848+ return {
4949+ plays: plays.map(
5050+ ({
5151+ uri,
5252+ authorDid,
5353+ createdAt,
5454+ indexedAt,
5555+ trackName,
5656+ trackMbId,
5757+ recordingMbId,
5858+ duration,
5959+ artistNames,
6060+ artistMbIds,
6161+ releaseName,
6262+ releaseMbId,
6363+ isrc,
6464+ originUrl,
6565+ musicServiceBaseDomain,
6666+ submissionClientAgent,
6767+ playedTime,
6868+ }) => ({
6969+ uri,
7070+ authorDid,
7171+ createdAt,
7272+ indexedAt,
7373+ trackName,
7474+ trackMbId,
7575+ recordingMbId,
7676+ duration,
7777+ artistNames,
7878+ artistMbIds,
7979+ releaseName,
8080+ releaseMbId,
8181+ isrc,
8282+ originUrl,
8383+ musicServiceBaseDomain,
8484+ submissionClientAgent,
8585+ playedTime,
8686+ }),
8787+ ),
8888+ } as OutputSchema;
8989+}
+51
apps/aqua/src/xrpc/feed/getPlay.ts
···11+import { TealContext } from "@/ctx";
22+import { db, tealSession, play } from "@teal/db";
33+import { eq, and } from "drizzle-orm";
44+import { OutputSchema } from "@teal/lexicons/src/types/fm/teal/alpha/feed/getPlay";
55+66+export default async function getPlay(c: TealContext) {
77+ // do we have required query params?
88+ const params = c.req.query();
99+ if (params.authorDid === undefined) {
1010+ throw new Error("authorDid is required");
1111+ }
1212+ if (!params.rkey) {
1313+ throw new Error("rkey is required");
1414+ }
1515+1616+ let res = await db
1717+ .select()
1818+ .from(play)
1919+ .where(
2020+ and(eq(play.authorDid, params.authorDid), and(eq(play.uri, params.rkey))),
2121+ )
2222+ .execute();
2323+2424+ if (res.length === 0) {
2525+ throw new Error("Play not found");
2626+ }
2727+ res[0];
2828+2929+ // return a PlayView
3030+ return {
3131+ play: {
3232+ uri: res[0].uri,
3333+ authorDid: res[0].authorDid,
3434+ createdAt: res[0].createdAt,
3535+ indexedAt: res[0].indexedAt,
3636+ trackName: res[0].trackName,
3737+ trackMbId: res[0].trackMbId,
3838+ recordingMbId: res[0].recordingMbId,
3939+ duration: res[0].duration,
4040+ artistNames: res[0].artistNames,
4141+ artistMbIds: res[0].artistMbIds,
4242+ releaseName: res[0].releaseName,
4343+ releaseMbId: res[0].releaseMbId,
4444+ isrc: res[0].isrc,
4545+ originUrl: res[0].originUrl,
4646+ musicServiceBaseDomain: res[0].musicServiceBaseDomain,
4747+ submissionClientAgent: res[0].submissionClientAgent,
4848+ playedTime: res[0].playedTime,
4949+ },
5050+ } as OutputSchema;
5151+}
+16
apps/aqua/src/xrpc/route.ts
···11+import { EnvWithCtx } from "@/ctx";
22+import { Hono } from "hono";
33+import getPlay from "./feed/getPlay";
44+import getActorFeed from "./feed/getActorFeed";
55+66+// mount this on /xrpc
77+const app = new Hono<EnvWithCtx>();
88+99+app.get("fm.teal.alpha.getPlay", async (c) => c.json(await getPlay(c)));
1010+app.get("fm.teal.alpha.feed.getActorFeed", async (c) =>
1111+ c.json(await getActorFeed(c)),
1212+);
1313+1414+export const getXrpcRouter = () => {
1515+ return app;
1616+};
+8-1
packages/db/schema.ts
···1515 toDriver(value: TData): string {
1616 return JSON.stringify(value);
1717 },
1818+ // handle single value (no json array) as well
1919+ fromDriver(value: string): TData {
2020+ if (value[0] === "[") {
2121+ return JSON.parse(value);
2222+ }
2323+ return [value] as TData;
2424+ },
1825 })();
19262027// Tables
···9198 originUrl: text(),
9299 /** The base domain of the music service. e.g. music.apple.com, tidal.com, spotify.com. */
93100 musicServiceBaseDomain: text(),
9494- /** A user-agent style string specifying the user agent. e.g. tealtracker/0.0.1b */
101101+ /** A user-agent style string specifying the user agent. e.g. fm.teal.frontend/0.0.1b */
95102 submissionClientAgent: text(),
96103 /** The unix timestamp of when the track was played */
97104 playedTime: text(),
···6767 },
6868 "musicServiceBaseDomain": {
6969 "type": "string",
7070- "description": "The base domain of the music service. e.g. music.apple.com, tidal.com, spotify.com. Defaults to 'local' if not provided."
7070+ "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."
7171 },
7272 "submissionClientAgent": {
7373 "type": "string",
7474 "maxLength": 256,
7575 "maxGraphemes": 2560,
7676- "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."
7676+ "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."
7777 },
7878 "playedTime": {
7979 "type": "string",
+8-3
packages/lexicons/src/lexicons.ts
···339339 type: 'query',
340340 parameters: {
341341 type: 'params',
342342- required: ['cursor'],
342342+ required: ['authorDID'],
343343 properties: {
344344 authorDID: {
345345 type: 'string',
···349349 cursor: {
350350 type: 'string',
351351 description: 'The cursor to start the query from',
352352+ },
353353+ limit: {
354354+ type: 'integer',
355355+ description:
356356+ 'The upper limit of tracks to get per request. Default is 20, max is 50.',
352357 },
353358 },
354359 },
···481486 musicServiceBaseDomain: {
482487 type: 'string',
483488 description:
484484- "The base domain of the music service. e.g. music.apple.com, tidal.com, spotify.com. Defaults to 'local' if not provided.",
489489+ "The base domain of the music service. e.g. music.apple.com, tidal.com, spotify.com. Defaults to 'local' if unavailable or not provided.",
485490 },
486491 submissionClientAgent: {
487492 type: 'string',
488493 maxLength: 256,
489494 maxGraphemes: 2560,
490495 description:
491491- "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.",
496496+ "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.",
492497 },
493498 playedTime: {
494499 type: 'string',
···11111212export interface QueryParams {
1313 /** The author's DID for the play */
1414- authorDID?: string
1414+ authorDID: string
1515 /** The cursor to start the query from */
1616- cursor: string
1616+ cursor?: string
1717+ /** The upper limit of tracks to get per request. Default is 20, max is 50. */
1818+ limit?: number
1719}
18201921export type InputSchema = undefined
···4345 input: HandlerInput
4446 req: express.Request
4547 res: express.Response
4848+ resetRouteRateLimits: () => Promise<void>
4649}
4750export type Handler<HA extends HandlerAuth = never> = (
4851 ctx: HandlerReqCtx<HA>,
···2727 isrc?: string
2828 /** The URL associated with this track */
2929 originUrl?: string
3030- /** The base domain of the music service. e.g. music.apple.com, tidal.com, spotify.com. Defaults to 'local' if not provided. */
3030+ /** The base domain of the music service. e.g. music.apple.com, tidal.com, spotify.com. Defaults to 'local' if unavailable or not provided. */
3131 musicServiceBaseDomain?: string
3232- /** 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. */
3232+ /** 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. */
3333 submissionClientAgent?: string
3434 /** The unix timestamp of when the track was played */
3535 playedTime?: string