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 · 321 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321import type { Interfaces as Notifications } from '@dbus-types/notifications'import dayjs from "dayjs";import type { DBusInterface} from 'dbus-ts';import { sessionBus, Connection, type ConnectOpts } from 'dbus-ts';import type EventEmitter from "events";import type {PlayObject, PlayObjectMinimal} from "../../core/Atomic.ts";import type {FormatPlayObjectOptions, InternalConfig} from "../common/infrastructure/Atomic.ts";import { MPRIS_IFACE, MPRIS_PATH, type MPRISMetadata, type MPRISSourceConfig, PLAYBACK_STATUS_STOPPED, type PlaybackStatus, type PlayerInfo,} from "../common/infrastructure/config/source/mpris.ts";import { removeDuplicates } from "../utils.ts";import { findCauseByMessage } from "../utils/ErrorUtils.ts";import type {RecentlyPlayedOptions} from "./AbstractSource.ts";import MemorySource from "./MemorySource.ts";import type { Readable, Writable } from 'stream';import net from 'net';import pEvent from 'p-event';import { baseFormatPlayObj } from '../utils/PlayTransformUtils.ts';import { artistNamesToCredits } from '../../core/StringUtils.ts';
export class MPRISSource extends MemorySource {
declare config: MPRISSourceConfig;
whitelist: string[] = []; blacklist: string[] = [];
multiPlatform: boolean = true;
constructor(name: any, config: MPRISSourceConfig, internal: InternalConfig, emitter: EventEmitter) { super('mpris', name, config, internal, emitter); this.canPoll = true;
const {data: {whitelist = [], blacklist = []} = {}} = config; if(!Array.isArray(whitelist)) { this.whitelist = whitelist.split(',') } else { this.whitelist = whitelist; } if(!Array.isArray(blacklist)) { this.blacklist = blacklist.split(','); } else { this.blacklist = blacklist; } }
static formatPlayObj(obj: PlayerInfo, options: FormatPlayObjectOptions = {}): PlayObject { const {newFromSource = false} = options; const { name, position, metadata: { length, album, artist = [], albumArtist = [], title, trackid, url, } = {} } = obj;
let actualAlbumArtists: string[] = []; if(albumArtist.filter(x => !artist.includes(x)).length > 0) { // only include album artists if they are not the EXACT same as the track artists // ...if they aren't the exact same then include all artists, even if they are duplicates of track artists actualAlbumArtists = albumArtist; }
const play: PlayObjectMinimal = { data: { track: title, album, artists: artistNamesToCredits(artist), albumArtists:artistNamesToCredits(actualAlbumArtists), duration: length, playDate: dayjs() }, meta: { source: 'mpris', trackId: trackid, newFromSource, url: { web: url }, trackProgressPosition: position, deviceId: name, } } return baseFormatPlayObj(obj, play); }
protected async doCheckConnection(): Promise<true | string | undefined> { // test if we can get DBus try { await this.getDBus(); return true; } catch (e) { throw new Error('Could not get DBus interface from operating system', {cause: e}); } }
protected getDBus = async () => { const conn = new Connection(createStream({})); try { const res = await Promise.race([ pEvent(conn, 'error'), conn.init() ]) if(res instanceof Error) { throw res; } } catch (e) { throw new Error('Failed to connected to session bus', {cause: e}); }
try { const promise = sessionBus<Notifications>(); const busNew = await sessionBus<Notifications>(); return await busNew.getInterface('org.freedesktop.DBus', '/org/freedesktop/DBus', 'org.freedesktop.DBus'); } catch (e) { throw new Error('Failed to get DBus interface', {cause: e}); } }
protected listNew = async () => { const iface = await this.getDBus(); const names = (await iface.ListNames())[0]; return names.filter((n) => n.includes('org.mpris.MediaPlayer2')) }
getPlayersInfo = async (activeOnly = true): Promise<PlayerInfo[]> => {
const busNew = await sessionBus<Notifications>();
const playerInfos: PlayerInfo[] = [];
const newList = await this.listNew();
for (const playerName of newList) { const plainPlayerName = playerName.replace('org.mpris.MediaPlayer2.', ''); try { const props = await busNew.getInterface(playerName, MPRIS_PATH, MPRIS_IFACE); // may not always have position available! can fallback to undefined for this let pos: number | undefined; try { pos = await this.getPlayerPosition(props); } catch (e) { // only log if the error is not related to position not being supported since this is a potentially expected result if(!findCauseByMessage(e, 'Position is not supported')) { this.logger.warn(new Error(`Could not get Position info for player ${plainPlayerName}`, {cause: e})); } } const status = await this.getPlayerStatus(props); if (status === PLAYBACK_STATUS_STOPPED && activeOnly) { continue; } const metadata = await this.getPlayerMetadata(props); playerInfos.push({ name: plainPlayerName, status, position: pos, metadata }); } catch (e) { this.logger.warn(new Error(`Could not parse D-bus info for player ${plainPlayerName}`, {cause: convertDBusExceptionToError(e)})); }
}
return playerInfos; }
protected getPlayerPosition = async (props: DBusInterface): Promise<number> => { try { const pos = await props['Position']; // microseconds return dayjs.duration({milliseconds: Number(pos / 1000)}).asSeconds(); } catch(e) { throw new Error('Could not get player Position', {cause: convertDBusExceptionToError(e)}); } }
protected getPlayerStatus = async (props: DBusInterface): Promise<PlaybackStatus> => { try { const status = await props['PlaybackStatus']; return status as PlaybackStatus; } catch (e) { throw new Error('Could not get player PlaybackStatus', {cause: convertDBusExceptionToError(e)}) } }
protected getPlayerMetadata = async (props: DBusInterface): Promise<MPRISMetadata> => { try { const metadata = await props['Metadata']; return this.metadataToPlain(metadata); } catch(e) { throw new Error('Could not get player Metadata', {cause: convertDBusExceptionToError(e)}); } }
metadataToPlain = (metadataVariant): MPRISMetadata => { const metadataPlain = {}; for (const k of Object.keys(metadataVariant)) { const value = metadataVariant[k]; if (value === undefined || value === null) { //logging.warn(`ignoring a null metadata value for key ${k}`); continue; } const plainKey = k.replace(/mpris:|xesam:/, ''); if(plainKey === 'length' && typeof value === 'number') { // microseconds to seconds metadataPlain[plainKey] = value / 1000000 } else { metadataPlain[plainKey] = value; } } return metadataPlain; }
getRecentlyPlayed = async (options: RecentlyPlayedOptions = {}) => { const infos = await this.getPlayersInfo(); const plays: PlayObject[] = []; for(const info of infos) { const lowerName = info.name.toLocaleLowerCase(); if(this.whitelist.length > 0) { if(!this.whitelist.some(x => lowerName.includes(x.toLocaleLowerCase()))) { this.logger.debug(`No name in whitelist was found in Player Name '${info.name}', skipping player`); continue; } } else if(this.blacklist.length > 0) { if(this.whitelist.some(x => lowerName.includes(x.toLocaleLowerCase()))) { this.logger.debug(`A name in blacklist was found in Player Name '${info.name}', skipping player`); continue; } } plays.push(MPRISSource.formatPlayObj(info)); } const deduped = removeDuplicates(plays); if(options.display === true) { return deduped; } return await this.processRecentPlays(deduped); }}
const convertDBusExceptionToError = (e: any): Error => { let err: Error; if(e instanceof Error) { err = e; } else if(Array.isArray(e)) { err = new Error(e.map(x => x.toString()).join(' | ')); } else { err = new Error(e.toString()); } return err;}
// unfortunately had to recreate this function from dbus-ts/Connection// in order to be able to create the Connection class without immediate init// so we can catch errorsconst createStream = (opts: ConnectOpts): Readable&Writable => { if (typeof opts !== 'object') { opts = {}; } if ("stream" in opts) { return opts.stream; } if ("socket" in opts) { return net.createConnection(opts.socket); } if ("port" in opts) { return net.createConnection(opts.port, opts.host); }
const busAddress = opts.busAddress || process.env.DBUS_SESSION_BUS_ADDRESS; if (!busAddress) throw new Error('unknown bus address');
const addresses = busAddress.split(';'); for (let i = 0; i < addresses.length; ++i) { const address = addresses[i]; const familyParams = address.split(':'); const family = familyParams[0]; const params: any = {}; familyParams[1].split(',').map(function(p) { const keyVal = p.split('='); params[keyVal[0]] = keyVal[1]; });
try { switch (family.toLowerCase()) { case 'tcp': return net.createConnection(params.port, (params.host || 'localhost')); case 'unix': if (params.socket) return net.createConnection(params.socket); if (params.path) return net.createConnection(params.path); throw new Error( "not enough parameters for 'unix' connection - you need to specify 'socket' or 'path' parameter" ); default: throw new Error('unknown address type:' + family); } } catch (e) { if (i < addresses.length - 1) { console.warn(e.message); } else { throw e; } } }}