Something went wrong. Try again.
[READ-ONLY] Mirror of https://github.com/FoxxMD/multi-scrobbler. Scrobble plays from multiple sources to multiple clients docs.multi-scrobbler.app
deezer docker jellyfin koito lastfm listenbrainz maloja mopidy mpris music music-assistant plex scrobble self-hosted spotify subsonic tautulli youtube-music
Something went wrong. Try again.
23 kB · 562 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562import { creditId, creditIsrc, creditMbid, creditsToNames, isrcMeta, mbMeta, playImage, withAlbumArt } from "../../../core/MusicMetadata.ts";import dayjs from "dayjs";import {rockskyRequiredFields, type Credit, type LifecycleInput, type PlayObject, type PlayObjectMinimal, type RockskyConfidenceField, type RockskyMissingField, type ScrobbleActionResult, type URLData} from "../../../core/Atomic.ts";import { nonEmptyStringOrDefault } from "../../../core/StringUtils.ts";import { creditToName } from "../../../core/MusicMetadata.ts";import { nameToCredit } from "../../../core/MusicMetadata.ts";import { namesToCredits } from "../../../core/MusicMetadata.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, 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, SimpleError } from "../errors/MSErrors.ts";import { type CreateScrobbleInput, RockskyClient, Agent, type SongViewDetailed, type ScrobbleInput, type ScrobbleViewBasic, RockskyError, type ActorTrackView } from "@rocksky/sdk";import { RockskyIndex } from "@rocksky/sdk/dedup";import { getRootCommon } from "../../iocCommon.ts";import type { MSCache } from "../Cache.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';import { findCauseByFunc } from "../../utils/ErrorUtils.ts";import { hashObject } from "../../utils/StringUtils.ts";import { normalizeStr } from "../../../core/StringUtils.ts";import { stringSameness } from "@foxxmd/string-sameness";import clone from "clone";import { difference } from '../../../core/DataUtils.ts';import path from "node:path";import { ATProtoUnauthenticatedApiClient } from "./atproto/ATProtoUnauthenticatedApiClient.ts";import fsPromise from 'node:fs/promises';import { getDataDir } from "../index.ts";import { getScrobbleTsSOCDate } from "../../utils/TimeUtils.ts";import { RockskyClientPool } from "./rocksky/RockskyClientPool.ts";
interface SubmitOptions { log?: boolean listenType?: ListenType}
export interface ListensResponse { count: number; listens: ListenResponse[];}
export interface SubmitResponse { payload?: { ignored_listens: number submitted_listens: number }, status: string}
export class RockSkyApiClient extends AbstractApiClient {
declare config: RockSkyClientData; lzUrl!: URLData; apiUrl: URLData; isKoito: boolean = false; cache: MSCache; userData?: HandleData
rsClient: RockskyClient; rsPool: RockskyClientPool; rsAgent?: Agent; rsIndex: RockskyIndex;
protected configDir: string;
constructor(name: any, config: RockSkyData & RockSkyOptions, options: AbstractApiOptions & {configDir: string}) { super('RockSky', name, config, options); const { apiUrl, token, } = config;
this.configDir = options.configDir; this.cache = getRootCommon().cache(); this.apiUrl = normalizeWebAddress(apiUrl ?? 'https://api.rocksky.app/xrpc');
this.logger.verbose(`API URL: '${apiUrl ?? '(None Given)'}' => Normalized: '${this.apiUrl.url}'`);
this.rsPool = new RockskyClientPool('Pool', {apis: [{enable: true}]}, {logger: this.logger}); this.rsClient = new RockskyClient(this.apiUrl.url.origin, token); this.rsIndex = new RockskyIndex(path.resolve(getDataDir(), `${this.getSafeExternalId()}-rsindex`)); }
async [Symbol.asyncDispose]() { try { await this.rsIndex.close(); } catch (e) { this.logger.warn(e); } }
public async buildData() { await this.rsIndex.open(); try { 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 }); } catch (e) { throw new UpstreamError('Unable to resolve atproto identifier', {cause: e, showStopper: false}); } }
testConnection = async () => { try { await isPortReachableConnect(this.apiUrl.port, {host: this.apiUrl.url.hostname, timeout: 2000}); } catch (e) { throw new Error('Could not reach API URL endpoint', {cause: e}); } return true; }
testAuth = async () => { if(this.userData === undefined) { try { await this.buildData(); } catch (e) { this.logger.warn(new Error('Failed to deterine if atproto identifier is real. Will proceed with config-defined handle but it may fail!', {cause: e})); } }
// authed write operations straight through PDS using xrpc if((this.userData !== undefined || this.config.handle !== undefined) && this.config.appPassword !== undefined) { try { this.rsAgent = await Agent.login(this.userData?.did ?? this.userData?.handle ?? this.config.handle, this.config.appPassword); this.rsAgent.useIndex(this.rsIndex); return true; } catch (e) { throw new AuthError('Could not login using handle/did and appPassword', {cause: e, unrecoverable: true}); } }
// 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.config.token !== undefined) { try { await this.rsClient.apikeys(); // const req = request.get('https://api.rocksky.app/profile').set('Authorization', `Bearer ${this.config.token}`); // await req; return true; } catch (e) { const upstreamErr = new UpstreamError('Failed to get apikeys() to test auth validity of token', {cause: e}); const cause = findCauseByFunc<RockskyError>(e, (ee) => ee instanceof RockskyError); throw new AuthError('Failed to get /profile with given token', {cause: upstreamErr, unrecoverable: cause?.status !== undefined && [401,403].includes(cause.status)}); } } throw new AuthError('No required credentials provided to try an authentication method.', {unrecoverable: true}); }
getUserListens = async (maxTracks: number, user?: string): Promise<RockskyScrobble[]> => { try {
const res = await this.rsPool.rsProxy.scrobbles(user ?? this.userData?.did ?? this.userData?.handle ?? this.config.handle, maxTracks, 0); // const res = await this.rsClient.actor.getActorScrobbles({ // limit: maxTracks, // offset: 0, // did: this.userData.did // }); return res; } catch (e) { throw e; } }
getRecentlyPlayed = async (maxTracks: number, user?: string): Promise<PlayObject[]> => { try { const resp = await this.getUserListens(maxTracks, user); return resp.map(x => rockskyScrobbleToPlay(x)); } catch (e: any) { this.logger.error(`Error encountered while getting User listens | Error => ${e.message}`); return []; } }
submitListen = async (play: PlayObject, options: SubmitOptions & {force?: boolean} = {}): Promise<ScrobbleActionResult> => { const { log = false, force = false} = options;
const warnings: string[] = [];
/** * paths is asynchronous, server-side validation of scrobbles * we don't recieve any real feedback about whether the scrobbles were accepted */ if(this.rsAgent === undefined) { const payload = removeUndefinedKeys(playToRockskyClientRecord(play), false); if(log) { this.logger.debug(`Submit Payload: ${JSON.stringify(payload)}`); } const resp = await this.rsClient.createScrobble(payload); return {payload, response: resp, createdAt: dayjs().toISOString()} }
/** * Client writes directly to PDS and is responsible for validating scrobbles * We get immediate feedback since we do all the work */
const found = hasRequiredScrobbleFields(play); if(found.length !== rockskyRequiredFields.options.length) { throw new ScrobbleSubmitError(`Will not submit scrobble because required fields are missing from data: ${difference(rockskyRequiredFields.options, found).join(', ')}`, {payload: playToRockskyClientRecord(play)}); }
const confidenceFields = hasScrobbleConfidenceFields(play); if(confidenceFields.length === 0) { if(force) { warnings.push('Scrobble data is missing all confidence fields (isrc, mbid, spotifyId) but user forced scrobble.'); } throw new ScrobbleSubmitError(`Will not submit scrobble because no confidence fields were found (isrc, mbid, spotifyId). Retry/force scrobble if you are sure it is correct.`, {payload: playToRockskyClientRecord(play)}); }
const payload = playToRockskyAgentRecord(play); let merged: PlayObject | undefined; try { const res = await this.rsAgent.scrobble(payload); const uData = rockskyUriToData(res); if(uData !== undefined) { merged = clone(play); merged.meta.url = { ...(merged.meta.url ?? {}), web: uData.web }; if(merged.meta.playId === undefined) { merged.meta.playId = uData.playId; } if(merged.meta.user === undefined) { merged.meta.user = uData.user; } } return removeUndefinedKeys({payload, response: res, mergedScrobble: merged, createdAt: dayjs().toISOString(), warnings: warnings.length === 0 ? undefined : warnings}, false); } catch (e) { throw new ScrobbleSubmitError(`Error occurred while writing scrobble to PDS`, {cause: e, payload: payload}); } }
updateNowPlaying = async (play?: PlayObject): Promise<void> => { if(this.rsAgent === undefined) { throw new SimpleError('Cannot set now playing when no using user/appPassword authentication'); } if(play === undefined) { await this.rsAgent.clearNowPlaying(); return; } await this.rsAgent.setNowPlaying(playToActorTrackView(play)); }
getRockskySongMatch = async (play: PlayObject): Promise<SongViewDetailed> => { const input = playToMatchSongInput(play); const inputHash = hashObject(removeUndefinedKeys(input, false)); const cacheKey = `rsMatchSong-${inputHash}`; let songDetailed: SongViewDetailed | undefined = await this.cache.cacheApi.get<SongViewDetailed>(cacheKey); if(songDetailed === undefined) { try { songDetailed = await this.rsPool.rsProxy.matchSong(input.title, input.artist, input.mbId, input.isrc, input.album); await this.cache.cacheApi.set(cacheKey, songDetailed); } catch (e) { throw new UpstreamError('Unable to match Play input with Rocksky song', {cause: e}); } }
return songDetailed; }
static formatPlayObj(obj: any, options: FormatPlayObjectOptions): PlayObject { return rockskyScrobbleToPlay(obj); }
async fetchCarToFile() { // TODO use `since` to get CAR diff instead of entire repo // can use last import date from migrations table if(this.userData === undefined) { throw new Error('Cannot fetch CAR because atproto identifier has not been resolved'); } const {did} = this.userData; const filename = path.resolve(this.configDir, `${this.getSafeExternalId()}-${dayjs().unix()}.car`); const atClient = new ATProtoUnauthenticatedApiClient('rocksky', { handleData: this.userData, identifier: this.config.handle }, { logger: this.logger }); await atClient.initClient(); await fsPromise.writeFile(filename, Buffer.from(await atClient.getCAR(did))); return filename; }
async syncSdkRepo(filename: string) { if(this.userData === undefined) { throw new Error('Cannot sync repo because atproto identifier has not been resolved'); } await this.rsIndex.indexCar(this.userData.did, await fsPromise.readFile(filename)); }
public getSafeExternalId() { return `${this.type}-${normalizeStr(this.name, {keepSingleWhitespace: false})}`; } }
interface RsMatchSongInput { title: string, artist: string, mbId?: string isrc?: string album?: string}
export const hasRequiredScrobbleFields = (play: PlayObject): RockskyMissingField[] => { const found: RockskyMissingField[] = [];
if(play.data.track !== undefined && play.data.track.name.trim() !== '') { found.push('track'); } if(play.data.artists !== undefined && play.data.artists.length > 0) { found.push('artists'); } if(play.data.album !== undefined && play.data.album.name.trim() !== '') { found.push('album'); } return found;}
export const hasScrobbleConfidenceFields = (play: PlayObject): RockskyConfidenceField[] => { const found: RockskyConfidenceField[] = [];
if(creditIsrc(play.data.track)) { found.push('isrc'); } if(creditMbid(play.data.track, 'recording')) { found.push('mbid'); } if(creditId(play.data.track, 'spotify', 'track')) { found.push('spotify'); } return found;}
const playToMatchSongInput = (play: PlayObject): RsMatchSongInput => ({ title: requirePlayTrack(play), artist: creditsToNames(play.data.artists).join(', '), mbId: creditMbid(play.data.track, 'track') ?? creditMbid(play.data.track, 'recording'), isrc: creditIsrc(play.data.track), album: creditToName(play.data.album)})
const mergeSongViewWithPlay = (song: SongViewDetailedMS, play: PlayObject): PlayObject => { const svPlay = songViewToPlay(song);
const mergedPlay: PlayObject = { data: { track: svPlay.data.track ?? play.data.track, album: svPlay.data.album ?? play.data.album, duration: svPlay.data.duration ?? play.data.duration, meta: { ...play.data.meta } }, meta: { ...svPlay.meta, ...play.meta } };
return mergedPlay;}
export const songViewToPlay = (song: SongViewDetailedMS): PlayObject => {
let artists: Credit[] = [], albumArtists: Credit[]; if(song.mbArtists !== undefined && song.mbArtists !== null && song.mbArtists.length > 0) { artists = song.mbArtists.map(x => nameToCredit(x.name, mbMeta(x.mbid, 'artist'))); } else if(song.artists !== undefined && song.artists !== null && song.artists.length > 0) { artists = song.artists.flatMap(x => x.name !== undefined ? [{name: x.name}] : []) } else if(song.artist !== undefined && song.artist !== null) { artists = [{name: song.artist}]; }
if(song.albumArtist !== undefined && song.albumArtist !== null) { if(song.albumArtist === song.artist || stringSameness(song.albumArtist, creditsToNames(artists).join(',')).highScore > 90) { albumArtists = artists; } else { albumArtists = [{name: song.albumArtist}] } } else { albumArtists = artists; }
// api returns mbId but type says mbid const mb = song.mbid ?? (song as any).mbId;
const play: PlayObject = { data: { track: nameToCredit(song.title, mbMeta(mb, 'recording'), isrcMeta(song.isrc)), artists, albumArtists, album: nameToCredit(song.album !== '' ? song.album : undefined), duration: song.duration !== undefined && song.duration !== 0 ? song.duration / 1000 : undefined, }, meta: { trackId: song.id, source: 'Rocksky', url: { web: song.uri } } }; let albumArt = song.albumArt; if(albumArt === undefined) { const match = song.matches?.[0]; if(match !== undefined && match.albumArt !== undefined) { albumArt = match.albumArt; } }
play.data = withAlbumArt(play.data, albumArt);
return baseFormatPlayObj(song, play);}
export const rockskyScrobbleToPlay = (obj: RockskyScrobble, opts: {playId?: string, web?: string, user?: string} = {}): PlayObject => { const { playId, web, user } = opts; const play: PlayObjectMinimal = { data: { track: nameToCredit(obj.title), artists: namesToCredits(obj.artist !== undefined && nonEmptyStringOrDefault(obj.artist) ? [obj.artist] : []), albumArtists: namesToCredits(obj.albumArtist !== undefined && nonEmptyStringOrDefault(obj.albumArtist) ? [obj.albumArtist] : []), album: nameToCredit(nonEmptyStringOrDefault(obj.album)), playDate: dayjs.utc(obj.createdAt).local() }, meta: { playId, user } }; if(web !== undefined) { play.meta.url = {web}; } if('trackId' in obj) { play.meta.trackId = obj.trackId as string; } // if('id' in obj) { // play.meta.playId = obj.id as string; // } if('albumArt' in obj) { play.data = withAlbumArt(play.data, obj.albumArt as string); }
if(obj.uri !== undefined) { const uData = rockskyUriToData( obj.uri); if(uData !== undefined) { if(web === undefined) { play.meta.url = { web: uData.web } } if(playId === undefined) { play.meta.playId = uData.playId; } if(user === undefined) { play.meta.user = uData.user; } } }
return baseFormatPlayObj(obj, play);}
// TODO remove once albumArtist is correctly included in CreateScrobbleInput interfacetype RealCreateScrobbleInput = CreateScrobbleInput & {albumArtist: string};
export const playToRockskyClientRecord = (play: PlayObject): RealCreateScrobbleInput => { const artistStr = creditsToNames(play.data.artists).join(', ');
const isrc = creditIsrc(play.data.track); const csi: RealCreateScrobbleInput = { title: requirePlayTrack(play), artist: artistStr, // albumArtist is a required field on rocksky server-side // tsiry's advice is that if there really is no album artists then just use the same value as artist albumArtist: (play.data.albumArtists ?? []).length === 0 ? artistStr : creditsToNames(play.data.albumArtists).join(', '), album: creditToName(play.data.album), mbId: creditMbid(play.data.track, 'recording'), isrc: isrc !== undefined ? isrcNoHyphens(isrc) : undefined, duration: play.data.duration !== undefined ? play.data.duration * 1000 : 0, spotifyLink: play.meta.source === 'spotify' && play.meta.url?.web !== undefined ? play.meta.url?.web : undefined, timestamp: getScrobbleTsSOCDate(play).unix() } return csi;}
export const playToRockskyAgentRecord = (play: PlayObject): ScrobbleInput => { const artistStr = creditsToNames(play.data.artists).join(', '); const {album} = play.data; if(album === undefined) { throw new SimpleError('Play must have an album to be converted to a Rocksky record'); }
const isrc = creditIsrc(play.data.track); const csi: ScrobbleInput = { title: requirePlayTrack(play), artist: artistStr, // albumArtist is a required field on rocksky server-side // tsiry's advice is that if there really is no album artists then just use the same value as artist albumArtist: (play.data.albumArtists ?? []).length === 0 ? artistStr : creditsToNames(play.data.albumArtists).join(', '), album: album.name, mbid: creditMbid(play.data.track, 'recording'), isrc: isrc !== undefined ? isrcNoHyphens(isrc) : undefined, duration: play.data.duration !== undefined ? play.data.duration * 1000 : 0, spotifyLink: play.meta.source === 'spotify' && play.meta.url?.web !== undefined ? play.meta.url?.web : undefined, createdAt: getScrobbleTsSOCDate(play).toISOString() } return csi;}
export const playToActorTrackView = (play: PlayObject): ActorTrackView => removeUndefinedKeys({ name: requirePlayTrack(play), artist: creditsToNames(play.data.artists).join(', '), album: creditToName(play.data.album), albumCoverUrl: playImage(play.data), durationMs: play.data.duration !== undefined ? Math.floor(play.data.duration * 1000) : undefined, source: play.meta.musicService, recordingMbId: creditMbid(play.data.track, 'recording') }, false)
const requirePlayTrack = (play: PlayObject): string => { if(play.data.track === undefined) { throw new SimpleError('Play must have a track title to be converted to a Rocksky record'); } return play.data.track.name;}
const ATPROTO_URI_REGEX = new RegExp(/at:\/\/(?<resource>(?<did>did.*?)\/app\.rocksky\.scrobble\/(?<tid>.*))/);
const rockskyUriToData = (str: string): { web?: string, playId?: string, user?: string } | undefined => { const uriRes = parseRegexSingle(ATPROTO_URI_REGEX, str); if (uriRes !== undefined) { return { web: `https://atproto.at/viewer?uri=${uriRes.named.resource}`, playId: uriRes.named.tid, user: uriRes.named.did } } return undefined;}export type RockskyScrobble = ScrobbleViewBasic;
export interface SongViewDetailedMS extends SongViewDetailed { requestQuery: string; requestQueries?: LifecycleInput[]; mbArtists?: { name: string; mbid: string; }[];}