Merge pull request #575 from FoxxMD/mpdPosition

fix(mpd): Improve position tracking
This commit is contained in:
Matt Foxx
2026-04-30 18:58:45 -04:00
committed by GitHub
5 changed files with 189 additions and 199 deletions
@@ -18,6 +18,8 @@ bind_to_address "any" # or a specific ipv4/v6 address
port "6600"
```
Multi-scrobbler requires only the [`read`](https://mpd.readthedocs.io/en/stable/user.html#permissions-and-passwords) permission in order to monitor your activity.
## Configuration
<Config config="MPDSourceConfig" fileContent={MPDConfig} name="mpd">
+14 -89
View File
@@ -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,
+1 -1
View File
@@ -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",
@@ -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';
+171 -79
View File
@@ -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<PlayerState, ReportedPlayerStatus> = {
'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;
state: {
elapsed: sElapsed,
duration: sDuration
} = {}
} = options;
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 = [];
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;
}
let trackName = title;
if(trackName === undefined && name !== undefined) {
trackName = name;
} else if(trackName === undefined && file !== undefined) {
trackName = file;
if(duration !== undefined) {
duration = Math.floor(duration);
}
const brainz: BrainzMeta = {
albumArtist: [musicbrainz_albumartistid],
album: musicbrainz_albumid,
recording: musicbrainz_trackid,
};
if(musicbrainz_artistid !== undefined) {
brainz.artist = [musicbrainz_artistid];
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<StatusResponse>();
currentSong = await this.client.api.status.currentsong<CurrentSongResponse>();
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]);