From 03d51c35e8f5c8294d7de16bc9da421da9ec524e Mon Sep 17 00:00:00 2001 From: Tsiry Sandratraina Date: Sun, 4 Jan 2026 12:57:37 +0300 Subject: [PATCH] Add actor neighbours endpoint and schema Introduce neighbourViewBasic schema and app.rocksky.actor.getActorNeighbours lexicon. Implement XRPC handler, wire into TS lexicon index and types, and add analytics types/handler support for neighbour data --- apps/api/lexicons/actor/defs.json | 45 +++++++++ .../lexicons/actor/getActorNeighbours.json | 38 ++++++++ apps/api/pkl/defs/actor/defs.pkl | 57 +++++++++++- .../api/pkl/defs/actor/getActorNeighbours.pkl | 33 +++++++ apps/api/src/lexicon/index.ts | 12 +++ apps/api/src/lexicon/lexicons.ts | 82 +++++++++++++++++ .../lexicon/types/app/rocksky/actor/defs.ts | 31 +++++++ .../app/rocksky/actor/getActorNeighbours.ts | 48 ++++++++++ .../xrpc/app/rocksky/actor/getActorAlbums.ts | 2 +- .../xrpc/app/rocksky/actor/getActorArtists.ts | 2 +- .../app/rocksky/actor/getActorLovedSongs.ts | 2 +- .../app/rocksky/actor/getActorNeighbours.ts | 86 +++++++++++++++++ .../app/rocksky/actor/getActorPlaylists.ts | 2 +- .../app/rocksky/actor/getActorScrobbles.ts | 2 +- .../xrpc/app/rocksky/actor/getActorSongs.ts | 2 +- .../src/xrpc/app/rocksky/actor/getProfile.ts | 2 +- crates/analytics/src/handlers/mod.rs | 3 +- crates/analytics/src/handlers/stats.rs | 92 ++++++++++++++++++- crates/analytics/src/types/stats.rs | 26 ++++++ 19 files changed, 555 insertions(+), 12 deletions(-) create mode 100644 apps/api/lexicons/actor/getActorNeighbours.json create mode 100644 apps/api/pkl/defs/actor/getActorNeighbours.pkl create mode 100644 apps/api/src/lexicon/types/app/rocksky/actor/getActorNeighbours.ts create mode 100644 apps/api/src/xrpc/app/rocksky/actor/getActorNeighbours.ts diff --git a/apps/api/lexicons/actor/defs.json b/apps/api/lexicons/actor/defs.json index a253b5e2..fc2ff847 100644 --- a/apps/api/lexicons/actor/defs.json +++ b/apps/api/lexicons/actor/defs.json @@ -73,6 +73,51 @@ "format": "datetime" } } + }, + "neighbourViewBasic": { + "type": "object", + "properties": { + "userId": { + "type": "string" + }, + "did": { + "type": "string" + }, + "handle": { + "type": "string" + }, + "displayName": { + "type": "string" + }, + "avatar": { + "type": "string", + "description": "The URL of the actor's avatar image.", + "format": "uri" + }, + "sharedArtistsCount": { + "type": "integer", + "description": "The number of artists shared with the actor." + }, + "similarityScore": { + "type": "integer", + "description": "The similarity score with the actor." + }, + "topSharedArtistNames": { + "type": "array", + "description": "The top shared artist names with the actor.", + "items": { + "type": "string" + } + }, + "topSharedArtistsDetails": { + "type": "array", + "description": "The top shared artist details with the actor.", + "items": { + "type": "ref", + "ref": "app.rocksky.artist.defs#artistViewBasic" + } + } + } } } } diff --git a/apps/api/lexicons/actor/getActorNeighbours.json b/apps/api/lexicons/actor/getActorNeighbours.json new file mode 100644 index 00000000..c478ad41 --- /dev/null +++ b/apps/api/lexicons/actor/getActorNeighbours.json @@ -0,0 +1,38 @@ +{ + "lexicon": 1, + "id": "app.rocksky.actor.getActorNeighbours", + "defs": { + "main": { + "type": "query", + "description": "Get neighbours for an actor", + "parameters": { + "type": "params", + "required": [ + "did" + ], + "properties": { + "did": { + "type": "string", + "description": "The DID or handle of the actor", + "format": "at-identifier" + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "properties": { + "neighbours": { + "type": "array", + "items": { + "type": "ref", + "ref": "app.rocksky.actor.defs#neighbourViewBasic" + } + } + } + } + } + } + } +} diff --git a/apps/api/pkl/defs/actor/defs.pkl b/apps/api/pkl/defs/actor/defs.pkl index 0d0218b6..76263947 100644 --- a/apps/api/pkl/defs/actor/defs.pkl +++ b/apps/api/pkl/defs/actor/defs.pkl @@ -1,4 +1,4 @@ -amends "../../schema/lexicon.pkl" +amends "../../schema/lexicon.pkl" lexicon = 1 id = "app.rocksky.actor.defs" @@ -43,7 +43,6 @@ defs = new Mapping { format = "datetime" description = "The date and time when the actor was last updated." } - } } ["profileViewBasic"] { @@ -88,4 +87,56 @@ defs = new Mapping { } } } -} \ No newline at end of file + ["neighbourViewBasic"] { + type = "object" + properties { + ["userId"] = new StringType { + type = "string" + } + + ["did"] = new StringType { + type = "string" + } + + ["handle"] = new StringType { + type = "string" + } + + ["displayName"] = new StringType { + type = "string" + } + + ["avatar"] = new StringType { + type = "string" + format = "uri" + description = "The URL of the actor's avatar image." + } + + ["sharedArtistsCount"] = new IntegerType { + type = "integer" + description = "The number of artists shared with the actor." + } + + ["similarityScore"] = new IntegerType { + type = "integer" + description = "The similarity score with the actor." + } + + ["topSharedArtistNames"] = new Array { + type = "array" + items = new StringType { + type = "string" + } + description = "The top shared artist names with the actor." + } + + ["topSharedArtistsDetails"] = new Array { + type = "array" + items = new Ref { + ref = "app.rocksky.artist.defs#artistViewBasic" + } + description = "The top shared artist details with the actor." + } + } + } +} diff --git a/apps/api/pkl/defs/actor/getActorNeighbours.pkl b/apps/api/pkl/defs/actor/getActorNeighbours.pkl new file mode 100644 index 00000000..11cfeeab --- /dev/null +++ b/apps/api/pkl/defs/actor/getActorNeighbours.pkl @@ -0,0 +1,33 @@ +amends "../../schema/lexicon.pkl" + +lexicon = 1 +id = "app.rocksky.actor.getActorNeighbours" +defs = new Mapping { + ["main"] { + type = "query" + description = "Get neighbours for an actor" + parameters = new Params { + required = List("did") + properties { + ["did"] = new StringType { + description = "The DID or handle of the actor" + format = "at-identifier" + } + } + } + output { + encoding = "application/json" + schema = new ObjectType { + type = "object" + properties = new Mapping { + ["neighbours"] = new Array { + type = "array" + items = new Ref { + ref = "app.rocksky.actor.defs#neighbourViewBasic" + } + } + } + } + } + } +} diff --git a/apps/api/src/lexicon/index.ts b/apps/api/src/lexicon/index.ts index a1ef62a7..81f3d116 100644 --- a/apps/api/src/lexicon/index.ts +++ b/apps/api/src/lexicon/index.ts @@ -17,6 +17,7 @@ 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 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"; import type * as AppRockskyActorGetActorScrobbles from "./types/app/rocksky/actor/getActorScrobbles"; import type * as AppRockskyActorGetActorSongs from "./types/app/rocksky/actor/getActorSongs"; @@ -314,6 +315,17 @@ export class AppRockskyActorNS { return this._server.xrpc.method(nsid, cfg); } + getActorNeighbours( + cfg: ConfigOf< + AV, + AppRockskyActorGetActorNeighbours.Handler>, + AppRockskyActorGetActorNeighbours.HandlerReqCtx> + >, + ) { + const nsid = "app.rocksky.actor.getActorNeighbours"; // @ts-ignore + return this._server.xrpc.method(nsid, cfg); + } + getActorPlaylists( cfg: ConfigOf< AV, diff --git a/apps/api/src/lexicon/lexicons.ts b/apps/api/src/lexicon/lexicons.ts index a37870f1..b3d84ffb 100644 --- a/apps/api/src/lexicon/lexicons.ts +++ b/apps/api/src/lexicon/lexicons.ts @@ -664,6 +664,51 @@ export const schemaDict = { }, }, }, + neighbourViewBasic: { + type: "object", + properties: { + userId: { + type: "string", + }, + did: { + type: "string", + }, + handle: { + type: "string", + }, + displayName: { + type: "string", + }, + avatar: { + type: "string", + description: "The URL of the actor's avatar image.", + format: "uri", + }, + sharedArtistsCount: { + type: "integer", + description: "The number of artists shared with the actor.", + }, + similarityScore: { + type: "integer", + description: "The similarity score with the actor.", + }, + topSharedArtistNames: { + type: "array", + description: "The top shared artist names with the actor.", + items: { + type: "string", + }, + }, + topSharedArtistsDetails: { + type: "array", + description: "The top shared artist details with the actor.", + items: { + type: "ref", + ref: "lex:app.rocksky.artist.defs#artistViewBasic", + }, + }, + }, + }, }, }, AppRockskyActorGetActorAlbums: { @@ -804,6 +849,42 @@ export const schemaDict = { }, }, }, + AppRockskyActorGetActorNeighbours: { + lexicon: 1, + id: "app.rocksky.actor.getActorNeighbours", + defs: { + main: { + type: "query", + description: "Get neighbours for an actor", + parameters: { + type: "params", + required: ["did"], + properties: { + did: { + type: "string", + description: "The DID or handle of the actor", + format: "at-identifier", + }, + }, + }, + output: { + encoding: "application/json", + schema: { + type: "object", + properties: { + neighbours: { + type: "array", + items: { + type: "ref", + ref: "lex:app.rocksky.actor.defs#neighbourViewBasic", + }, + }, + }, + }, + }, + }, + }, + }, AppRockskyActorGetActorPlaylists: { lexicon: 1, id: "app.rocksky.actor.getActorPlaylists", @@ -5716,6 +5797,7 @@ export const ids = { AppRockskyActorGetActorAlbums: "app.rocksky.actor.getActorAlbums", AppRockskyActorGetActorArtists: "app.rocksky.actor.getActorArtists", AppRockskyActorGetActorLovedSongs: "app.rocksky.actor.getActorLovedSongs", + AppRockskyActorGetActorNeighbours: "app.rocksky.actor.getActorNeighbours", AppRockskyActorGetActorPlaylists: "app.rocksky.actor.getActorPlaylists", AppRockskyActorGetActorScrobbles: "app.rocksky.actor.getActorScrobbles", AppRockskyActorGetActorSongs: "app.rocksky.actor.getActorSongs", 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 3275091e..f3b25579 100644 --- a/apps/api/src/lexicon/types/app/rocksky/actor/defs.ts +++ b/apps/api/src/lexicon/types/app/rocksky/actor/defs.ts @@ -5,6 +5,7 @@ import { type ValidationResult, BlobRef } from "@atproto/lexicon"; import { lexicons } from "../../../../lexicons"; import { isObj, hasProp } from "../../../../util"; import { CID } from "multiformats/cid"; +import type * as AppRockskyArtistDefs from "../artist/defs"; export interface ProfileViewDetailed { /** The unique identifier of the actor. */ @@ -65,3 +66,33 @@ export function isProfileViewBasic(v: unknown): v is ProfileViewBasic { export function validateProfileViewBasic(v: unknown): ValidationResult { return lexicons.validate("app.rocksky.actor.defs#profileViewBasic", v); } + +export interface NeighbourViewBasic { + userId?: string; + did?: string; + handle?: string; + displayName?: string; + /** The URL of the actor's avatar image. */ + avatar?: string; + /** The number of artists shared with the actor. */ + sharedArtistsCount?: number; + /** The similarity score with the actor. */ + similarityScore?: number; + /** The top shared artist names with the actor. */ + topSharedArtistNames?: string[]; + /** The top shared artist details with the actor. */ + topSharedArtistsDetails?: AppRockskyArtistDefs.ArtistViewBasic[]; + [k: string]: unknown; +} + +export function isNeighbourViewBasic(v: unknown): v is NeighbourViewBasic { + return ( + isObj(v) && + hasProp(v, "$type") && + v.$type === "app.rocksky.actor.defs#neighbourViewBasic" + ); +} + +export function validateNeighbourViewBasic(v: unknown): ValidationResult { + return lexicons.validate("app.rocksky.actor.defs#neighbourViewBasic", v); +} diff --git a/apps/api/src/lexicon/types/app/rocksky/actor/getActorNeighbours.ts b/apps/api/src/lexicon/types/app/rocksky/actor/getActorNeighbours.ts new file mode 100644 index 00000000..a3e054f8 --- /dev/null +++ b/apps/api/src/lexicon/types/app/rocksky/actor/getActorNeighbours.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 { + /** The DID or handle of the actor */ + did: string; +} + +export type InputSchema = undefined; + +export interface OutputSchema { + neighbours?: AppRockskyActorDefs.NeighbourViewBasic[]; + [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/getActorAlbums.ts b/apps/api/src/xrpc/app/rocksky/actor/getActorAlbums.ts index dc2ce458..2ca96dca 100644 --- a/apps/api/src/xrpc/app/rocksky/actor/getActorAlbums.ts +++ b/apps/api/src/xrpc/app/rocksky/actor/getActorAlbums.ts @@ -6,7 +6,7 @@ import type { AlbumViewBasic } from "lexicon/types/app/rocksky/album/defs"; import { deepCamelCaseKeys } from "lib"; export default function (server: Server, ctx: Context) { - const getActorAlbums = (params) => + const getActorAlbums = (params: QueryParams) => pipe( { params, ctx }, retrieve, diff --git a/apps/api/src/xrpc/app/rocksky/actor/getActorArtists.ts b/apps/api/src/xrpc/app/rocksky/actor/getActorArtists.ts index 583ec289..26883f31 100644 --- a/apps/api/src/xrpc/app/rocksky/actor/getActorArtists.ts +++ b/apps/api/src/xrpc/app/rocksky/actor/getActorArtists.ts @@ -6,7 +6,7 @@ import type { ArtistViewBasic } from "lexicon/types/app/rocksky/artist/defs"; import { deepCamelCaseKeys } from "lib"; export default function (server: Server, ctx: Context) { - const getActorArtists = (params) => + const getActorArtists = (params: QueryParams) => pipe( { params, ctx }, retrieve, diff --git a/apps/api/src/xrpc/app/rocksky/actor/getActorLovedSongs.ts b/apps/api/src/xrpc/app/rocksky/actor/getActorLovedSongs.ts index 60a51636..099f842e 100644 --- a/apps/api/src/xrpc/app/rocksky/actor/getActorLovedSongs.ts +++ b/apps/api/src/xrpc/app/rocksky/actor/getActorLovedSongs.ts @@ -8,7 +8,7 @@ import tables from "schema"; import type { SelectTrack } from "schema/tracks"; export default function (server: Server, ctx: Context) { - const getActorLovedSongs = (params) => + const getActorLovedSongs = (params: QueryParams) => pipe( { params, ctx }, retrieve, diff --git a/apps/api/src/xrpc/app/rocksky/actor/getActorNeighbours.ts b/apps/api/src/xrpc/app/rocksky/actor/getActorNeighbours.ts new file mode 100644 index 00000000..7f40e3ad --- /dev/null +++ b/apps/api/src/xrpc/app/rocksky/actor/getActorNeighbours.ts @@ -0,0 +1,86 @@ +import type { Context } from "context"; +import { Effect, pipe } from "effect"; +import type { Server } from "lexicon"; +import type { QueryParams } from "lexicon/types/app/rocksky/actor/getActorNeighbours"; +import type { NeighbourViewBasic } from "lexicon/types/app/rocksky/actor/defs"; +import { deepCamelCaseKeys } from "lib"; +import users from "schema/users"; +import { eq, or } from "drizzle-orm"; + +export default function (server: Server, ctx: Context) { + const getActorNeighbours = (params: QueryParams) => + pipe( + { params, ctx }, + retrieve, + Effect.flatMap(presentation), + Effect.retry({ times: 3 }), + Effect.timeout("120 seconds"), + Effect.catchAll((err) => { + console.error(err); + return Effect.succeed({ neighbours: [] }); + }), + ); + server.app.rocksky.actor.getActorNeighbours({ + handler: async ({ params }) => { + const result = await Effect.runPromise(getActorNeighbours(params)); + return { + encoding: "application/json", + body: result, + }; + }, + }); +} + +const retrieve = ({ + params, + ctx, +}: { + params: QueryParams; + ctx: Context; +}): Effect.Effect<{ data: Neighbour[] }, Error> => { + return Effect.tryPromise({ + try: async () => { + const user = await ctx.db + .select() + .from(users) + .where(or(eq(users.did, params.did), eq(users.handle, params.did))) + .execute() + .then((rows) => rows[0]); + + if (!user) { + throw new Error(`User not found`); + } + + return ctx.analytics.post("library.getNeighbours", { + user_id: user.id, + }); + }, + catch: (error) => new Error(`Failed to retrieve neighbours: ${error}`), + }); +}; + +const presentation = ({ + data, +}: { + data: Neighbour[]; +}): Effect.Effect<{ neighbours: NeighbourViewBasic[] }, never> => { + return Effect.sync(() => ({ neighbours: deepCamelCaseKeys(data) })); +}; + +type Neighbour = { + id: string; + avatar: string; + did: string; + displayName: string; + handle: string; + sharedArtistsCount: number; + similarityScore: number; + topSharedArtistNames: string[]; + topSharedArtistsDetails: { + id: string; + name: string; + picture: string; + uri: string; + }[]; + userId: string; +}; diff --git a/apps/api/src/xrpc/app/rocksky/actor/getActorPlaylists.ts b/apps/api/src/xrpc/app/rocksky/actor/getActorPlaylists.ts index eeb4ef7b..b2ed8252 100644 --- a/apps/api/src/xrpc/app/rocksky/actor/getActorPlaylists.ts +++ b/apps/api/src/xrpc/app/rocksky/actor/getActorPlaylists.ts @@ -8,7 +8,7 @@ import tables from "schema"; import type { SelectPlaylist } from "schema/playlists"; export default function (server: Server, ctx: Context) { - const getActorPlaylists = (params) => + const getActorPlaylists = (params: QueryParams) => pipe( { params, ctx }, retrieve, diff --git a/apps/api/src/xrpc/app/rocksky/actor/getActorScrobbles.ts b/apps/api/src/xrpc/app/rocksky/actor/getActorScrobbles.ts index b0984549..cd0ce7d1 100644 --- a/apps/api/src/xrpc/app/rocksky/actor/getActorScrobbles.ts +++ b/apps/api/src/xrpc/app/rocksky/actor/getActorScrobbles.ts @@ -6,7 +6,7 @@ import type { ScrobbleViewBasic } from "lexicon/types/app/rocksky/scrobble/defs" import { deepCamelCaseKeys } from "lib"; export default function (server: Server, ctx: Context) { - const getActorScrobbles = (params) => + const getActorScrobbles = (params: QueryParams) => pipe( { params, ctx }, retrieve, diff --git a/apps/api/src/xrpc/app/rocksky/actor/getActorSongs.ts b/apps/api/src/xrpc/app/rocksky/actor/getActorSongs.ts index 7522b181..0a184bfa 100644 --- a/apps/api/src/xrpc/app/rocksky/actor/getActorSongs.ts +++ b/apps/api/src/xrpc/app/rocksky/actor/getActorSongs.ts @@ -6,7 +6,7 @@ import type { SongViewBasic } from "lexicon/types/app/rocksky/song/defs"; import { deepCamelCaseKeys } from "lib"; export default function (server: Server, ctx: Context) { - const getActorSongs = (params) => + const getActorSongs = (params: QueryParams) => pipe( { params, ctx }, retrieve, diff --git a/apps/api/src/xrpc/app/rocksky/actor/getProfile.ts b/apps/api/src/xrpc/app/rocksky/actor/getProfile.ts index 50615350..b5d9e9ca 100644 --- a/apps/api/src/xrpc/app/rocksky/actor/getProfile.ts +++ b/apps/api/src/xrpc/app/rocksky/actor/getProfile.ts @@ -18,7 +18,7 @@ import type { SelectSpotifyToken } from "schema/spotify-tokens"; import type { SelectUser } from "schema/users"; export default function (server: Server, ctx: Context) { - const getActorProfile = (params, auth: HandlerAuth) => + const getActorProfile = (params: QueryParams, auth: HandlerAuth) => pipe( { params, ctx, did: auth.credentials?.did }, resolveHandleToDid, diff --git a/crates/analytics/src/handlers/mod.rs b/crates/analytics/src/handlers/mod.rs index 6568a79c..ba3b5829 100644 --- a/crates/analytics/src/handlers/mod.rs +++ b/crates/analytics/src/handlers/mod.rs @@ -12,7 +12,7 @@ use stats::{ }; use tracks::{get_loved_tracks, get_top_tracks, get_tracks}; -use crate::handlers::artists::get_artist_listeners; +use crate::handlers::{artists::get_artist_listeners, stats::get_neighbours}; pub mod albums; pub mod artists; @@ -61,6 +61,7 @@ pub async fn handle( "library.getArtistAlbums" => get_artist_albums(payload, req, conn.clone()).await, "library.getArtistTracks" => get_artist_tracks(payload, req, conn.clone()).await, "library.getArtistListeners" => get_artist_listeners(payload, req, conn.clone()).await, + "library.getNeighbours" => get_neighbours(payload, req, conn.clone()).await, _ => return Err(anyhow::anyhow!("Method not found")), } } diff --git a/crates/analytics/src/handlers/stats.rs b/crates/analytics/src/handlers/stats.rs index b0f1b36d..052204b3 100644 --- a/crates/analytics/src/handlers/stats.rs +++ b/crates/analytics/src/handlers/stats.rs @@ -1,12 +1,13 @@ use std::sync::{Arc, Mutex}; use crate::read_payload; +use crate::types::stats::GetNeighboursParams; use crate::types::{ scrobble::{ScrobblesPerDay, ScrobblesPerMonth, ScrobblesPerYear}, stats::{ GetAlbumScrobblesParams, GetArtistScrobblesParams, GetScrobblesPerDayParams, GetScrobblesPerMonthParams, GetScrobblesPerYearParams, GetStatsParams, - GetTrackScrobblesParams, + GetTrackScrobblesParams, Neighbour, }, }; use actix_web::{web, HttpRequest, HttpResponse}; @@ -460,3 +461,92 @@ pub async fn get_track_scrobbles( let scrobbles: Result, _> = scrobbles.collect(); Ok(HttpResponse::Ok().json(scrobbles?)) } + +pub async fn get_neighbours( + payload: &mut web::Payload, + _req: &HttpRequest, + conn: Arc>, +) -> Result { + let body = read_payload!(payload); + let params = serde_json::from_slice::(&body)?; + let conn = conn.lock().unwrap(); + tracing::info!(user_id = %params.user_id, "Get neighbours"); + + let mut stmt = conn.prepare( + r#" + WITH user_top_artists AS ( + SELECT + user_id, + artist_id, + COUNT(*) as play_count, + ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY COUNT(*) DESC) as artist_rank + FROM scrobbles s + INNER JOIN artists a ON a.id = s.artist_id + WHERE s.artist_id IS NOT NULL + AND a.name != 'Various Artists' + GROUP BY user_id, artist_id + ), + weighted_similarity AS ( + SELECT + u1.user_id as target_user, + u2.user_id as neighbor_user, + SUM(1.0 / (u1.artist_rank + u2.artist_rank)) as similarity_score, + COUNT(DISTINCT u1.artist_id) as shared_artists, + ARRAY_AGG(DISTINCT u1.artist_id) FILTER (WHERE u1.artist_rank <= 20) as top_shared_artists + FROM user_top_artists u1 + JOIN user_top_artists u2 + ON u1.artist_id = u2.artist_id + AND u1.user_id != u2.user_id + WHERE u1.user_id = ? + AND u1.artist_rank <= 50 + AND u2.artist_rank <= 50 + GROUP BY u1.user_id, u2.user_id + HAVING shared_artists >= 3 + AND top_shared_artists IS NOT NULL + ) + SELECT + ws.neighbor_user, + u.display_name, + u.handle, + u.did, + u.avatar, + ws.similarity_score, + ws.shared_artists, + to_json(LIST(a.name ORDER BY array_position(ws.top_shared_artists, a.id))) as top_shared_artist_names, + to_json(LIST({'id': a.id, 'name': a.name, 'picture': a.picture, 'uri': a.uri} + ORDER BY array_position(ws.top_shared_artists, a.id))) as top_shared_artists_details + FROM weighted_similarity ws + LEFT JOIN users u ON u.id = ws.neighbor_user + INNER JOIN UNNEST(ws.top_shared_artists) AS t(artist_id) ON true + INNER JOIN artists a ON a.id = t.artist_id + GROUP BY ws.neighbor_user, u.display_name, u.handle, u.did, u.avatar, ws.similarity_score, ws.shared_artists, ws.top_shared_artists + ORDER BY ws.similarity_score DESC + LIMIT 20 + "#, + )?; + + let neighbours = stmt.query_map([¶ms.user_id], |row| { + let top_shared_artist_names_json: String = row.get(7)?; + let top_shared_artists_details_json: String = row.get(8)?; + + let top_shared_artist_names: Vec = + serde_json::from_str(&top_shared_artist_names_json).unwrap_or_else(|_| Vec::new()); + let top_shared_artists_details: Vec = + serde_json::from_str(&top_shared_artists_details_json).unwrap_or_else(|_| Vec::new()); + + Ok(Neighbour { + user_id: row.get(0)?, + display_name: row.get(1)?, + handle: row.get(2)?, + did: row.get(3)?, + avatar: row.get(4)?, + similarity_score: row.get(5)?, + shared_artists_count: row.get(6)?, + top_shared_artist_names, + top_shared_artists_details, + }) + })?; + + let neighbours: Result, _> = neighbours.collect(); + Ok(HttpResponse::Ok().json(neighbours?)) +} diff --git a/crates/analytics/src/types/stats.rs b/crates/analytics/src/types/stats.rs index fbd4edbb..14a543d1 100644 --- a/crates/analytics/src/types/stats.rs +++ b/crates/analytics/src/types/stats.rs @@ -128,3 +128,29 @@ impl Default for GetTrackScrobblesParams { } } } + +#[derive(Debug, Serialize, Deserialize)] +pub struct GetNeighboursParams { + pub user_id: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct NeighbourArtist { + pub id: String, + pub name: String, + pub picture: Option, + pub uri: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Neighbour { + pub user_id: String, + pub display_name: Option, + pub handle: String, + pub did: String, + pub avatar: String, + pub similarity_score: f64, + pub shared_artists_count: i64, + pub top_shared_artist_names: Vec, + pub top_shared_artists_details: Vec, +} -- 2.51.2