mirror of
https://github.com/FoxxMD/multi-scrobbler.git
synced 2026-09-03 05:10:00 +03:00
Merge pull request #575 from FoxxMD/mpdPosition
fix(mpd): Improve position tracking
This commit is contained in:
@@ -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">
|
||||
|
||||
Generated
+14
-89
@@ -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
@@ -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';
|
||||
@@ -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]);
|
||||
|
||||
Reference in New Issue
Block a user