diff --git a/apps/api/lexicons/actor/defs.json b/apps/api/lexicons/actor/defs.json index fc2ff847..adb5dc6b 100644 --- a/apps/api/lexicons/actor/defs.json +++ b/apps/api/lexicons/actor/defs.json @@ -118,6 +118,67 @@ } } } + }, + "compatibilityViewBasic": { + "type": "object", + "properties": { + "compatibilityLevel": { + "type": "integer" + }, + "compatibilityPercentage": { + "type": "integer" + }, + "sharedArtists": { + "type": "integer" + }, + "topSharedArtistNames": { + "type": "array", + "items": { + "type": "string" + } + }, + "topSharedDetailedArtists": { + "type": "array", + "items": { + "type": "ref", + "ref": "app.rocksky.actor.defs#artistViewBasic" + } + }, + "user1ArtistCount": { + "type": "integer" + }, + "user2ArtistCount": { + "type": "integer" + } + } + }, + "artistViewBasic": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "picture": { + "type": "string", + "format": "uri" + }, + "uri": { + "type": "string", + "format": "at-uri" + }, + "user1Rank": { + "type": "integer" + }, + "user2Rank": { + "type": "integer" + }, + "weight": { + "type": "integer" + } + } } } } diff --git a/apps/api/lexicons/actor/getActorCompatibility.json b/apps/api/lexicons/actor/getActorCompatibility.json new file mode 100644 index 00000000..72eb85c5 --- /dev/null +++ b/apps/api/lexicons/actor/getActorCompatibility.json @@ -0,0 +1,35 @@ +{ + "lexicon": 1, + "id": "app.rocksky.actor.getActorCompatibility", + "defs": { + "main": { + "type": "query", + "description": "Get compatibility for an actor", + "parameters": { + "type": "params", + "required": [ + "did" + ], + "properties": { + "did": { + "type": "string", + "description": "DID or handle to get compatibility for", + "format": "at-identifier" + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "properties": { + "compatibility": { + "type": "ref", + "ref": "app.rocksky.actor.defs#compatibilityViewBasic" + } + } + } + } + } + } +} diff --git a/apps/api/pkl/defs/actor/defs.pkl b/apps/api/pkl/defs/actor/defs.pkl index 76263947..ce7d754d 100644 --- a/apps/api/pkl/defs/actor/defs.pkl +++ b/apps/api/pkl/defs/actor/defs.pkl @@ -139,4 +139,70 @@ defs = new Mapping { } } } + ["compatibilityViewBasic"] { + type = "object" + properties { + ["compatibilityLevel"] = new IntegerType { + type = "integer" + } + ["compatibilityPercentage"] = new IntegerType { + type = "integer" + } + ["sharedArtists"] = new IntegerType { + type = "integer" + } + ["topSharedArtistNames"] = new Array { + type = "array" + items = new StringType { + type = "string" + } + } + ["topSharedDetailedArtists"] = new Array { + type = "array" + items = new Ref { + ref = "app.rocksky.actor.defs#artistViewBasic" + } + } + ["user1ArtistCount"] = new IntegerType { + type = "integer" + } + ["user2ArtistCount"] = new IntegerType { + type = "integer" + } + } + } + ["artistViewBasic"] { + type = "object" + properties { + ["id"] = new StringType { + type = "string" + } + + ["name"] = new StringType { + type = "string" + } + + ["picture"] = new StringType { + type = "string" + format = "uri" + } + + ["uri"] = new StringType { + type = "string" + format = "at-uri" + } + + ["user1Rank"] = new IntegerType { + type = "integer" + } + + ["user2Rank"] = new IntegerType { + type = "integer" + } + + ["weight"] = new IntegerType { + type = "integer" + } + } + } } diff --git a/apps/api/pkl/defs/actor/getActorCompatibility.pkl b/apps/api/pkl/defs/actor/getActorCompatibility.pkl new file mode 100644 index 00000000..47d41589 --- /dev/null +++ b/apps/api/pkl/defs/actor/getActorCompatibility.pkl @@ -0,0 +1,31 @@ +amends "../../schema/lexicon.pkl" + +lexicon = 1 +id = "app.rocksky.actor.getActorCompatibility" +defs = new Mapping { + ["main"] { + type = "query" + description = "Get compatibility for an actor" + parameters = new Params { + required = List("did") + properties { + ["did"] = new StringType { + description = "DID or handle to get compatibility for" + format = "at-identifier" + } + } + } + output { + encoding = "application/json" + schema = new ObjectType { + type = "object" + properties = new Mapping { + ["compatibility"] = new Ref { + type = "ref" + ref = "app.rocksky.actor.defs#compatibilityViewBasic" + } + } + } + } + } +} diff --git a/apps/api/src/lexicon/index.ts b/apps/api/src/lexicon/index.ts index 81f3d116..564c946f 100644 --- a/apps/api/src/lexicon/index.ts +++ b/apps/api/src/lexicon/index.ts @@ -16,6 +16,7 @@ import type * as FmTealAlphaFeedGetActorFeed from "./types/fm/teal/alpha/feed/ge import type * as FmTealAlphaFeedGetPlay from "./types/fm/teal/alpha/feed/getPlay"; import type * as AppRockskyActorGetActorAlbums from "./types/app/rocksky/actor/getActorAlbums"; import type * as AppRockskyActorGetActorArtists from "./types/app/rocksky/actor/getActorArtists"; +import type * as AppRockskyActorGetActorCompatibility from "./types/app/rocksky/actor/getActorCompatibility"; import type * as AppRockskyActorGetActorLovedSongs from "./types/app/rocksky/actor/getActorLovedSongs"; import type * as AppRockskyActorGetActorNeighbours from "./types/app/rocksky/actor/getActorNeighbours"; import type * as AppRockskyActorGetActorPlaylists from "./types/app/rocksky/actor/getActorPlaylists"; @@ -304,6 +305,17 @@ export class AppRockskyActorNS { return this._server.xrpc.method(nsid, cfg); } + getActorCompatibility( + cfg: ConfigOf< + AV, + AppRockskyActorGetActorCompatibility.Handler>, + AppRockskyActorGetActorCompatibility.HandlerReqCtx> + >, + ) { + const nsid = "app.rocksky.actor.getActorCompatibility"; // @ts-ignore + return this._server.xrpc.method(nsid, cfg); + } + getActorLovedSongs( cfg: ConfigOf< AV, diff --git a/apps/api/src/lexicon/lexicons.ts b/apps/api/src/lexicon/lexicons.ts index b3d84ffb..b468ba12 100644 --- a/apps/api/src/lexicon/lexicons.ts +++ b/apps/api/src/lexicon/lexicons.ts @@ -709,6 +709,67 @@ export const schemaDict = { }, }, }, + compatibilityViewBasic: { + type: "object", + properties: { + compatibilityLevel: { + type: "integer", + }, + compatibilityPercentage: { + type: "integer", + }, + sharedArtists: { + type: "integer", + }, + topSharedArtistNames: { + type: "array", + items: { + type: "string", + }, + }, + topSharedDetailedArtists: { + type: "array", + items: { + type: "ref", + ref: "lex:app.rocksky.actor.defs#artistViewBasic", + }, + }, + user1ArtistCount: { + type: "integer", + }, + user2ArtistCount: { + type: "integer", + }, + }, + }, + artistViewBasic: { + type: "object", + properties: { + id: { + type: "string", + }, + name: { + type: "string", + }, + picture: { + type: "string", + format: "uri", + }, + uri: { + type: "string", + format: "at-uri", + }, + user1Rank: { + type: "integer", + }, + user2Rank: { + type: "integer", + }, + weight: { + type: "integer", + }, + }, + }, }, }, AppRockskyActorGetActorAlbums: { @@ -803,6 +864,39 @@ export const schemaDict = { }, }, }, + AppRockskyActorGetActorCompatibility: { + lexicon: 1, + id: "app.rocksky.actor.getActorCompatibility", + defs: { + main: { + type: "query", + description: "Get compatibility for an actor", + parameters: { + type: "params", + required: ["did"], + properties: { + did: { + type: "string", + description: "DID or handle to get compatibility for", + format: "at-identifier", + }, + }, + }, + output: { + encoding: "application/json", + schema: { + type: "object", + properties: { + compatibility: { + type: "ref", + ref: "lex:app.rocksky.actor.defs#compatibilityViewBasic", + }, + }, + }, + }, + }, + }, + }, AppRockskyActorGetActorLovedSongs: { lexicon: 1, id: "app.rocksky.actor.getActorLovedSongs", @@ -5796,6 +5890,8 @@ export const ids = { AppRockskyActorDefs: "app.rocksky.actor.defs", AppRockskyActorGetActorAlbums: "app.rocksky.actor.getActorAlbums", AppRockskyActorGetActorArtists: "app.rocksky.actor.getActorArtists", + AppRockskyActorGetActorCompatibility: + "app.rocksky.actor.getActorCompatibility", AppRockskyActorGetActorLovedSongs: "app.rocksky.actor.getActorLovedSongs", AppRockskyActorGetActorNeighbours: "app.rocksky.actor.getActorNeighbours", AppRockskyActorGetActorPlaylists: "app.rocksky.actor.getActorPlaylists", diff --git a/apps/api/src/lexicon/types/app/rocksky/actor/defs.ts b/apps/api/src/lexicon/types/app/rocksky/actor/defs.ts index f3b25579..14a9b3c9 100644 --- a/apps/api/src/lexicon/types/app/rocksky/actor/defs.ts +++ b/apps/api/src/lexicon/types/app/rocksky/actor/defs.ts @@ -96,3 +96,51 @@ export function isNeighbourViewBasic(v: unknown): v is NeighbourViewBasic { export function validateNeighbourViewBasic(v: unknown): ValidationResult { return lexicons.validate("app.rocksky.actor.defs#neighbourViewBasic", v); } + +export interface CompatibilityViewBasic { + compatibilityLevel?: number; + compatibilityPercentage?: number; + sharedArtists?: number; + topSharedArtistNames?: string[]; + topSharedDetailedArtists?: ArtistViewBasic[]; + user1ArtistCount?: number; + user2ArtistCount?: number; + [k: string]: unknown; +} + +export function isCompatibilityViewBasic( + v: unknown, +): v is CompatibilityViewBasic { + return ( + isObj(v) && + hasProp(v, "$type") && + v.$type === "app.rocksky.actor.defs#compatibilityViewBasic" + ); +} + +export function validateCompatibilityViewBasic(v: unknown): ValidationResult { + return lexicons.validate("app.rocksky.actor.defs#compatibilityViewBasic", v); +} + +export interface ArtistViewBasic { + id?: string; + name?: string; + picture?: string; + uri?: string; + user1Rank?: number; + user2Rank?: number; + weight?: number; + [k: string]: unknown; +} + +export function isArtistViewBasic(v: unknown): v is ArtistViewBasic { + return ( + isObj(v) && + hasProp(v, "$type") && + v.$type === "app.rocksky.actor.defs#artistViewBasic" + ); +} + +export function validateArtistViewBasic(v: unknown): ValidationResult { + return lexicons.validate("app.rocksky.actor.defs#artistViewBasic", v); +} diff --git a/apps/api/src/lexicon/types/app/rocksky/actor/getActorCompatibility.ts b/apps/api/src/lexicon/types/app/rocksky/actor/getActorCompatibility.ts new file mode 100644 index 00000000..242cb566 --- /dev/null +++ b/apps/api/src/lexicon/types/app/rocksky/actor/getActorCompatibility.ts @@ -0,0 +1,48 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import type express from "express"; +import { ValidationResult, BlobRef } from "@atproto/lexicon"; +import { lexicons } from "../../../../lexicons"; +import { isObj, hasProp } from "../../../../util"; +import { CID } from "multiformats/cid"; +import type { HandlerAuth, HandlerPipeThrough } from "@atproto/xrpc-server"; +import type * as AppRockskyActorDefs from "./defs"; + +export interface QueryParams { + /** DID or handle to get compatibility for */ + did: string; +} + +export type InputSchema = undefined; + +export interface OutputSchema { + compatibility?: AppRockskyActorDefs.CompatibilityViewBasic; + [k: string]: unknown; +} + +export type HandlerInput = undefined; + +export interface HandlerSuccess { + encoding: "application/json"; + body: OutputSchema; + headers?: { [key: string]: string }; +} + +export interface HandlerError { + status: number; + message?: string; +} + +export type HandlerOutput = HandlerError | HandlerSuccess | HandlerPipeThrough; +export type HandlerReqCtx = { + auth: HA; + params: QueryParams; + input: HandlerInput; + req: express.Request; + res: express.Response; + resetRouteRateLimits: () => Promise; +}; +export type Handler = ( + ctx: HandlerReqCtx, +) => Promise | HandlerOutput; diff --git a/apps/api/src/xrpc/app/rocksky/actor/getActorCompatibility.ts b/apps/api/src/xrpc/app/rocksky/actor/getActorCompatibility.ts new file mode 100644 index 00000000..a507bd9e --- /dev/null +++ b/apps/api/src/xrpc/app/rocksky/actor/getActorCompatibility.ts @@ -0,0 +1,108 @@ +import type { Context } from "context"; +import { Effect, pipe } from "effect"; +import type { Server } from "lexicon"; +import type { QueryParams } from "lexicon/types/app/rocksky/actor/getActorCompatibility"; +import type { CompatibilityViewBasic } from "lexicon/types/app/rocksky/actor/defs"; +import { deepCamelCaseKeys } from "lib"; +import users from "schema/users"; +import { eq, or } from "drizzle-orm"; +import { HandlerAuth } from "@atproto/xrpc-server"; + +export default function (server: Server, ctx: Context) { + const getActorCompatibility = (params: QueryParams, auth: HandlerAuth) => + pipe( + { params, ctx, did: auth.credentials?.did }, + retrieve, + Effect.flatMap(presentation), + Effect.retry({ times: 3 }), + Effect.timeout("120 seconds"), + Effect.catchAll((err) => { + console.error(err); + return Effect.succeed({ comptibility: null }); + }), + ); + server.app.rocksky.actor.getActorCompatibility({ + auth: ctx.authVerifier, + handler: async ({ params, auth }) => { + const result = await Effect.runPromise( + getActorCompatibility(params, auth), + ); + return { + encoding: "application/json", + body: result, + }; + }, + }); +} + +const retrieve = ({ + params, + ctx, + did, +}: { + params: QueryParams; + ctx: Context; + did: string | undefined; +}): Effect.Effect<{ data: Compatibility[] }, Error> => { + return Effect.tryPromise({ + try: async () => { + if (!did) { + throw new Error(`User not authenticated`); + } + + const user1 = await ctx.db + .select() + .from(users) + .where(eq(users.did, did)) + .execute() + .then((rows) => rows[0]); + + if (!user1) { + throw new Error(`User1 not found`); + } + + const user2 = await ctx.db + .select() + .from(users) + .where(or(eq(users.did, params.did), eq(users.handle, params.did))) + .execute() + .then((rows) => rows[0]); + + if (!user2) { + throw new Error(`User2 not found`); + } + + return ctx.analytics.post("library.getCompatibility", { + user_id1: user1.id, + user_id2: user2.id, + }); + }, + catch: (error) => new Error(`Failed to retrieve compatibility: ${error}`), + }); +}; + +const presentation = ({ + data, +}: { + data: Compatibility[]; +}): Effect.Effect<{ compatibility: CompatibilityViewBasic }, never> => { + return Effect.sync(() => ({ compatibility: deepCamelCaseKeys(data) })); +}; + +type Compatibility = { + compatibility_level: number; + compatibility_percentage: number; + shared_artists: number; + top_shared_artists: string[]; + top_shared_detailed_artists: { + id: string; + name: string; + picture: string; + uri: string; + user1_rank: number; + user2_rank: number; + weight: number; + }[]; + user1_artist_count: number; + user2_artist_count: number; +}; diff --git a/apps/api/src/xrpc/index.ts b/apps/api/src/xrpc/index.ts index 5c294402..360bf2ea 100644 --- a/apps/api/src/xrpc/index.ts +++ b/apps/api/src/xrpc/index.ts @@ -81,6 +81,7 @@ import getFollows from "./app/rocksky/graph/getFollows"; import getKnownFollowers from "./app/rocksky/graph/getKnownFollowers"; import unfollowAccount from "./app/rocksky/graph/unfollowAccount"; import getActorNeighbours from "./app/rocksky/actor/getActorNeighbours"; +import getActorCompatibility from "./app/rocksky/actor/getActorCompatibility"; export default function (server: Server, ctx: Context) { // app.rocksky @@ -165,6 +166,7 @@ export default function (server: Server, ctx: Context) { getKnownFollowers(server, ctx); unfollowAccount(server, ctx); getActorNeighbours(server, ctx); + getActorCompatibility(server, ctx); return server; } diff --git a/apps/web/src/pages/home/nowplayings/NowPlayings.tsx b/apps/web/src/pages/home/nowplayings/NowPlayings.tsx index a150a2dd..829e683d 100644 --- a/apps/web/src/pages/home/nowplayings/NowPlayings.tsx +++ b/apps/web/src/pages/home/nowplayings/NowPlayings.tsx @@ -237,7 +237,7 @@ function NowPlayings() { {showLeftChevron && (