feat: Implement working tealfm client

* Add pds option
This commit is contained in:
FoxxMD 2025-11-07 16:31:41 +00:00
parent ea23a89101
commit ebb74d1b5c
5 changed files with 170 additions and 20 deletions

View file

@ -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<T> {
uri: string;
cid: string;
value: T;
}

View file

@ -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)) {

View file

@ -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<boolean>;
async createScrobbleRecord(record: ScrobbleRecord): Promise<void> {
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<ListRecord<ScrobbleRecord>[]> {
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<ScrobbleRecord>[];
} catch (e) {
throw new Error(`Failed to create record`, { cause: e });
}
}
}

View file

@ -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 });

View file

@ -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<ScrobbleRecord>[];
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<ScrobbleRecord>): 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:\/\/(?<resource>(?<did>did.*?)\/fm.teal.alpha.feed.play\/(?<tid>.*))/);