From 9d839db2e179df0e10db21b83219fb2bc8a50f2b Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Thu, 30 Apr 2026 13:58:41 +0000 Subject: [PATCH] feat(mpd): Rewrite with newer MPD client for better payload parsing #574 mpd-api was missing fields and had inconsistent data. mpd-js is actually typed and returns better parsed data. --- package-lock.json | 103 +------ package.json | 2 +- .../infrastructure/config/source/mpd.ts | 31 +-- src/backend/sources/MPDSource.ts | 252 ++++++++++++------ 4 files changed, 188 insertions(+), 200 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0ac33fd7..16da9788 100644 --- a/package-lock.json +++ b/package-lock.json @@ -68,7 +68,7 @@ "lastfm-ts-api": "^2.6.0", "merge-error-cause": "^5.0.2", "mopidy": "^1.3.0", - "mpd-api": "^1.1.2", + "mpc-js": "^2.1.1", "musicbrainz-api": "^0.27.0", "nanoid": "^3.3.1", "node-object-hash": "^3.1.1", @@ -4149,10 +4149,6 @@ "node": ">=18" } }, - "node_modules/@tokenizer/token": { - "version": "0.3.0", - "license": "MIT" - }, "node_modules/@tsconfig/node18": { "version": "1.0.3", "dev": true, @@ -7998,6 +7994,12 @@ "node": ">=6" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, "node_modules/events": { "version": "3.3.0", "license": "MIT", @@ -8389,21 +8391,6 @@ "json-buffer": "3.0.1" } }, - "node_modules/file-type": { - "version": "16.5.4", - "license": "MIT", - "dependencies": { - "readable-web-to-node-stream": "^3.0.0", - "strtok3": "^6.2.4", - "token-types": "^4.1.1" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sindresorhus/file-type?sponsor=1" - } - }, "node_modules/filesize": { "version": "10.1.6", "dev": true, @@ -10821,23 +10808,16 @@ } } }, - "node_modules/mpd-api": { - "version": "1.1.2", - "license": "UNLICENSED", + "node_modules/mpc-js": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/mpc-js/-/mpc-js-2.1.1.tgz", + "integrity": "sha512-FQ/C4QDq8vVhnYBG51ZCrFgdzUhMfcYu35eCiGjcsqyNXvrDFL585Tg0R+vQ/Up4vb/7t1sGM4TwT7QwL3a0bA==", + "license": "MIT", "dependencies": { - "debug": "^4.3.4", - "file-type": "^16.5.3", - "mpd2": "^1.0.5" + "eventemitter3": "^5.0.1" }, "engines": { - "node": ">=11.0.0" - } - }, - "node_modules/mpd2": { - "version": "1.0.5", - "license": "UNLICENSED", - "dependencies": { - "debug": "^4.1.1" + "node": ">=18.0.0" } }, "node_modules/ms": { @@ -11700,17 +11680,6 @@ "node_modules/pause": { "version": "0.0.1" }, - "node_modules/peek-readable": { - "version": "4.1.0", - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Borewit" - } - }, "node_modules/perfect-freehand": { "version": "1.2.3", "dev": true, @@ -12368,20 +12337,6 @@ "node": ">= 6" } }, - "node_modules/readable-web-to-node-stream": { - "version": "3.0.2", - "license": "MIT", - "dependencies": { - "readable-stream": "^3.6.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Borewit" - } - }, "node_modules/readdirp": { "version": "3.6.0", "dev": true, @@ -13492,21 +13447,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/strtok3": { - "version": "6.3.0", - "license": "MIT", - "dependencies": { - "@tokenizer/token": "^0.3.0", - "peek-readable": "^4.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Borewit" - } - }, "node_modules/stylis": { "version": "4.2.0", "dev": true, @@ -13753,21 +13693,6 @@ "node": ">=0.6" } }, - "node_modules/token-types": { - "version": "4.2.1", - "license": "MIT", - "dependencies": { - "@tokenizer/token": "^0.3.0", - "ieee754": "^1.2.1" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Borewit" - } - }, "node_modules/touch": { "version": "3.1.1", "dev": true, diff --git a/package.json b/package.json index 747885d2..8138779d 100644 --- a/package.json +++ b/package.json @@ -106,7 +106,7 @@ "lastfm-ts-api": "^2.6.0", "merge-error-cause": "^5.0.2", "mopidy": "^1.3.0", - "mpd-api": "^1.1.2", + "mpc-js": "^2.1.1", "musicbrainz-api": "^0.27.0", "nanoid": "^3.3.1", "node-object-hash": "^3.1.1", diff --git a/src/backend/common/infrastructure/config/source/mpd.ts b/src/backend/common/infrastructure/config/source/mpd.ts index 6b5635f4..a7cd34f6 100644 --- a/src/backend/common/infrastructure/config/source/mpd.ts +++ b/src/backend/common/infrastructure/config/source/mpd.ts @@ -39,33 +39,4 @@ export interface MPDSourceAIOConfig extends MPDSourceConfig { type: 'mpd' } -export type PlayerState = 'play' | 'stop' | 'pause'; - -export interface StatusResponse { - state: PlayerState - /** - * Position within the current song in seconds - * */ - elapsed?: number - /** - * Duration of the current song in seconds - * */ - duration?: number - error?: string -} - -export interface CurrentSongResponse { - file: string - time: number - name?: string - performer?: string - artist?: string - album?: string - albumartist?: string - title?: string - musicbrainz_albumartistid?: string - musicbrainz_albumid?: string - musicbrainz_artistid?: string - musicbrainz_releasetrackid?: string - musicbrainz_trackid?: string -} +export type PlayerState = 'play' | 'stop' | 'pause'; \ No newline at end of file diff --git a/src/backend/sources/MPDSource.ts b/src/backend/sources/MPDSource.ts index 732b121b..b14b0c40 100644 --- a/src/backend/sources/MPDSource.ts +++ b/src/backend/sources/MPDSource.ts @@ -1,7 +1,7 @@ import dayjs from "dayjs"; import { EventEmitter } from "events"; -import mpdapiNS, { MPDApi } from 'mpd-api'; -import mpd2 from 'mpd2'; +import path from 'path'; +import {MPC, type Status, type Song, type PlaylistItem} from 'mpc-js'; import { BrainzMeta, PlayObject, PlayObjectLifecycleless } from "../../core/Atomic.js"; import { FormatPlayObjectOptions, @@ -12,17 +12,14 @@ import { SINGLE_USER_PLATFORM_ID, } from "../common/infrastructure/Atomic.js"; import { - CurrentSongResponse, MPDSourceConfig, PlayerState, - StatusResponse, } from "../common/infrastructure/config/source/mpd.js"; import { isPortReachable } from "../utils/NetworkUtils.js"; import { RecentlyPlayedOptions } from "./AbstractSource.js"; import { MemoryPositionalSource } from "./MemoryPositionalSource.js"; import { baseFormatPlayObj } from "../utils/PlayTransformUtils.js"; - -const mpdClient = mpdapiNS.default; +import { isDebugMode, sleep } from "../utils.js"; const CLIENT_PLAYER_STATE: Record = { 'play': REPORTED_PLAYER_STATUSES.playing, @@ -35,11 +32,12 @@ export class MPDSource extends MemoryPositionalSource { host?: string port?: number - // {host?: string, port?: number, path?: string, password?: string}; - clientConfig: mpd2.MPD.Config; - client!: MPDApi.ClientAPI; + mpc: MPC; deviceId: string + protected currentPlayPath: string; + protected currentPlaySong?: Song; + constructor(name: any, config: MPDSourceConfig, internal: InternalConfig, emitter: EventEmitter) { const { data = {} @@ -68,7 +66,6 @@ export class MPDSource extends MemoryPositionalSource { data: { url, path, - password, } = {} } = this.config; @@ -77,17 +74,8 @@ export class MPDSource extends MemoryPositionalSource { this.logger.verbose(`Config URL: '${url ?? '(None Given)'}' => Normalized: '${host}:${port}'`); this.host = host; this.port = Number.parseInt(port); - this.clientConfig = { - host, - port: this.port, - password - } } else { this.logger.verbose(`Using socket path: ${path}`); - this.clientConfig = { - path, - password - } } return true; @@ -108,13 +96,32 @@ export class MPDSource extends MemoryPositionalSource { doAuthentication = async () => { try { - this.client = await mpdClient.connect({...this.clientConfig, timeout: 1000}); - this.client.on('system-player', () => { - if(this.getIsSleeping()) { - // wake up now! + 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) { @@ -138,54 +145,113 @@ export class MPDSource extends MemoryPositionalSource { } } - formatPlayObj(obj: CurrentSongResponse, options: FormatPlayObjectOptions = {}): PlayObject { + formatPlayObj(obj: Song | PlaylistItem, options: FormatPlayObjectOptions & {state?: Status} = {}): PlayObject { + + let trackName: string, + album: string, + artists: string[] = [], + albumArtists: string[] = [], + duration: number, + position: number, + brainz: BrainzMeta = {}; + const { - file, - time, - artist, - performer, - album, - albumartist, - title, - name, - musicbrainz_albumartistid, - musicbrainz_albumid, - musicbrainz_artistid, - musicbrainz_releasetrackid, - musicbrainz_trackid, - } = obj; - - let artists = []; - let albumArtists = []; - 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 = []; - } + 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; + } + } - let trackName = title; - if(trackName === undefined && name !== undefined) { - trackName = name; - } else if(trackName === undefined && file !== undefined) { - 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; } - const brainz: BrainzMeta = { - albumArtist: [musicbrainz_albumartistid], - album: musicbrainz_albumid, - recording: musicbrainz_trackid, - }; - if(musicbrainz_artistid !== undefined) { - brainz.artist = [musicbrainz_artistid]; + 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: PlayObjectLifecycleless = { @@ -194,40 +260,66 @@ export class MPDSource extends MemoryPositionalSource { albumArtists, album, track: trackName, - duration: time + duration }, meta: { brainz, - trackProgressPosition: options.trackProgressPosition, + trackProgressPosition: position, mediaPlayerName: 'mpd' } } - return baseFormatPlayObj({...obj, trackProgressPosition: options.trackProgressPosition}, play); + return baseFormatPlayObj({...obj, trackProgressPosition: position}, play); } getRecentlyPlayed = async (options: RecentlyPlayedOptions = {}) => { - let state: StatusResponse; - let currentSong: CurrentSongResponse; + let mpcCurrentItem: PlaylistItem, + //mpcSong: Song, + mpcStatus: Status; try { - state = await this.client.api.status.get(); - currentSong = await this.client.api.status.currentsong(); + 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; - if(currentSong !== undefined) { - play = this.formatPlayObj(currentSong, {trackProgressPosition: state.elapsed}); + 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[state.state], + status: CLIENT_PLAYER_STATE[mpcStatus.state], play, - position: state.elapsed + position: play?.meta?.trackProgressPosition } return await this.processRecentPlays([playerState]); -- 2.51.2