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.
9.7 kB · 262 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263import { childLogger } from "@foxxmd/logging";import type { EventEmitter } from "events";import type { CloseEvent, ErrorEvent, RetryEvent } from 'iso-websocket';import { WS } from 'iso-websocket'import { randomUUID } from "node:crypto";import pEvent from 'p-event';import type {ComponentAuthType, PlayObject, PlayObjectMinimal, URLData} from "../../core/Atomic.ts";import { UpstreamError } from "../common/errors/UpstreamError.ts";import { type FormatPlayObjectOptions, type InternalConfig, type PlayerStateData,} from "../common/infrastructure/Atomic.ts";import { COMPONENT_AUTH_TYPE, SINGLE_USER_PLATFORM_ID } from '../../core/Atomic.ts';import type {MCAuthenticateRequest, MCAuthenticateResponse, MCPlaybackOverviewRequest, MCPlaybackOverviewResponse, MusikcubeSourceConfig} from "../common/infrastructure/config/source/musikcube.ts";import { sleep } from "../utils.ts";import type {RecentlyPlayedOptions} from "./AbstractSource.ts";import { MemoryPositionalSource } from "./MemoryPositionalSource.ts";import { normalizeWSAddress } from "../utils/NetworkUtils.ts";import { baseFormatPlayObj } from "../utils/PlayTransformUtils.ts";import { artistNamesToCredits } from "../../core/StringUtils.ts";import { AuthError } from "../common/errors/MSErrors.ts";
const CLIENT_STATE = { 0: 'connecting', 1: 'open', 2: 'closing', 3: 'closed'}
export class MusikcubeSource extends MemoryPositionalSource { declare config: MusikcubeSourceConfig; override authType: ComponentAuthType = COMPONENT_AUTH_TYPE.unattended;
url: URLData;
client!: WS; deviceId: string version?: string
constructor(name: any, config: MusikcubeSourceConfig, internal: InternalConfig, emitter: EventEmitter) { const { data = {} } = config; const { ...rest } = data; super('musikcube', name, {...config, data: {...rest}}, internal, emitter);
const { data: { url = 'ws://localhost:7905', device_id } = {} } = config; this.deviceId = device_id ?? name; this.url = normalizeWSAddress(url, {defaultPort: 7905}); this.requiresAuth = true; this.canPoll = true; }
protected async doBuildInitData(): Promise<true | string | undefined> { const { data: { url } = {} } = this.config; const normal = this.url.normal; this.logger.verbose(`Config URL: '${url ?? '(None Given)'}' => Normalized: '${normal}'`) if (!normal.includes('ws://') && !normal.includes('wss://')) { throw new Error(`Server URL must start with ws:// or wss://`); } this.client = new WS(this.url.url.toString(), { automaticOpen: false, retry: { retries: 0 } }); const wsLogger = childLogger(this.logger, 'WS'); this.client.addEventListener('retry', (e) => { wsLogger.verbose(`Retrying connection, attempt ${e.attempt}`); }); this.client.addEventListener('close', (e) => { wsLogger.warn(`Connection was closed: ${e.code} => ${e.reason}`); if (e.reason.includes('unauthenticated')) { this.authed = false; } }); this.client.addEventListener('open', (e) => { wsLogger.verbose(`Connection was established.`); if (this.authed) { // was a reconnect, try auto authenticating wsLogger.verbose('Resending auth message after (probably) reconnection...'); this.client.send(JSON.stringify(this.getAuthPayload())); } }); this.client.addEventListener('error', (e) => { if (e.message.includes('Connection failed after')) { this.connectionOK = false; this.authed = false; } const hint = e.error?.cause?.message ?? undefined; wsLogger.error(new Error(`Communication with server failed${hint !== undefined ? ` (${hint})` : ''}`, {cause: e.error})); });
this.client.addEventListener('message', (e) => { const data = getMessageData<any>(e); if(isAuthenticateResponse(data)) { this.version = data.options.environment.app_version; wsLogger.verbose(`${!data.options.authenticated ? 'NOT ' : ''}Authenticated for Muiskcube ${data.options.environment.app_version} with API v${data.options.environment.api_version}`); } }); return true; }
protected async doCheckConnection(): Promise<true | string | undefined> { try { this.client.open(); const opened = await pEvent(this.client, 'open'); return true; } catch (e) { this.client.close(); const hint = e.error?.cause?.message ?? undefined; throw new Error(`Could not connect to Musikcube metadata server${hint !== undefined ? ` (${hint})` : ''}`, {cause: e.error ?? e}); }
}
protected getAuthPayload = (): MCAuthenticateRequest => { return { name: 'authenticate', type: 'request', id: randomUUID(), device_id: this.deviceId, options: { password: this.config.data.password } } }
doAuthentication = async () => { try { const authRace = Promise.race([ pEvent(this.client, 'message'), pEvent(this.client, 'close'), sleep(2000), ]); this.client.send(JSON.stringify(this.getAuthPayload())); const authE = await authRace; if(authE === undefined) { throw new Error('Musikcube did not respond to auth message after 2000 ms'); } else if(isCloseEvent(authE)) { throw new AuthError(`Password is not correct: ${authE.code} => ${authE.reason}`, {unrecoverable: true}); } else if(isErrorEvent(authE)) { throw new AuthError(`Unexpected error occurred while authenticating: ${authE.message}`, {cause: authE.error, unrecoverable: false}); }
return true; } catch (e) { throw e; } }
formatPlayObj(obj: MCPlaybackOverviewResponse, options: FormatPlayObjectOptions = {}): PlayObject { const { options: { playing_duration, playing_current_time, playing_track: { album, album_artist, artist, title, id, external_id } }, } = obj; const artists: string[] = []; const albumArtists: string[] = []; if(artist !== undefined) { artists.push(artist); } if(album_artist !== undefined && album_artist !== artist) { albumArtists.push(album_artist); } const play: PlayObjectMinimal = { data: { artists: artistNamesToCredits(artists), albumArtists: artistNamesToCredits(albumArtists), album: album === '' ? undefined : album, track: title === '' ? undefined: title, duration: playing_duration }, meta: { trackProgressPosition: playing_current_time, deviceId: this.deviceId, trackId: external_id, mediaPlayerName: 'Musikcube', mediaPlayerVersion: this.version } } return baseFormatPlayObj(obj, play); }
getRecentlyPlayed = async (options: RecentlyPlayedOptions = {}) => { if (this.client.readyState !== this.client.OPEN) { throw new Error('WS connection is no longer open.'); }
const overviewPayload: MCPlaybackOverviewRequest = { name: 'get_playback_overview', type: 'request', device_id: this.deviceId, id: randomUUID() }
const messageEventPromise = pEvent(this.client, 'message'); this.client.send(JSON.stringify(overviewPayload)); const messageEvent = await Promise.race([ messageEventPromise, sleep(2000), ]);
if(messageEvent === undefined) { throw new UpstreamError('Did not receive playback message after waiting 2000ms'); }
const playbackOverview = getMessageData<MCPlaybackOverviewResponse>(messageEvent)
const play: PlayObject | undefined = playbackOverview.options.playing_track === undefined ? undefined : this.formatPlayObj(playbackOverview);
const playerState: PlayerStateData = { platformId: SINGLE_USER_PLATFORM_ID, status: playbackOverview.options.state, play, position: playbackOverview.options.playing_current_time }
return await this.processRecentPlays([playerState]); }
}
const getMessageData = <T>(e: any): T => { return JSON.parse(e.data) as T;}
const isCloseEvent = (e: Event): e is CloseEvent => { return e.type === 'close';}const isErrorEvent = (e: Event): e is ErrorEvent => { return e.type === 'error';}const isRetryEvent = (e: Event): e is RetryEvent => { return e.type === 'retry';}
const isAuthenticateResponse = (data: any): data is MCAuthenticateResponse => { return 'name' in data && data.name === 'authenticate';}