From ebb74d1b5c76c3fb19e951b410122fb4f666a6b7 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Fri, 7 Nov 2025 16:31:41 +0000 Subject: [PATCH] feat: Implement working tealfm client * Add pds option --- .../infrastructure/config/client/tealfm.ts | 53 ++++++++++- .../common/vendor/ListenbrainzApiClient.ts | 2 +- .../bluesky/AbstractBlueSkyApiClient.ts | 27 +++++- .../vendor/bluesky/BlueSkyAppApiClient.ts | 14 +-- src/backend/scrobblers/TealfmScrobbler.ts | 94 ++++++++++++++++--- 5 files changed, 170 insertions(+), 20 deletions(-) diff --git a/src/backend/common/infrastructure/config/client/tealfm.ts b/src/backend/common/infrastructure/config/client/tealfm.ts index f67198c9..84aa68ca 100644 --- a/src/backend/common/infrastructure/config/client/tealfm.ts +++ b/src/backend/common/infrastructure/config/client/tealfm.ts @@ -1,12 +1,30 @@ import { RequestRetryOptions } from "../common.js" -import { CommonClientConfig, CommonClientData } from "./index.js" +import { CommonClientConfig, CommonClientData, CommonClientOptions } from "./index.js" export interface TealData extends RequestRetryOptions { } export interface TealClientData extends TealData, CommonClientData { + /** + * The base URI of the Multi-Scrobbler to use for ATProto OAuth + * + * Only include this if you want to use OAuth. The URI must be a non-IP/non-local domain using https: protocol. + */ baseUri?: string + /** + * Identify the account to login as + * + * * For **App Password** Auth - your email + * * For **Oauth** - your handle minus the @ + */ identifier: string + /** + * The [App Password](https://atproto.com/specs/xrpc#app-passwords) you created for your account + * + * This is created under https://bsky.app/settings/app-passwords + * + * **Use this if you are self-hosting Multi-Scrobbler on localhost or accessed like http://IP:PORT** + */ appPassword?: string } @@ -19,8 +37,41 @@ export interface TealClientConfig extends CommonClientConfig { * */ configureAs?: 'client' | 'source' data: TealClientData + options?: TealClientOptions +} + +export interface TealClientOptions extends CommonClientOptions { + /** The [PDS (Personal Data Server)](https://github.com/bluesky-social/pds) to use + * + * @default "https://bsky.social" + * @examples ["https://bsky.social"] + */ + pds?: string } export interface TealClientAIOConfig extends TealClientConfig { type: 'tealfm' +} + +export interface ScrobbleRecord { + $type: "fm.teal.alpha.feed.play", + trackName: string, + playedTime: string, + duration?: number + artists?: {artistName?: string, artistMbId?: string}[] + /** Album name */ + releaseName?: string + submissionClientAgent: string, + musicServiceBaseDomain?: string + // musicbrainz + recordingMbId?: string + releaseMbId?: string + isrc?: string, + [x: string]: unknown +} + +export interface ListRecord { + uri: string; + cid: string; + value: T; } \ No newline at end of file diff --git a/src/backend/common/vendor/ListenbrainzApiClient.ts b/src/backend/common/vendor/ListenbrainzApiClient.ts index 3ec38b21..791c4795 100644 --- a/src/backend/common/vendor/ListenbrainzApiClient.ts +++ b/src/backend/common/vendor/ListenbrainzApiClient.ts @@ -773,7 +773,7 @@ const musicServices = { * Converts MS musicService to LZ cononical Music Service Name, if one exists * @see https://listenbrainz.readthedocs.io/en/latest/users/json.html#payload-json-details * */ -const musicServiceToCononical = (str: string): string | undefined => { +export const musicServiceToCononical = (str: string): string | undefined => { const lower = str.trim().toLocaleLowerCase(); for(const [k, v] of Object.entries(musicServices)) { if(lower.includes(k)) { diff --git a/src/backend/common/vendor/bluesky/AbstractBlueSkyApiClient.ts b/src/backend/common/vendor/bluesky/AbstractBlueSkyApiClient.ts index cc8fcd1c..94b40da1 100644 --- a/src/backend/common/vendor/bluesky/AbstractBlueSkyApiClient.ts +++ b/src/backend/common/vendor/bluesky/AbstractBlueSkyApiClient.ts @@ -1,6 +1,6 @@ import { getRoot } from "../../../ioc.js"; import { AbstractApiOptions } from "../../infrastructure/Atomic.js"; -import { TealClientData } from "../../infrastructure/config/client/tealfm.js"; +import { ListRecord, ScrobbleRecord, TealClientData } from "../../infrastructure/config/client/tealfm.js"; import AbstractApiClient from "../AbstractApiClient.js"; import { Agent } from "@atproto/api"; import { MSCache } from "../../Cache.js"; @@ -23,4 +23,29 @@ export abstract class AbstractBlueSkyApiClient extends AbstractApiClient { abstract initClient(): void; abstract restoreSession(): Promise; + + async createScrobbleRecord(record: ScrobbleRecord): Promise { + try { + await this.agent.com.atproto.repo.createRecord({ + repo: this.agent.sessionManager.did, + collection: "fm.teal.alpha.feed.play", + record + }); + } catch (e) { + throw new Error(`Failed to create record`, { cause: e }); + } + } + + async listScrobbleRecord(limit: number = 20): Promise[]> { + try { + const response = await this.agent.com.atproto.repo.listRecords({ + repo: this.agent.sessionManager.did, + collection: "fm.teal.alpha.feed.play", + limit + }); + return response.data.records as unknown as ListRecord[]; + } catch (e) { + throw new Error(`Failed to create record`, { cause: e }); + } + } } \ No newline at end of file diff --git a/src/backend/common/vendor/bluesky/BlueSkyAppApiClient.ts b/src/backend/common/vendor/bluesky/BlueSkyAppApiClient.ts index cdc82af0..1ce5f65c 100644 --- a/src/backend/common/vendor/bluesky/BlueSkyAppApiClient.ts +++ b/src/backend/common/vendor/bluesky/BlueSkyAppApiClient.ts @@ -6,25 +6,28 @@ import { AbstractBlueSkyApiClient } from "./AbstractBlueSkyApiClient.js"; export class BlueSkyAppApiClient extends AbstractBlueSkyApiClient { + declare config: TealClientData; + + pds: string appSession?: CredentialSession; appPwAuth: boolean - constructor(name: any, config: TealClientData, options: AbstractApiOptions) { + constructor(name: any, config: TealClientData & {pds?: string}, options: AbstractApiOptions) { super(name, config, options); - - this.logger.verbose('Using App Password auth for session'); + this.pds = config.pds ?? 'https://bsky.social'; + this.logger.verbose(`Using App Password auth for session with PDS ${this.pds}`); } protected initClientApp() { - this.appSession = new CredentialSession(new URL('https://bsky.social'), undefined, (evt: AtpSessionEvent, sess?: AtpSessionData) => { + this.appSession = new CredentialSession(new URL(this.pds), undefined, (evt: AtpSessionEvent, sess?: AtpSessionData) => { this.cache.cacheAuth.set(`appPwSession-${this.name}`, sess); }); this.agent = new Agent(this.appSession); } initClient() { - this.appSession = new CredentialSession(new URL('https://bsky.social'), undefined, (evt: AtpSessionEvent, sess?: AtpSessionData) => { + this.appSession = new CredentialSession(new URL(this.pds), undefined, (evt: AtpSessionEvent, sess?: AtpSessionData) => { this.cache.cacheAuth.set(`appPwSession-${this.name}`, sess); }); this.agent = new Agent(this.appSession); @@ -58,7 +61,6 @@ export class BlueSkyAppApiClient extends AbstractBlueSkyApiClient { return false; } this.logger.debug('Logged in.'); - //this.cache.cacheAuth.set(`appPwSession-${this.name}`, f.data); return true; } catch (e) { this.logger.error('Could not login using app password', { cause: e }); diff --git a/src/backend/scrobblers/TealfmScrobbler.ts b/src/backend/scrobblers/TealfmScrobbler.ts index fe308063..b7606191 100644 --- a/src/backend/scrobblers/TealfmScrobbler.ts +++ b/src/backend/scrobblers/TealfmScrobbler.ts @@ -5,17 +5,17 @@ import { buildTrackString, capitalize } from "../../core/StringUtils.js"; import { isNodeNetworkException } from "../common/errors/NodeErrors.js"; import { UpstreamError } from "../common/errors/UpstreamError.js"; import { FormatPlayObjectOptions } from "../common/infrastructure/Atomic.js"; -import { playToListenPayload } from "../common/vendor/ListenbrainzApiClient.js"; +import { musicServiceToCononical, playToListenPayload } from "../common/vendor/ListenbrainzApiClient.js"; import { Notifiers } from "../notifier/Notifiers.js"; import AbstractScrobbleClient from "./AbstractScrobbleClient.js"; -import { isDebugMode } from "../utils.js"; -import { KoitoClientConfig } from "../common/infrastructure/config/client/koito.js"; -import { KoitoApiClient, listenObjectResponseToPlay } from "../common/vendor/koito/KoitoApiClient.js"; -import { TealClientConfig } from "../common/infrastructure/config/client/tealfm.js"; +import { ListRecord, ScrobbleRecord, TealClientConfig } from "../common/infrastructure/config/client/tealfm.js"; import { BlueSkyAppApiClient } from "../common/vendor/bluesky/BlueSkyAppApiClient.js"; import { BlueSkyOauthApiClient } from "../common/vendor/bluesky/BlueSkyOauthApiClient.js"; import { AbstractBlueSkyApiClient } from "../common/vendor/bluesky/AbstractBlueSkyApiClient.js"; +import { getRoot } from "../ioc.js"; +import dayjs from "dayjs"; +import { parseRegexSingle } from "@foxxmd/regex-buddy-core"; export default class TealScrobbler extends AbstractScrobbleClient { @@ -28,9 +28,8 @@ export default class TealScrobbler extends AbstractScrobbleClient { constructor(name: any, config: TealClientConfig, options = {}, notifier: Notifiers, emitter: EventEmitter, logger: Logger) { super('tealfm', name, config, notifier, emitter, logger); - // https://listenbrainz.readthedocs.io/en/latest/users/api/core.html#get--1-user-(user_name)-listens - // 1000 is way too high. maxing at 100 - this.MAX_INITIAL_SCROBBLES_FETCH = 100; + this.MAX_INITIAL_SCROBBLES_FETCH = 20; + this.scrobbleDelay = 1500; this.supportsNowPlaying = false; if(config.data.appPassword !== undefined) { this.client = new BlueSkyAppApiClient(name, config.data, {...options, logger}); @@ -42,7 +41,7 @@ export default class TealScrobbler extends AbstractScrobbleClient { } } - formatPlayObj = (obj: any, options: FormatPlayObjectOptions = {}) => listenObjectResponseToPlay(obj, options); + formatPlayObj = (obj: any, options: FormatPlayObjectOptions = {}) => recordToPlay(obj); public playToClientPayload(playObject: PlayObject): object { return playToListenPayload(playObject); @@ -89,7 +88,13 @@ export default class TealScrobbler extends AbstractScrobbleClient { } getScrobblesForRefresh = async (limit: number) => { - return []; + let list: ListRecord[]; + try { + list = await this.client.listScrobbleRecord(limit) + } catch (e) { + throw new Error('Error occurred while trying to fetch records', {cause: e}); + } + return list.map(x => listRecordToPlay(x)); } alreadyScrobbled = async (playObj: PlayObject, log = false) => (await this.existingScrobble(playObj)) !== undefined @@ -103,7 +108,7 @@ export default class TealScrobbler extends AbstractScrobbleClient { } = playObj; try { - + await this.client.createScrobbleRecord(playToRecord(playObj)) if (newFromSource) { this.logger.info(`Scrobbled (New) => (${source}) ${buildTrackString(playObj)}`); } else { @@ -116,3 +121,70 @@ export default class TealScrobbler extends AbstractScrobbleClient { } } } + +export const playToRecord = (play: PlayObject): ScrobbleRecord => { + + const record: ScrobbleRecord = { + $type: "fm.teal.alpha.feed.play", + trackName: play.data.track, + artists: play.data.artists.map(x => ({artistName: x})), + duration: play.data.duration, + playedTime: play.data.playDate.toISOString(), + releaseName: play.data.album, + submissionClientAgent: `multi-scrobbler/${getRoot().items.version}`, + musicServiceBaseDomain: play.meta.musicService !== undefined ? musicServiceToCononical(play.meta.musicService) : undefined, + recordingMbId: play.data.meta?.brainz?.track, + releaseMbId: play.data.meta?.brainz?.album + } + + return record; +} + +export const listRecordToPlay = (listRecord: ListRecord): PlayObject => { + const opts: RecordOptions = {}; + const uriRes = parseRegexSingle(ATPROTO_URI_REGEX, listRecord.uri); + if(uriRes !== undefined) { + opts.web = `https://atp.tools/at:/${uriRes.named.resource}`; + opts.playId = uriRes.named.tid; + opts.user = uriRes.named.did; + } + return recordToPlay(listRecord.value, opts); +} + +interface RecordOptions { + web?: string, + playId?: string, + user?: string +} +export const recordToPlay = (record: ScrobbleRecord, options: RecordOptions = {}): PlayObject => { + + const play: PlayObject = { + data: { + track: record.trackName, + artists: record.artists.filter(x => x.artistName !== undefined).map(x => x.artistName), + duration: record.duration, + playDate: dayjs(record.playedTime), + album: record.releaseName, + meta: { + brainz: { + track: record.recordingMbId, + album: record.releaseMbId, + artist: record.artists.filter(x => x.artistMbId !== undefined).map(x => x.artistMbId) + } + } + }, + meta: { + source: 'tealfm', + parsedFrom: 'history', + playId: options.playId, + url: { + web: options.web + }, + user: options.user + } + }; + + return play; +} + +const ATPROTO_URI_REGEX = new RegExp(/at:\/\/(?(?did.*?)\/fm.teal.alpha.feed.play\/(?.*))/); \ No newline at end of file