diff --git a/src/backend/common/infrastructure/config/client/atproto.ts b/src/backend/common/infrastructure/config/client/atproto.ts index 2e063409..a852509a 100644 --- a/src/backend/common/infrastructure/config/client/atproto.ts +++ b/src/backend/common/infrastructure/config/client/atproto.ts @@ -25,7 +25,7 @@ export const atProtoAppDataSchema = z.object({ * **Use this if you are self-hosting Multi-Scrobbler on localhost or accessed like http://IP:PORT** */ appPassword: z.string().meta({ - description: "The [App Password](https://atproto.com/specs/xrpc#app-passwords) you created for your account" + description: "The [App Password](https://atproto.com/specs/xrpc#app-passwords) you created for your Atmosphere account" }), }); diff --git a/src/backend/common/infrastructure/config/client/rocksky.ts b/src/backend/common/infrastructure/config/client/rocksky.ts index 72e9644a..4f246b9c 100644 --- a/src/backend/common/infrastructure/config/client/rocksky.ts +++ b/src/backend/common/infrastructure/config/client/rocksky.ts @@ -6,16 +6,6 @@ import { atProtoAppDataSchema } from "./atproto.ts"; export const rockSkyDataSchema = z.object({ ...requestRetryOptionsSchema.shape, - /** - * API Key generated from [API Applications](https://docs.rocksky.app/migrating-from-listenbrainz-to-rocksky-1040189m0) in Rocksky for your account - * - * @examples ["6794186bf-1157-4de6-80e5-uvb411f3ea2b"] - * */ - key: z.string().optional().meta({ - description: "API Key generated from [API Applications](https://docs.rocksky.app/migrating-from-listenbrainz-to-rocksky-1040189m0) in Rocksky for your account", - examples: ["6794186bf-1157-4de6-80e5-uvb411f3ea2b"] - }), - /** * Access Token generated from https://rocksky.app/access-tokens in Rocksky for your account * @@ -35,17 +25,15 @@ export const rockSkyDataSchema = z.object({ * * */ handle: z.string().meta({ - description: "The **fully-qualified** handle for your ATPRoto/Bluesky account" + description: "The **fully-qualified** handle, or identifier, for your Atmosphere account" }), - did: z.string().optional(), - appPassword: atProtoAppDataSchema.shape.appPassword.optional() + appPassword: atProtoAppDataSchema.shape.appPassword.optional().meta(atProtoAppDataSchema.shape.appPassword.meta()) }); export type RockSkyData = z.infer; const envDataSchema = z.object({ - ROCKSKY_KEY: rockSkyDataSchema.shape.key, ROCKSKY_TOKEN: rockSkyDataSchema.shape.token, ROCKSKY_HANDLE: rockSkyDataSchema.shape.handle, ROCKSKY_APP_PW: rockSkyDataSchema.shape.appPassword @@ -57,7 +45,6 @@ export const envSchemas: EnvClientSchema ({ configureAs: 'client', data: { - key: partial.ROCKSKY_KEY, token: partial.ROCKSKY_TOKEN, handle: partial.ROCKSKY_HANDLE, appPassword: partial.ROCKSKY_APP_PW @@ -70,18 +57,6 @@ export const rockSkyClientDataSchema = rockSkyDataSchema.extend(commonClientData export type RockSkyClientData = z.infer; export const rockSkyOptionsSchema = z.object({ - /** - * URL for the Rocksky *Listenbrainz* endpoint, if not using the default - * - * @examples ["https://audioscrobbler.rocksky.app"] - * @default "https://audioscrobbler.rocksky.app" - * */ - audioScrobblerUrl: z.string().optional().meta({ - description: "URL for the Rocksky *Listenbrainz* endpoint, if not using the default", - default: "https://audioscrobbler.rocksky.app", - examples: ["https://audioscrobbler.rocksky.app"] - }), - /** * URL for the Rocksky *API* endpoint, if not using the default * diff --git a/src/backend/common/infrastructure/config/client/tealfm.ts b/src/backend/common/infrastructure/config/client/tealfm.ts index c12c0926..dbc0074a 100644 --- a/src/backend/common/infrastructure/config/client/tealfm.ts +++ b/src/backend/common/infrastructure/config/client/tealfm.ts @@ -6,8 +6,11 @@ import {commonClientConfigSchema, commonClientDataSchema, commonClientOptionsSch export const tealDataSchema = z.object({ ...requestRetryOptionsSchema.shape, - ...atProtoUserIdentifierDataSchema.shape, + //...atProtoUserIdentifierDataSchema.shape, ...atProtoAppDataSchema.shape, + identifier: atProtoUserIdentifierDataSchema.shape.identifier.meta({ + description: "The **fully-qualified** handle, or identifier, for your Atmosphere account" + }), /** * The base URI of the Multi-Scrobbler to use for ATProto OAuth * diff --git a/src/backend/common/infrastructure/config/source/rocksky.ts b/src/backend/common/infrastructure/config/source/rocksky.ts index 5f391bea..e73793ca 100644 --- a/src/backend/common/infrastructure/config/source/rocksky.ts +++ b/src/backend/common/infrastructure/config/source/rocksky.ts @@ -12,7 +12,6 @@ export const rockskySourceDataSchema = z.object({ export type RockskySourceData = z.infer; const envDataSchema = z.object({ - SOURCE_ROCKSKY_KEY: rockskySourceDataSchema.shape.key, SOURCE_ROCKSKY_HANDLE: rockskySourceDataSchema.shape.handle, }); @@ -22,7 +21,6 @@ export const envSchemas: EnvSourceSchema ({ configureAs: 'source', data: { - key: partial.SOURCE_ROCKSKY_KEY, handle: partial.SOURCE_ROCKSKY_HANDLE } }) diff --git a/src/backend/common/vendor/RockSkyApiClient.ts b/src/backend/common/vendor/RockSkyApiClient.ts index 46fc074f..80c04458 100644 --- a/src/backend/common/vendor/RockSkyApiClient.ts +++ b/src/backend/common/vendor/RockSkyApiClient.ts @@ -1,24 +1,20 @@ import dayjs from "dayjs"; -import type { Request, Response } from 'superagent'; -import request from 'superagent'; +import type request from 'superagent'; import {rockskyRequiredFields, type ArtistCredit, type LifecycleInput, type PlayObject, type PlayObjectMinimal, type RockskyConfidenceField, type RockskyMissingField, type ScrobbleActionResult, type URLData} from "../../../core/Atomic.ts"; import { artistCreditsToNames, artistNamesToCredits, nonEmptyStringOrDefault } from "../../../core/StringUtils.ts"; import { UpstreamError } from "../errors/UpstreamError.ts"; import type {AbstractApiOptions, FormatPlayObjectOptions} from "../infrastructure/Atomic.ts"; import type {RockSkyClientData, RockSkyData, RockSkyOptions} from "../infrastructure/config/client/rocksky.ts"; import AbstractApiClient from "./AbstractApiClient.ts"; -import { isPortReachableConnect, joinedUrl, normalizeWebAddress } from '../../utils/NetworkUtils.ts'; -import type {ListenResponse, ListenType, SubmitPayload} from '../../../core/vendor/listenbrainz/interfaces.ts'; -import { playToListenPayload } from './listenbrainz/lzUtils.ts'; -import type {Handle} from "@atcute/lexicons"; -import { getATProtoIdentifier, identifierToAtProtoHandle } from './atproto/atUtils.ts'; +import { isPortReachableConnect, normalizeWebAddress } from '../../utils/NetworkUtils.ts'; +import type {ListenResponse, ListenType} from '../../../core/vendor/listenbrainz/interfaces.ts'; +import { getATProtoIdentifier, identifierToAtProtoHandle, isDID } from './atproto/atUtils.ts'; import { baseFormatPlayObj } from "../../utils/PlayTransformUtils.ts"; import { AuthError, ScrobbleSubmitError } from "../errors/MSErrors.ts"; -import { tryApiCall } from "../../utils/RequestUtils.ts"; import { type CreateScrobbleInput, RockskyClient, Agent, type SongViewDetailed, type ScrobbleInput, type ScrobbleViewBasic } from "@rocksky/sdk"; import { getRoot } from "../../ioc.ts"; import type { MSCache } from "../Cache.ts"; -import type {HandleData} from "../infrastructure/config/client/atproto.ts"; +import type {ATProtoUserIdentifierData, HandleData} from "../infrastructure/config/client/atproto.ts"; import { parseRegexSingle } from "@foxxmd/regex-buddy-core"; import { removeUndefinedKeys } from "../../../core/DataUtils.ts"; import { isrcNoHyphens } from '../../../core/PlayUtils.ts'; @@ -54,7 +50,6 @@ export class RockSkyApiClient extends AbstractApiClient { lzUrl: URLData; apiUrl: URLData; isKoito: boolean = false; - handle: Handle; cache: MSCache; userData!: HandleData @@ -65,112 +60,40 @@ export class RockSkyApiClient extends AbstractApiClient { constructor(name: any, config: RockSkyData & RockSkyOptions, options: AbstractApiOptions) { super('RockSky', name, config, options); const { - audioScrobblerUrl, 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.rsPool = new RockskyClientPool('Pool', {apis: [{enable: true, token}]}, {logger: this.logger}); this.rsClient = new RockskyClient(token); } - isLzMode = () => this.config.key !== undefined && this.config.token === undefined && this.rsAgent === undefined; - - doCallLZApi = async (req: Request, retries = 0): Promise => { - try { - req.set('Authorization', `Token ${this.config.key}`); - return await req as T; - } catch (e) { - const { - message, - err, - status, - response: { - body = undefined, - text = undefined, - } = {} - } = e; - // TODO check err for network exception - if(status !== undefined) { - const msgParts = [`(HTTP Status ${status})`]; - // if the response is 400 then its likely there was an issue with the data we sent rather than an error with the service - const showStopper = status !== 400; - if(body !== undefined) { - if(typeof body === 'object') { - if('code' in body) { - msgParts.push(`Code ${body.code}`); - } - if('error' in body) { - msgParts.push(`Error => ${body.error}`); - } - if('message' in body) { - msgParts.push(`Message => ${body.error}`); - } - // if('track_metadata' in body) { - // msgParts.push(`Track Metadata => ${JSON.stringify(body.track_metadata)}`); - // } - } else if(typeof body === 'string') { - msgParts.push(`Response => ${body}`); - } - } else if (text !== undefined) { - msgParts.push(`Response => ${text}`); - } - throw new UpstreamError(`Listenbrainz API Request Failed => ${msgParts.join(' | ')}`, {cause: e, showStopper}); - } - throw e; - } - } - - callLZApi = async (reqFunc: () => Request, retries = 0): Promise => { - - try { - return await tryApiCall(() => this.doCallLZApi(reqFunc()), {...this.config, logger: this.logger}) as T; - } catch (e) { - throw e; - } - } - - - callApi = async (reqFunc: () => Request, retries = 0): Promise => { - const apiCall = async () => await reqFunc(); - - try { - return await tryApiCall(apiCall, {...this.config, logger: this.logger}) as T; - } catch (e) { - throw e; - } - } - testConnection = async () => { try { await isPortReachableConnect(this.apiUrl.port, {host: this.apiUrl.url.hostname}); } catch (e) { throw new Error('Could not reach API 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 () => { - this.userData = await getATProtoIdentifier({identifier: this.handle, did: this.config.did }, { logger: this.logger, cache: this.cache.cacheAuth }); + const atProtoHandleData: ATProtoUserIdentifierData = { + identifier: this.config.handle + }; + const cleanIdentifier = this.config.handle; + if(isDID(cleanIdentifier)) { + this.logger.debug(`Identifier ${cleanIdentifier} looks like a DID, skipping parsing as a handle.`); + atProtoHandleData.did = cleanIdentifier; + } else { + atProtoHandleData.identifier = identifierToAtProtoHandle(cleanIdentifier, {logger: this.logger, defaultDomain: 'bsky.social'}); + } + this.userData = await getATProtoIdentifier(atProtoHandleData, { logger: this.logger, cache: this.cache.cacheAuth }); // authed write operations straight through PDS using xrpc if(this.userData !== undefined && this.config.appPassword !== undefined) { @@ -181,18 +104,7 @@ export class RockSkyApiClient extends AbstractApiClient { } } - // no agent and no rs client token - if(this.isLzMode()) { - try { - const resp = await this.callLZApi(() => request.get(`${joinedUrl(this.lzUrl.url,'1/validate-token')}`)); - return true; - } catch (e) { - const cause = findCauseByFunc(e, (ee) => isSuperAgentResponseError(ee)); - throw new AuthError('Failed to validate token for listenbrainz mode', {cause: e, unrecoverable: cause !== undefined && [401,403].includes(cause.status)}); - } - } - - // if no lz key and no xrpc client then we need to test if the token for the rs client is valid + // if no xrpc client then we need to test if the token for the rs client is valid // so we can use the client for write operations later if(this.rsAgent === undefined) { try { @@ -234,41 +146,14 @@ export class RockSkyApiClient extends AbstractApiClient { } submitListen = async (play: PlayObject, options: SubmitOptions & {force?: boolean} = {}): Promise => { - const { log = false, listenType = 'single', force = false} = options; + const { log = false, force = false} = options; const warnings: string[] = []; /** - * First two paths are asynchronous, server-side validation of scrobbles + * paths is asynchronous, server-side validation of scrobbles * we don't recieve any real feedback about whether the scrobbles were accepted */ - 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; - } - const submitPayload: SubmitPayload = {listen_type: listenType, payload: [listenPayload]}; - - 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, createdAt: dayjs().toISOString()}; - } catch (e) { - throw new ScrobbleSubmitError(`Error occurred while making Rocksky API scrobble (${listenType}) request`, {cause: e, payload: submitPayload}); - } - } - if(this.rsAgent === undefined) { const payload = removeUndefinedKeys(playToRockskyClientRecord(play)); if(log) { diff --git a/src/backend/scrobblers/RockskyScrobbler.ts b/src/backend/scrobblers/RockskyScrobbler.ts index 1fc241f3..4c98bb3e 100644 --- a/src/backend/scrobblers/RockskyScrobbler.ts +++ b/src/backend/scrobblers/RockskyScrobbler.ts @@ -10,9 +10,8 @@ import type {ListenPayload} from '../../core/vendor/listenbrainz/interfaces.ts'; import { isDebugMode } from "../utils.ts"; import { durationToHuman } from '../../core/TimeUtils.ts'; -import { RockSkyApiClient, rockskyScrobbleToPlay, type SubmitResponse } from "../common/vendor/RockSkyApiClient.ts"; +import { RockSkyApiClient, rockskyScrobbleToPlay } from "../common/vendor/RockSkyApiClient.ts"; import type {RockSkyClientConfig} from "../common/infrastructure/config/client/rocksky.ts"; -import { ScrobbleSubmitError } from "../common/errors/MSErrors.ts"; import AbstractHistoricalScrobbleClient from "./AbstractHistoricalScrobbleClient.ts"; import { fromStream } from '@atcute/repo'; import fsPromise from 'node:fs/promises'; @@ -59,12 +58,12 @@ export default class RockskyScrobbler extends AbstractHistoricalScrobbleClient { protected async doBuildInitData(): Promise { const { data: { - key, - token, + appPassword, + token } = {} } = this.config; - if (key === undefined && token === undefined) { - throw new Error('Must provide an API Key or Access Token'); + if (appPassword === undefined && token === undefined) { + throw new Error('Must provide an App Password or Access Token'); } return true; } @@ -107,10 +106,6 @@ export default class RockskyScrobbler extends AbstractHistoricalScrobbleClient { try { const result = await this.api.submitListen(playObj, { log: isDebugMode(), force: isRetry === true }); - if (this.api.isLzMode() && ((result.response as SubmitResponse).payload?.ignored_listens ?? 0) > 0) { - throw new ScrobbleSubmitError('Scrobble was successfully submitted but Rocksky ignored it', { showStopper: false, responseBody: result.response, payload: result.payload }); - } - if (newFromSource) { this.logger.info(`Scrobbled (New) => (${source}) ${buildTrackString(playObj)}`); } else { diff --git a/src/backend/sources/RockskySource.ts b/src/backend/sources/RockskySource.ts index 25aab92e..efc913af 100644 --- a/src/backend/sources/RockskySource.ts +++ b/src/backend/sources/RockskySource.ts @@ -41,12 +41,11 @@ export default class RockskySource extends MemorySource { protected async doBuildInitData(): Promise { const { data: { - key, token, } = {} } = this.config; - if (key === undefined && token === undefined) { - throw new Error('Must provide an API Key or Access Token'); + if (token === undefined) { + throw new Error('Must provide an Access Token'); } return true; }