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.
12 kB · 331 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332import dayjs from "dayjs";import type { EventEmitter } from "events";import path from 'path';import {MPC, type Status, type Song, type PlaylistItem} from 'mpc-js';import type {BrainzMeta, ComponentAuthType, PlayObject, PlayObjectMinimal} from "../../core/Atomic.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 { REPORTED_PLAYER_STATUSES } from '../../core/Atomic.ts';import type {ReportedPlayerStatus} from '../../core/Atomic.ts';import type {MPDSourceConfig, PlayerState} from "../common/infrastructure/config/source/mpd.ts";import { isPortReachable } from "../utils/NetworkUtils.ts";import type {RecentlyPlayedOptions} from "./AbstractSource.ts";import { MemoryPositionalSource } from "./MemoryPositionalSource.ts";import { baseFormatPlayObj } from "../utils/PlayTransformUtils.ts";import { isDebugMode, sleep } from "../utils.ts";import { artistNamesToCredits } from "../../core/StringUtils.ts";import { AuthError } from "../common/errors/MSErrors.ts";
const CLIENT_PLAYER_STATE: Record<PlayerState, ReportedPlayerStatus> = { 'play': REPORTED_PLAYER_STATUSES.playing, 'pause': REPORTED_PLAYER_STATUSES.paused, 'stop': REPORTED_PLAYER_STATUSES.stopped,}
export class MPDSource extends MemoryPositionalSource { declare config: MPDSourceConfig;
host?: string port?: number mpc: MPC; deviceId: string
protected currentPlayPath: string; protected currentPlaySong?: Song;
override authType: ComponentAuthType = COMPONENT_AUTH_TYPE.unattended;
constructor(name: any, config: MPDSourceConfig, internal: InternalConfig, emitter: EventEmitter) { const { data = {} } = config; const { interval = 5, // reduced polling interval because its likely we are on the same network ...rest } = data; super('mpd', name, {...config, data: {...rest, interval}}, internal, emitter);
this.requiresAuth = true; this.canPoll = true; }
static parseConnectionUrl(valRaw: string): [string, string] { if(valRaw.trim() === '') { throw new Error(`'url' cannot be an empty string`); }
const [host, port] = valRaw.trim().split(':'); return [host, port ?? '6600']; }
protected async doBuildInitData(): Promise<true | string | undefined> { const { data: { url, path, } = {} } = this.config;
if(path === undefined) { const [host, port] = MPDSource.parseConnectionUrl(url ?? 'localhost:6600'); this.logger.verbose(`Config URL: '${url ?? '(None Given)'}' => Normalized: '${host}:${port}'`); this.host = host; this.port = Number.parseInt(port); } else { this.logger.verbose(`Using socket path: ${path}`); }
return true; }
protected async doCheckConnection(): Promise<true | string | undefined> { if(this.host !== undefined) { try { await isPortReachable(this.port, {host: this.host}); return `${this.host}:${this.port} is reachable.`; } catch (e) { throw e; } } return null; }
doAuthentication = async () => {
try { this.mpc = new MPC(); if(this.host !== undefined) { const prom = this.mpc.connectTCP(this.host, this.port) await Promise.race([ prom, sleep(1000) ]); if(!this.mpc.isReady) { // handled any rejected socket promise error, if it occurs later prom.catch(err => this.logger.warn(err)); this.mpc.disconnect(); throw new Error('Timed out waiting for TCP response from MPD'); } } else { await this.mpc.connectUnixSocket(this.config.data.path); }
if(this.config.data.password !== undefined) { await this.mpc.connection.password(this.config.data.password); } this.mpc.on('changed', (p) => { if(p.includes('player') && this.getIsSleeping()) { // wake up now! this.logger.debug(`Waking up from sleeping ${Math.abs(this.getWakeAt().diff(dayjs(), 'ms'))}ms early due to player state change`) this.setWakeAt(dayjs()); } }); return true; } catch (e) { let friendlyError: string | undefined; if(e.code === 'ENOENT') { friendlyError = 'Socket file does not exist' } else if(e.code === 'EACCES') { friendlyError = 'Incorrect permissions to access socket file' } // if(e.errno !== undefined) { // switch(e.errno) { // case mpd2.default.MPDError.CODES.PERMISSION: // friendlyError = 'No permission to connect'; // break; // case mpd2.default.MPDError.CODES.PASSWORD: // friendlyError = 'Password is probably not correct'; // break; // } // } throw new AuthError(`Could not connect to MPD server${friendlyError !== undefined ? ` (Hint: ${friendlyError})` : ''}`, {cause: e, unrecoverable: false}); } }
formatPlayObj(obj: Song | PlaylistItem, options: FormatPlayObjectOptions & {state?: Status} = {}): PlayObject {
let trackName: string, album: string, artists: string[] | undefined = [], albumArtists: string[] | undefined = [], duration: number, position: number, brainz: BrainzMeta = {};
const { state: { elapsed: sElapsed, duration: sDuration } = {} } = options;
position = sElapsed;
if('entryType' in obj && obj.entryType === 'song') { const { path: file, duration: songDuration, artist, performer, album: sAlbum, albumArtist, title, name, musicBrainzAlbumArtistId: musicbrainz_albumartistid, musicBrainzAlbumId: musicbrainz_albumid, musicBrainzArtistId: musicbrainz_artistid, musicBrainzReleaseTrackId: musicbrainz_releasetrackid, musicBrainzTrackId: musicbrainz_trackid, } = obj;
trackName = title; if(trackName === undefined && name !== undefined) { trackName = name; } else if(trackName === undefined && file !== undefined) { const pathSplit = file.split(path.sep); if(pathSplit.length > 1) { trackName = pathSplit[pathSplit.length - 1]; } else { trackName = file; } }
if(artist !== undefined) { artists.push(artist); } if(albumArtist !== undefined && albumArtist !== artist) { albumArtists.push(albumArtist); } if(artists.length === 0 && performer !== undefined) { artists.push(performer); } if(artists.length === 0 && albumArtists.length !== 0) { // switch these, tags are probably improper artists = albumArtists; albumArtists = []; }
album = sAlbum;
duration = songDuration ?? sDuration;
brainz = { albumArtist: musicbrainz_albumartistid !== undefined ? [musicbrainz_albumartistid] : undefined, album: musicbrainz_albumid, recording: musicbrainz_trackid, artist: musicbrainz_artistid !== undefined ? [musicbrainz_artistid] : undefined };
} else { const { path: file, duration: songDuration, artist, album: pAlbum, albumArtist, title, name } = obj;
trackName = title ?? name; if(trackName === undefined) { const pathSplit = file.split(path.sep); if(pathSplit.length > 1) { trackName = pathSplit[pathSplit.length - 1]; } else { trackName = file; } }
artists = artist !== undefined ? [artist] : undefined; album = pAlbum; albumArtists = albumArtist !== undefined && albumArtist !== artist ? [albumArtist] : undefined; duration = songDuration ?? sDuration; }
if(duration !== undefined) { duration = Math.floor(duration); } if(position !== undefined) { // so that we can end up with 100% played position = Math.ceil(position); }
const play: PlayObjectMinimal = { data: { artists: artists !== undefined ? artistNamesToCredits(artists) : [], albumArtists: albumArtists !== undefined ? artistNamesToCredits(albumArtists) : [], album, track: trackName, duration, meta: { brainz } }, meta: { trackProgressPosition: position, mediaPlayerName: 'mpd' } } return baseFormatPlayObj({...obj, trackProgressPosition: position}, play); }
getRecentlyPlayed = async (options: RecentlyPlayedOptions = {}) => {
let mpcCurrentItem: PlaylistItem, //mpcSong: Song, mpcStatus: Status; try { mpcCurrentItem = await this.mpc.status.currentSong(); mpcStatus = await this.mpc.status.status(); } catch (e) { this.connectionOK = false; this.authed = false; throw e; }
let play: PlayObject | undefined, newPath = false; if(mpcCurrentItem !== undefined && mpcCurrentItem.path !== undefined) { if(this.currentPlayPath !== mpcCurrentItem.path) { newPath = true; this.currentPlaySong = undefined; this.currentPlayPath = mpcCurrentItem.path; try { const resp = await this.mpc.database.listInfo(mpcCurrentItem.path); if(resp.length > 0 && resp[0].isSong()) { //mpcSong = resp[0]; this.currentPlaySong = resp[0]; } } catch (e) { this.logger.warn(`Could not retrieve Song db info for uri ${mpcCurrentItem.path}`); } } play = this.formatPlayObj(this.currentPlaySong ?? mpcCurrentItem, { state: mpcStatus });
if(newPath) { this.logger.trace('Current playing is a new path. Logging payload/Play on first seen for this path'); this.logger.trace(`MPD Payload => ${JSON.stringify({currentItem: mpcCurrentItem, status: mpcStatus, song: this.currentPlaySong})}`); this.logger.trace(`MS Play => ${JSON.stringify(play)}`); }
if(isDebugMode() && !newPath) { this.logger.trace(`Raw mpc.js payload => ${JSON.stringify({mpcStatus, mpcSong: mpcCurrentItem})}`); } }
const playerState: PlayerStateData = { platformId: SINGLE_USER_PLATFORM_ID, status: CLIENT_PLAYER_STATE[mpcStatus.state], play, position: play?.meta?.trackProgressPosition }
return await this.processRecentPlays([playerState]); }
}