diff --git a/package-lock.json b/package-lock.json index 1311234d..80c84a4c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,6 +31,7 @@ "@keyv/valkey": "^1.0.8", "@lukehagar/plexjs": "^0.39.0", "@pierre/diffs": "^1.2.4", + "@rocksky/sdk": "^0.2.1", "@supercharge/promise-pool": "^3.0.0", "@svrooij/sonos": "^2.5.0", "@xhayper/discord-rpc": "1.3.0", @@ -3762,6 +3763,12 @@ } } }, + "node_modules/@rocksky/sdk": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@rocksky/sdk/-/sdk-0.2.1.tgz", + "integrity": "sha512-UqjrKSwbQBYcnXtB4GMT9AS/1BC9hfujyDa6XkUPsI/7VfNMKsqS3NNNL6V/4x+9wOcuDn/JofiDWVH7WzpEVA==", + "license": "MIT" + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", diff --git a/package.json b/package.json index 12828365..3c7cff80 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,7 @@ "@keyv/valkey": "^1.0.8", "@lukehagar/plexjs": "^0.39.0", "@pierre/diffs": "^1.2.4", + "@rocksky/sdk": "^0.2.1", "@supercharge/promise-pool": "^3.0.0", "@svrooij/sonos": "^2.5.0", "@xhayper/discord-rpc": "1.3.0", diff --git a/src/backend/common/infrastructure/config/client/rocksky.ts b/src/backend/common/infrastructure/config/client/rocksky.ts index b0ae0334..0dff523b 100644 --- a/src/backend/common/infrastructure/config/client/rocksky.ts +++ b/src/backend/common/infrastructure/config/client/rocksky.ts @@ -10,6 +10,13 @@ export interface RockSkyData extends RequestRetryOptions{ * */ key: string + /** + * Access Token generated from https://rocksky.app/access-tokens in Rocksky for your account + * + * @examples ["eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJkaWQ....."] + * */ + token?: string + /** * The **fully-qualified** handle for your ATPRoto/Bluesky account, like: * diff --git a/src/backend/common/vendor/RockSkyApiClient.ts b/src/backend/common/vendor/RockSkyApiClient.ts index 31892491..cb52c6c0 100644 --- a/src/backend/common/vendor/RockSkyApiClient.ts +++ b/src/backend/common/vendor/RockSkyApiClient.ts @@ -1,7 +1,7 @@ import dayjs from "dayjs"; import request, { Request, Response } from 'superagent'; import { PlayObject, PlayObjectLifecycleless, ScrobbleActionResult, URLData } from "../../../core/Atomic.js"; -import { artistNamesToCredits, nonEmptyStringOrDefault } from "../../../core/StringUtils.js"; +import { artistCreditsToNames, artistNamesToCredits, nonEmptyStringOrDefault } from "../../../core/StringUtils.js"; import { UpstreamError } from "../errors/UpstreamError.js"; import { AbstractApiOptions, DEFAULT_RETRY_MULTIPLIER, FormatPlayObjectOptions } from "../infrastructure/Atomic.js"; import { RockSkyClientData, RockSkyData, RockSkyOptions } from "../infrastructure/config/client/rocksky.js"; @@ -12,10 +12,14 @@ import { ListenPayload, ListenResponse, ListenType, SubmitPayload } from './list import { playToListenPayload } from './listenbrainz/lzUtils.js'; import { RockskyScrobble } from './rocksky/interfaces.js'; import { Handle } from "@atcute/lexicons"; -import { identifierToAtProtoHandle } from './atproto/atUtils.js'; +import { getATProtoIdentifier, identifierToAtProtoHandle } from './atproto/atUtils.js'; import { baseFormatPlayObj } from "../../utils/PlayTransformUtils.js"; import { ScrobbleSubmitError } from "../errors/MSErrors.js"; import { tryApiCall } from "../../utils/RequestUtils.js"; +import { CreateScrobbleInput, RockskyClient } from "@rocksky/sdk"; +import { getRoot } from "../../ioc.js"; +import { MSCache } from "../Cache.js"; +import { HandleData } from "../infrastructure/config/client/atproto.js"; interface SubmitOptions { log?: boolean @@ -42,22 +46,34 @@ export class RockSkyApiClient extends AbstractApiClient { apiUrl: URLData; isKoito: boolean = false; handle: Handle; + cache: MSCache; + userData!: HandleData + + rsClient?: RockskyClient; constructor(name: any, config: RockSkyData & RockSkyOptions, options: AbstractApiOptions) { super('RockSky', name, config, options); const { audioScrobblerUrl, - apiUrl + apiUrl, + token, + key } = config; + this.cache = getRoot().items.cache(); this.lzUrl = normalizeWebAddress(audioScrobblerUrl ?? 'https://audioscrobbler.rocksky.app/'); this.apiUrl = normalizeWebAddress(apiUrl ?? 'https://api.rocksky.app/xrpc/'); this.logger.verbose(`Audioscrobbler URL: '${audioScrobblerUrl ?? '(None Given)'}' => Normalized: '${this.lzUrl.url}'`); this.logger.verbose(`API URL: '${apiUrl ?? '(None Given)'}' => Normalized: '${this.apiUrl.url}'`); this.handle = identifierToAtProtoHandle(this.config.handle, {logger: this.logger, defaultDomain: 'bsky.social'}); + if(key !== undefined) { + this.logger.warn(`DEPRECATED: Listenbrainz interface (API Application 'key' auth) has been deprecated in favor of native API (access token auth). Please refer to the MS Rocksky docs and switch. Listenbrainz/key auth will be removed in a future release`); + } + this.rsClient = new RockskyClient({auth: token}); } + protected isLzMode = () => this.config.key !== undefined; doCallLZApi = async (req: Request, retries = 0): Promise => { try { @@ -130,40 +146,45 @@ export class RockSkyApiClient extends AbstractApiClient { } catch (e) { throw new Error('Could not reach API URL endpoint', {cause: e}); } - try { - await isPortReachableConnect(this.lzUrl.port, {host: this.lzUrl.url.hostname}); - } catch (e) { - throw new Error('Could not reach Audioscrobbler URL endpoint', {cause: e}); + if(this.isLzMode()) { + try { + await isPortReachableConnect(this.lzUrl.port, {host: this.lzUrl.url.hostname}); + } catch (e) { + throw new Error('Could not reach Audioscrobbler URL endpoint', {cause: e}); + } } return true; } testAuth = async () => { - try { - const resp = await this.callLZApi(() => request.get(`${joinedUrl(this.lzUrl.url,'1/validate-token')}`)); - return true; - } catch (e) { - throw e; + this.userData = await getATProtoIdentifier({identifier: this.handle }, { logger: this.logger, cache: this.cache.cacheAuth }); + if(this.isLzMode()) { + try { + const resp = await this.callLZApi(() => request.get(`${joinedUrl(this.lzUrl.url,'1/validate-token')}`)); + return true; + } catch (e) { + throw e; + } + } else { + try { + const req = request.get('https://api.rocksky.app/profile').set('Authorization', `Bearer ${this.config.token}`); + await req; + return true; + } catch (e) { + throw new UpstreamError('Failed to get /profile with given token', {cause: e}); + } } } getUserListens = async (maxTracks: number, user?: string): Promise => { try { - const resp = await this.callApi(() => request - .get(`${joinedUrl(this.apiUrl.url,`app.rocksky.actor.getActorScrobbles`)}`) - // this endpoint can take forever, sometimes, and we want to make sure we timeout in a reasonable amount of time for polling sources to continue trying to scrobble - .timeout({ - response: 3000, // wait 3 seconds before timeout if server doesn't response at all - deadline: 5000 // wait 5 seconds overall for request to complete - }) - .query({ - limit: maxTracks, - offset: 0, - did: user ?? this.config.handle - }) - .redirects(1)); - return resp.body as UserScrobbleResponse; + const res = await this.rsClient.actor.getActorScrobbles({ + limit: maxTracks, + offset: 0, + did: this.userData.did + }); + return res; } catch (e) { throw e; } @@ -182,29 +203,38 @@ export class RockSkyApiClient extends AbstractApiClient { submitListen = async (play: PlayObject, options: SubmitOptions = {}): Promise => { const { log = false, listenType = 'single'} = options; - const listenPayload = playToListenPayload(play); - if(listenType === 'playing_now') { - delete listenPayload.listened_at; + if(this.isLzMode()) { + const listenPayload = playToListenPayload(play); + if(listenType === 'playing_now') { + delete listenPayload.listened_at; + } + // https://tangled.org/rocksky.app/rocksky/blob/main/crates/scrobbler/src/listenbrainz/types.rs#L11 + // rocksky only uses duration_ms + if(play.data.duration !== undefined && listenPayload.track_metadata.additional_info?.duration !== undefined) { + delete listenPayload.track_metadata.additional_info.duration; + listenPayload.track_metadata.additional_info.duration_ms = Math.round(play.data.duration) * 1000; } - // https://tangled.org/rocksky.app/rocksky/blob/main/crates/scrobbler/src/listenbrainz/types.rs#L11 - // rocksky only uses duration_ms - if(play.data.duration !== undefined && listenPayload.track_metadata.additional_info?.duration !== undefined) { - delete listenPayload.track_metadata.additional_info.duration; - listenPayload.track_metadata.additional_info.duration_ms = Math.round(play.data.duration) * 1000; - } - const submitPayload: SubmitPayload = {listen_type: listenType, payload: [listenPayload]}; + const submitPayload: SubmitPayload = {listen_type: listenType, payload: [listenPayload]}; - try { - if(log) { - this.logger.debug(`Submit Payload: ${JSON.stringify(submitPayload)}`); + try { + if(log) { + this.logger.debug(`Submit Payload: ${JSON.stringify(submitPayload)}`); + } + const resp = await this.callLZApi(() => request.post(`${joinedUrl(this.lzUrl.url,'1/submit-listens')}`).type('json').send(submitPayload)); + if(log) { + this.logger.debug(`Submit Response: ${resp.text}`) + } + return {payload: submitPayload, response: resp.body as SubmitResponse}; + } catch (e) { + throw new ScrobbleSubmitError(`Error occurred while making Rocksky API scrobble (${listenType}) request`, {cause: e, payload: submitPayload}); } - const resp = await this.callLZApi(() => request.post(`${joinedUrl(this.lzUrl.url,'1/submit-listens')}`).type('json').send(submitPayload)); + } else { + const payload = playToRockskyRecord(play); if(log) { - this.logger.debug(`Submit Response: ${resp.text}`) + this.logger.debug(`Submit Payload: ${JSON.stringify(payload)}`); } - return {payload: submitPayload, response: resp.body as SubmitResponse}; - } catch (e) { - throw new ScrobbleSubmitError(`Error occurred while making Rocksky API scrobble (${listenType}) request`, {cause: e, payload: submitPayload}); + const resp = await this.rsClient.scrobble.createScrobble(playToRockskyRecord(play)); + return {payload, response: resp} } } @@ -214,7 +244,7 @@ export class RockSkyApiClient extends AbstractApiClient { } interface UserScrobbleResponse { - scrobbles: RockskyScrobble[] + scrobbles?: RockskyScrobble[] } export const rockskyScrobbleToPlay = (obj: RockskyScrobble): PlayObject => { @@ -222,15 +252,35 @@ export const rockskyScrobbleToPlay = (obj: RockskyScrobble): PlayObject => { data: { track: obj.title, artists: artistNamesToCredits(nonEmptyStringOrDefault(obj.artist) ? [obj.artist] : []), + // @ts-expect-error its in the response but missing from types albumArtists: artistNamesToCredits(nonEmptyStringOrDefault(obj.albumArtist) ? [obj.albumArtist] : []), album: nonEmptyStringOrDefault(obj.album), + // @ts-expect-error its in the response but missing from types playDate: dayjs.utc(obj.createdAt).local() }, meta: { + // @ts-expect-error its in the response but missing from types trackId: obj.trackId, - playId: obj.id + playId: obj.id, } }; + if('albumArt' in obj) { + play.meta.art = {album: obj.albumArt as string} + } return baseFormatPlayObj(obj, play); +} + +export const playToRockskyRecord = (play: PlayObject): CreateScrobbleInput => { + const csi: CreateScrobbleInput = { + title: play.data.track, + artist: artistCreditsToNames(play.data.artists).join(', '), + album: play.data.album, + mbId: play.data.meta?.brainz?.track, + isrc: play.data.isrc, + duration: play.data.duration !== undefined ? play.data.duration * 1000 : undefined, + spotifyLink: play.meta.source === 'spotify' && play.meta.url?.web !== undefined ? play.meta.url?.web : undefined, + timestamp: play.data.playDate.unix() + } + return csi; } \ No newline at end of file diff --git a/src/backend/common/vendor/rocksky/interfaces.ts b/src/backend/common/vendor/rocksky/interfaces.ts index ce3d87cb..5f13f3e3 100644 --- a/src/backend/common/vendor/rocksky/interfaces.ts +++ b/src/backend/common/vendor/rocksky/interfaces.ts @@ -1,22 +1,4 @@ -export interface RockskyScrobble extends RockskyScrobbleUris, RockSkyScrobbleUserData { - id: string - trackId: string - title: string - artist: string - albumArtist: string - albumArt: String - album: string - createdAt: string -} +import { RockskyClient } from "@rocksky/sdk"; +import { ElementOf } from "ts-essentials"; -export interface RockskyScrobbleUris { - trackUri: string - artistUri: string - albumUri: string - uri: string -} - -export interface RockSkyScrobbleUserData { - did: string - handle: string -} \ No newline at end of file +export type RockskyScrobble = ElementOf>['scrobbles']>; \ No newline at end of file diff --git a/src/backend/scrobblers/RockskyScrobbler.ts b/src/backend/scrobblers/RockskyScrobbler.ts index a8c2df23..aebaf3e8 100644 --- a/src/backend/scrobblers/RockskyScrobbler.ts +++ b/src/backend/scrobblers/RockskyScrobbler.ts @@ -40,10 +40,11 @@ export default class RockskyScrobbler extends AbstractScrobbleClient { const { data: { key, + token, } = {} } = this.config; - if (key === undefined) { - throw new Error('Must provide an API Key'); + if (key === undefined && token === undefined) { + throw new Error('Must provide an API Key or Access Token'); } return true; }