mirror of
https://github.com/FoxxMD/multi-scrobbler.git
synced 2026-05-05 23:50:21 +00:00
feat: Implement working tealfm client
* Add pds option
This commit is contained in:
parent
ea23a89101
commit
ebb74d1b5c
5 changed files with 170 additions and 20 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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>.*))/);
|
||||
Loading…
Add table
Add a link
Reference in a new issue