[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
0

Configure Feed

Select the types of activity you want to include in your feed.

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.

FoxxMD (Apr 30, 2026, 10:50 PM UTC) 9d839db2 ec4a5107

+187 -199
+14 -89
package-lock.json
··· 68 68 "lastfm-ts-api": "^2.6.0", 69 69 "merge-error-cause": "^5.0.2", 70 70 "mopidy": "^1.3.0", 71 - "mpd-api": "^1.1.2", 71 + "mpc-js": "^2.1.1", 72 72 "musicbrainz-api": "^0.27.0", 73 73 "nanoid": "^3.3.1", 74 74 "node-object-hash": "^3.1.1", ··· 4149 4149 "node": ">=18" 4150 4150 } 4151 4151 }, 4152 - "node_modules/@tokenizer/token": { 4153 - "version": "0.3.0", 4154 - "license": "MIT" 4155 - }, 4156 4152 "node_modules/@tsconfig/node18": { 4157 4153 "version": "1.0.3", 4158 4154 "dev": true, ··· 7998 7994 "node": ">=6" 7999 7995 } 8000 7996 }, 7997 + "node_modules/eventemitter3": { 7998 + "version": "5.0.4", 7999 + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", 8000 + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", 8001 + "license": "MIT" 8002 + }, 8001 8003 "node_modules/events": { 8002 8004 "version": "3.3.0", 8003 8005 "license": "MIT", ··· 8389 8391 "json-buffer": "3.0.1" 8390 8392 } 8391 8393 }, 8392 - "node_modules/file-type": { 8393 - "version": "16.5.4", 8394 - "license": "MIT", 8395 - "dependencies": { 8396 - "readable-web-to-node-stream": "^3.0.0", 8397 - "strtok3": "^6.2.4", 8398 - "token-types": "^4.1.1" 8399 - }, 8400 - "engines": { 8401 - "node": ">=10" 8402 - }, 8403 - "funding": { 8404 - "url": "https://github.com/sindresorhus/file-type?sponsor=1" 8405 - } 8406 - }, 8407 8394 "node_modules/filesize": { 8408 8395 "version": "10.1.6", 8409 8396 "dev": true, ··· 10821 10808 } 10822 10809 } 10823 10810 }, 10824 - "node_modules/mpd-api": { 10825 - "version": "1.1.2", 10826 - "license": "UNLICENSED", 10811 + "node_modules/mpc-js": { 10812 + "version": "2.1.1", 10813 + "resolved": "https://registry.npmjs.org/mpc-js/-/mpc-js-2.1.1.tgz", 10814 + "integrity": "sha512-FQ/C4QDq8vVhnYBG51ZCrFgdzUhMfcYu35eCiGjcsqyNXvrDFL585Tg0R+vQ/Up4vb/7t1sGM4TwT7QwL3a0bA==", 10815 + "license": "MIT", 10827 10816 "dependencies": { 10828 - "debug": "^4.3.4", 10829 - "file-type": "^16.5.3", 10830 - "mpd2": "^1.0.5" 10817 + "eventemitter3": "^5.0.1" 10831 10818 }, 10832 10819 "engines": { 10833 - "node": ">=11.0.0" 10834 - } 10835 - }, 10836 - "node_modules/mpd2": { 10837 - "version": "1.0.5", 10838 - "license": "UNLICENSED", 10839 - "dependencies": { 10840 - "debug": "^4.1.1" 10820 + "node": ">=18.0.0" 10841 10821 } 10842 10822 }, 10843 10823 "node_modules/ms": { ··· 11700 11680 "node_modules/pause": { 11701 11681 "version": "0.0.1" 11702 11682 }, 11703 - "node_modules/peek-readable": { 11704 - "version": "4.1.0", 11705 - "license": "MIT", 11706 - "engines": { 11707 - "node": ">=8" 11708 - }, 11709 - "funding": { 11710 - "type": "github", 11711 - "url": "https://github.com/sponsors/Borewit" 11712 - } 11713 - }, 11714 11683 "node_modules/perfect-freehand": { 11715 11684 "version": "1.2.3", 11716 11685 "dev": true, ··· 12366 12335 }, 12367 12336 "engines": { 12368 12337 "node": ">= 6" 12369 - } 12370 - }, 12371 - "node_modules/readable-web-to-node-stream": { 12372 - "version": "3.0.2", 12373 - "license": "MIT", 12374 - "dependencies": { 12375 - "readable-stream": "^3.6.0" 12376 - }, 12377 - "engines": { 12378 - "node": ">=8" 12379 - }, 12380 - "funding": { 12381 - "type": "github", 12382 - "url": "https://github.com/sponsors/Borewit" 12383 12338 } 12384 12339 }, 12385 12340 "node_modules/readdirp": { ··· 13492 13447 "url": "https://github.com/sponsors/sindresorhus" 13493 13448 } 13494 13449 }, 13495 - "node_modules/strtok3": { 13496 - "version": "6.3.0", 13497 - "license": "MIT", 13498 - "dependencies": { 13499 - "@tokenizer/token": "^0.3.0", 13500 - "peek-readable": "^4.1.0" 13501 - }, 13502 - "engines": { 13503 - "node": ">=10" 13504 - }, 13505 - "funding": { 13506 - "type": "github", 13507 - "url": "https://github.com/sponsors/Borewit" 13508 - } 13509 - }, 13510 13450 "node_modules/stylis": { 13511 13451 "version": "4.2.0", 13512 13452 "dev": true, ··· 13751 13691 "license": "MIT", 13752 13692 "engines": { 13753 13693 "node": ">=0.6" 13754 - } 13755 - }, 13756 - "node_modules/token-types": { 13757 - "version": "4.2.1", 13758 - "license": "MIT", 13759 - "dependencies": { 13760 - "@tokenizer/token": "^0.3.0", 13761 - "ieee754": "^1.2.1" 13762 - }, 13763 - "engines": { 13764 - "node": ">=10" 13765 - }, 13766 - "funding": { 13767 - "type": "github", 13768 - "url": "https://github.com/sponsors/Borewit" 13769 13694 } 13770 13695 }, 13771 13696 "node_modules/touch": {
+1 -1
package.json
··· 106 106 "lastfm-ts-api": "^2.6.0", 107 107 "merge-error-cause": "^5.0.2", 108 108 "mopidy": "^1.3.0", 109 - "mpd-api": "^1.1.2", 109 + "mpc-js": "^2.1.1", 110 110 "musicbrainz-api": "^0.27.0", 111 111 "nanoid": "^3.3.1", 112 112 "node-object-hash": "^3.1.1",
+1 -30
src/backend/common/infrastructure/config/source/mpd.ts
··· 39 39 type: 'mpd' 40 40 } 41 41 42 - export type PlayerState = 'play' | 'stop' | 'pause'; 43 - 44 - export interface StatusResponse { 45 - state: PlayerState 46 - /** 47 - * Position within the current song in seconds 48 - * */ 49 - elapsed?: number 50 - /** 51 - * Duration of the current song in seconds 52 - * */ 53 - duration?: number 54 - error?: string 55 - } 56 - 57 - export interface CurrentSongResponse { 58 - file: string 59 - time: number 60 - name?: string 61 - performer?: string 62 - artist?: string 63 - album?: string 64 - albumartist?: string 65 - title?: string 66 - musicbrainz_albumartistid?: string 67 - musicbrainz_albumid?: string 68 - musicbrainz_artistid?: string 69 - musicbrainz_releasetrackid?: string 70 - musicbrainz_trackid?: string 71 - } 42 + export type PlayerState = 'play' | 'stop' | 'pause';
+171 -79
src/backend/sources/MPDSource.ts
··· 1 1 import dayjs from "dayjs"; 2 2 import { EventEmitter } from "events"; 3 - import mpdapiNS, { MPDApi } from 'mpd-api'; 4 - import mpd2 from 'mpd2'; 3 + import path from 'path'; 4 + import {MPC, type Status, type Song, type PlaylistItem} from 'mpc-js'; 5 5 import { BrainzMeta, PlayObject, PlayObjectLifecycleless } from "../../core/Atomic.js"; 6 6 import { 7 7 FormatPlayObjectOptions, ··· 12 12 SINGLE_USER_PLATFORM_ID, 13 13 } from "../common/infrastructure/Atomic.js"; 14 14 import { 15 - CurrentSongResponse, 16 15 MPDSourceConfig, 17 16 PlayerState, 18 - StatusResponse, 19 17 } from "../common/infrastructure/config/source/mpd.js"; 20 18 import { isPortReachable } from "../utils/NetworkUtils.js"; 21 19 import { RecentlyPlayedOptions } from "./AbstractSource.js"; 22 20 import { MemoryPositionalSource } from "./MemoryPositionalSource.js"; 23 21 import { baseFormatPlayObj } from "../utils/PlayTransformUtils.js"; 24 - 25 - const mpdClient = mpdapiNS.default; 22 + import { isDebugMode, sleep } from "../utils.js"; 26 23 27 24 const CLIENT_PLAYER_STATE: Record<PlayerState, ReportedPlayerStatus> = { 28 25 'play': REPORTED_PLAYER_STATUSES.playing, ··· 35 32 36 33 host?: string 37 34 port?: number 38 - // {host?: string, port?: number, path?: string, password?: string}; 39 - clientConfig: mpd2.MPD.Config; 40 - client!: MPDApi.ClientAPI; 35 + mpc: MPC; 41 36 deviceId: string 37 + 38 + protected currentPlayPath: string; 39 + protected currentPlaySong?: Song; 42 40 43 41 constructor(name: any, config: MPDSourceConfig, internal: InternalConfig, emitter: EventEmitter) { 44 42 const { ··· 68 66 data: { 69 67 url, 70 68 path, 71 - password, 72 69 } = {} 73 70 } = this.config; 74 71 ··· 77 74 this.logger.verbose(`Config URL: '${url ?? '(None Given)'}' => Normalized: '${host}:${port}'`); 78 75 this.host = host; 79 76 this.port = Number.parseInt(port); 80 - this.clientConfig = { 81 - host, 82 - port: this.port, 83 - password 84 - } 85 77 } else { 86 78 this.logger.verbose(`Using socket path: ${path}`); 87 - this.clientConfig = { 88 - path, 89 - password 90 - } 91 79 } 92 80 93 81 return true; ··· 108 96 doAuthentication = async () => { 109 97 110 98 try { 111 - this.client = await mpdClient.connect({...this.clientConfig, timeout: 1000}); 112 - this.client.on('system-player', () => { 113 - if(this.getIsSleeping()) { 114 - // wake up now! 99 + this.mpc = new MPC(); 100 + if(this.host !== undefined) { 101 + const prom = this.mpc.connectTCP(this.host, this.port) 102 + await Promise.race([ 103 + prom, 104 + sleep(1000) 105 + ]); 106 + if(!this.mpc.isReady) { 107 + // handled any rejected socket promise error, if it occurs later 108 + prom.catch(err => this.logger.warn(err)); 109 + this.mpc.disconnect(); 110 + throw new Error('Timed out waiting for TCP response from MPD'); 111 + } 112 + } else { 113 + await this.mpc.connectUnixSocket(this.config.data.path); 114 + } 115 + 116 + if(this.config.data.password !== undefined) { 117 + await this.mpc.connection.password(this.config.data.password); 118 + } 119 + this.mpc.on('changed', (p) => { 120 + if(p.includes('player') && this.getIsSleeping()) { 121 + // wake up now! 115 122 this.logger.debug(`Waking up from sleeping ${Math.abs(this.getWakeAt().diff(dayjs(), 'ms'))}ms early due to player state change`) 116 123 this.setWakeAt(dayjs()); 117 - } 124 + } 118 125 }); 119 126 return true; 120 127 } catch (e) { ··· 138 145 } 139 146 } 140 147 141 - formatPlayObj(obj: CurrentSongResponse, options: FormatPlayObjectOptions = {}): PlayObject { 148 + formatPlayObj(obj: Song | PlaylistItem, options: FormatPlayObjectOptions & {state?: Status} = {}): PlayObject { 149 + 150 + let trackName: string, 151 + album: string, 152 + artists: string[] = [], 153 + albumArtists: string[] = [], 154 + duration: number, 155 + position: number, 156 + brainz: BrainzMeta = {}; 157 + 142 158 const { 143 - file, 144 - time, 145 - artist, 146 - performer, 147 - album, 148 - albumartist, 149 - title, 150 - name, 151 - musicbrainz_albumartistid, 152 - musicbrainz_albumid, 153 - musicbrainz_artistid, 154 - musicbrainz_releasetrackid, 155 - musicbrainz_trackid, 156 - } = obj; 159 + state: { 160 + elapsed: sElapsed, 161 + duration: sDuration 162 + } = {} 163 + } = options; 157 164 158 - let artists = []; 159 - let albumArtists = []; 160 - if(artist !== undefined) { 161 - artists.push(artist); 162 - } 163 - if(albumartist !== undefined && albumartist !== artist) { 164 - albumArtists.push(albumartist); 165 - } 166 - if(artists.length === 0 && performer !== undefined) { 167 - artists.push(performer); 168 - } 169 - if(artists.length === 0 && albumArtists.length !== 0) { 170 - // switch these, tags are probably improper 171 - artists = albumArtists; 172 - albumArtists = []; 173 - } 165 + position = sElapsed; 166 + 167 + if('entryType' in obj && obj.entryType === 'song') { 168 + const { 169 + path: file, 170 + duration: songDuration, 171 + artist, 172 + performer, 173 + album: sAlbum, 174 + albumArtist, 175 + title, 176 + name, 177 + musicBrainzAlbumArtistId: musicbrainz_albumartistid, 178 + musicBrainzAlbumId: musicbrainz_albumid, 179 + musicBrainzArtistId: musicbrainz_artistid, 180 + musicBrainzReleaseTrackId: musicbrainz_releasetrackid, 181 + musicBrainzTrackId: musicbrainz_trackid, 182 + } = obj; 174 183 175 - let trackName = title; 176 - if(trackName === undefined && name !== undefined) { 177 - trackName = name; 178 - } else if(trackName === undefined && file !== undefined) { 179 - trackName = file; 184 + trackName = title; 185 + if(trackName === undefined && name !== undefined) { 186 + trackName = name; 187 + } else if(trackName === undefined && file !== undefined) { 188 + const pathSplit = file.split(path.sep); 189 + if(pathSplit.length > 1) { 190 + trackName = pathSplit[pathSplit.length - 1]; 191 + } else { 192 + trackName = file; 193 + } 194 + } 195 + 196 + if(artist !== undefined) { 197 + artists.push(artist); 198 + } 199 + if(albumArtist !== undefined && albumArtist !== artist) { 200 + albumArtists.push(albumArtist); 201 + } 202 + if(artists.length === 0 && performer !== undefined) { 203 + artists.push(performer); 204 + } 205 + if(artists.length === 0 && albumArtists.length !== 0) { 206 + // switch these, tags are probably improper 207 + artists = albumArtists; 208 + albumArtists = []; 209 + } 210 + 211 + album = sAlbum; 212 + 213 + duration = songDuration ?? sDuration; 214 + 215 + brainz = { 216 + albumArtist: musicbrainz_albumartistid !== undefined ? [musicbrainz_albumartistid] : undefined, 217 + album: musicbrainz_albumid, 218 + recording: musicbrainz_trackid, 219 + artist: musicbrainz_artistid !== undefined ? [musicbrainz_artistid] : undefined 220 + }; 221 + 222 + } else { 223 + const { 224 + path: file, 225 + duration: songDuration, 226 + artist, 227 + album: pAlbum, 228 + albumArtist, 229 + title, 230 + name 231 + } = obj; 232 + 233 + trackName = title ?? name; 234 + if(trackName === undefined) { 235 + const pathSplit = file.split(path.sep); 236 + if(pathSplit.length > 1) { 237 + trackName = pathSplit[pathSplit.length - 1]; 238 + } else { 239 + trackName = file; 240 + } 241 + } 242 + 243 + artists = artist !== undefined ? [artist] : undefined; 244 + album = pAlbum; 245 + albumArtists = albumArtist !== undefined && albumArtist !== artist ? [albumArtist] : undefined; 246 + duration = songDuration ?? sDuration; 180 247 } 181 248 182 - const brainz: BrainzMeta = { 183 - albumArtist: [musicbrainz_albumartistid], 184 - album: musicbrainz_albumid, 185 - recording: musicbrainz_trackid, 186 - }; 187 - if(musicbrainz_artistid !== undefined) { 188 - brainz.artist = [musicbrainz_artistid]; 249 + if(duration !== undefined) { 250 + duration = Math.floor(duration); 251 + } 252 + if(position !== undefined) { 253 + // so that we can end up with 100% played 254 + position = Math.ceil(position); 189 255 } 190 256 191 257 const play: PlayObjectLifecycleless = { ··· 194 260 albumArtists, 195 261 album, 196 262 track: trackName, 197 - duration: time 263 + duration 198 264 }, 199 265 meta: { 200 266 brainz, 201 - trackProgressPosition: options.trackProgressPosition, 267 + trackProgressPosition: position, 202 268 mediaPlayerName: 'mpd' 203 269 } 204 270 } 205 - return baseFormatPlayObj({...obj, trackProgressPosition: options.trackProgressPosition}, play); 271 + return baseFormatPlayObj({...obj, trackProgressPosition: position}, play); 206 272 } 207 273 208 274 getRecentlyPlayed = async (options: RecentlyPlayedOptions = {}) => { 209 275 210 - let state: StatusResponse; 211 - let currentSong: CurrentSongResponse; 276 + let mpcCurrentItem: PlaylistItem, 277 + //mpcSong: Song, 278 + mpcStatus: Status; 212 279 try { 213 - state = await this.client.api.status.get<StatusResponse>(); 214 - currentSong = await this.client.api.status.currentsong<CurrentSongResponse>(); 280 + mpcCurrentItem = await this.mpc.status.currentSong(); 281 + mpcStatus = await this.mpc.status.status(); 215 282 } catch (e) { 216 283 this.connectionOK = false; 217 284 this.authed = false; 218 285 throw e; 219 286 } 220 287 221 - let play: PlayObject | undefined; 222 - if(currentSong !== undefined) { 223 - play = this.formatPlayObj(currentSong, {trackProgressPosition: state.elapsed}); 288 + let play: PlayObject | undefined, 289 + newPath = false; 290 + if(mpcCurrentItem !== undefined && mpcCurrentItem.path !== undefined) { 291 + if(this.currentPlayPath !== mpcCurrentItem.path) { 292 + newPath = true; 293 + this.currentPlaySong = undefined; 294 + this.currentPlayPath = mpcCurrentItem.path; 295 + try { 296 + const resp = await this.mpc.database.listInfo(mpcCurrentItem.path); 297 + if(resp.length > 0 && resp[0].isSong()) { 298 + //mpcSong = resp[0]; 299 + this.currentPlaySong = resp[0]; 300 + } 301 + } catch (e) { 302 + this.logger.warn(`Could not retrieve Song db info for uri ${mpcCurrentItem.path}`); 303 + } 304 + } 305 + play = this.formatPlayObj(this.currentPlaySong ?? mpcCurrentItem, { state: mpcStatus }); 306 + 307 + if(newPath) { 308 + this.logger.trace('Current playing is a new path. Logging payload/Play on first seen for this path'); 309 + this.logger.trace(`MPD Payload => ${JSON.stringify({currentItem: mpcCurrentItem, status: mpcStatus, song: this.currentPlaySong})}`); 310 + this.logger.trace(`MS Play => ${JSON.stringify(play)}`); 311 + } 312 + 313 + if(isDebugMode() && !newPath) { 314 + this.logger.trace(`Raw mpc.js payload => ${JSON.stringify({mpcStatus, mpcSong: mpcCurrentItem})}`); 315 + } 224 316 } 225 317 226 318 const playerState: PlayerStateData = { 227 319 platformId: SINGLE_USER_PLATFORM_ID, 228 - status: CLIENT_PLAYER_STATE[state.state], 320 + status: CLIENT_PLAYER_STATE[mpcStatus.state], 229 321 play, 230 - position: state.elapsed 322 + position: play?.meta?.trackProgressPosition 231 323 } 232 324 233 325 return await this.processRecentPlays([playerState]);