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.
27 kB · 645 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645import type EventEmitter from "events";import type {BrainzMeta, ComponentAuthType, PlayObject, PlayObjectMinimal, URLData} from "../../core/Atomic.ts";import { artistNamesToCredits, combinePartsToString, truncateStringToLength } from "../../core/StringUtils.ts";import { asPlayerStateDataMaybePlay, type FormatPlayObjectOptions, type InternalConfig, MBID_VARIOUS_ARTISTS, type PlayerStateDataMaybePlay} from "../common/infrastructure/Atomic.ts";import { COMPONENT_AUTH_TYPE, REPORTED_PLAYER_STATUSES } from '../../core/Atomic.ts';import type {PlayPlatformId} from '../../core/Atomic.ts';import { getFirstNonEmptyString, isDebugMode, } from "../utils.ts";import { buildStatePlayerPlayIdententifyingInfo, hashObject, parseArrayFromMaybeString } from "../utils/StringUtils.ts";import type {GetSessionsMetadata} from "@lukehagar/plexjs/sdk/models/operations/getsessions.js";import { PlexAPI, HTTPClient, type Fetcher } from "@lukehagar/plexjs";import { Agent } from 'undici';import type {PlexApiSourceConfig} from "../common/infrastructure/config/source/plex.ts";import { isPortReachable, normalizeWebAddress } from '../utils/NetworkUtils.ts';import type {GetTokenDetailsUserPlexAccount} from '@lukehagar/plexjs/sdk/models/operations/gettokendetails.js';import { parseRegexSingle } from '@foxxmd/regex-buddy-core';import { Readable } from 'node:stream';import { PlexPlayerState } from './PlayerState/PlexPlayerState.ts';import type { AbstractPlayerState} from './PlayerState/AbstractPlayerState.ts';import type {PlayerStateOptions} from './PlayerState/AbstractPlayerState.ts';import type {Logger} from '@foxxmd/logging';import { MemoryPositionalSource } from './MemoryPositionalSource.ts';import { FixedSizeList } from 'fixed-size-list';import { SDKValidationError } from '@lukehagar/plexjs/sdk/models/errors/sdkvalidationerror.js';import type { Keyv } from 'cacheable';import { initMemoryCache } from "../common/Cache.ts";import { baseFormatPlayObj } from "../utils/PlayTransformUtils.ts";
const shortDeviceId = truncateStringToLength(10, '');
export const LOCAL_USER = 'PLEX_LOCAL_USER';
const MBID_PREFIX = "mbid://";
const THUMB_REGEX = new RegExp(/\/library\/metadata\/(?<ratingkey>\d+)\/thumb\/\d+/)
export default class PlexApiSource extends MemoryPositionalSource { users: string[] = [];
override authType: ComponentAuthType = COMPONENT_AUTH_TYPE.unattended;
plexApi: PlexAPI; plexUser: string;
httpClient: HTTPClient;
deviceId: string;
address: URLData;
usersAllow: string[] = []; usersBlock: string[] = []; devicesAllow: string[] = []; devicesBlock: string[] = []; librariesAllow: string[] = []; librariesBlock: string[] = [];
logFilterFailure: false | 'debug' | 'warn';
mediaIdsSeen: FixedSizeList<string>; uniqueDropReasons: FixedSizeList<string>;
libraries: {name: string, collectionType: string, uuid: string}[] = []; private mbIdCache: Keyv<string>;
declare config: PlexApiSourceConfig;
constructor(name: any, config: PlexApiSourceConfig, internal: InternalConfig, emitter: EventEmitter) { super('plex', name, config, internal, emitter); this.canPoll = true; this.multiPlatform = true; this.requiresAuth = true; this.requiresAuthInteraction = false; this.deviceId = `${name}-ms${internal.version}-${truncateStringToLength(10, '')(hashObject(config))}`; this.uniqueDropReasons = new FixedSizeList<string>(100); this.mediaIdsSeen = new FixedSizeList<string>(100); this.mbIdCache = initMemoryCache<string | null>({lruSize: 1000, ttl: '1m'}) as Keyv<string | null>; }
protected async doBuildInitData(): Promise<true | string | undefined> { const { data: { token, interval = 5, usersAllow = [], usersBlock = [], devicesAllow = [], devicesBlock = [], librariesAllow = [], librariesBlock = [] } = {}, options: { logFilterFailure = (isDebugMode() ? 'debug' : 'warn'), ignoreInvalidCert = false } = {} } = this.config;
this.config.data.interval = interval;
if((token === undefined || token.trim() === '')) { throw new Error(`'token' must be specified in config data`); }
if (logFilterFailure !== false && !['debug', 'warn'].includes(logFilterFailure)) { this.logger.warn(`logFilterFailure value of '${logFilterFailure.toString()}' is NOT VALID. Logging will not occur if filters fail. You should fix this.`); } else { this.logFilterFailure = logFilterFailure; }
if(usersAllow === true) { this.usersAllow = []; } else { const ua = parseArrayFromMaybeString(usersAllow, {lower: true}); if(ua.length === 1 && ua[0] === 'true') { this.usersAllow = []; } else { this.usersAllow = ua; } } this.usersBlock = parseArrayFromMaybeString(usersBlock, {lower: true}); this.devicesAllow = parseArrayFromMaybeString(devicesAllow, {lower: true}); this.devicesBlock = parseArrayFromMaybeString(devicesBlock, {lower: true}); this.librariesAllow = parseArrayFromMaybeString(librariesAllow, {lower: true}); this.librariesBlock = parseArrayFromMaybeString(librariesBlock, {lower: true});
if(this.librariesBlock.length > 0 && this.librariesAllow.length > 0) { this.logger.warn(`When both 'librariesAllow' and 'librariesBlock' are specified only 'librariesAllow' is used.`); }
this.address = normalizeWebAddress(this.config.data.url); this.logger.debug(`Config URL: ${this.config.data.url} | Normalized: ${this.address.toString()}`);
if(ignoreInvalidCert) { this.logger.debug('Using http client that ignores self-signed certs');
// https://github.com/nodejs/undici/issues/1489#issuecomment-1543856261 const bypassAgent = new Agent({ connect: { rejectUnauthorized: false, }, });
const bypassFetcher: Fetcher = (input, init) => {
if (init == null) { // @ts-ignore return fetch(input, {dispatcher: bypassAgent}); } else { // @ts-ignore return fetch(input, {...init, dispatcher: bypassAgent}); } }; this.httpClient = new HTTPClient({ fetcher: bypassFetcher }); } else { this.httpClient = new HTTPClient(); }
this.plexApi = new PlexAPI({ serverURL: this.address.url.toString(), accessToken: this.config.data.token, httpClient: this.httpClient });
return true; }
protected async doCheckConnection(): Promise<true | string | undefined> { try { const reachable = await isPortReachable(this.address.port, {host: this.address.url.hostname}); if(!reachable) { throw new Error(`Could not reach server at ${this.address}}`); } return true; } catch (e) { throw e; } }
protected doAuthentication = async (): Promise<boolean> => { try {
const server = await this.plexApi.server.getServerCapabilities();
let userPlexAccount: GetTokenDetailsUserPlexAccount;
try { const tokenDetails = await this.plexApi.authentication.getTokenDetails(); userPlexAccount = tokenDetails.userPlexAccount; } catch (e) { if(e instanceof SDKValidationError && 'UserPlexAccount' in (e.rawValue as object)) { userPlexAccount = (e.rawValue as {UserPlexAccount: GetTokenDetailsUserPlexAccount}).UserPlexAccount as GetTokenDetailsUserPlexAccount; } else { throw new Error('Could not parse Plex Account details to determine authenticated username', {cause: e}); } }
this.plexUser = getFirstNonEmptyString([userPlexAccount.username, userPlexAccount.title, userPlexAccount.friendlyName, userPlexAccount.email]);
if(this.usersAllow.length === 0) { this.usersAllow.push(this.plexUser.toLocaleLowerCase()); this.usersAllow.push(LOCAL_USER.toLocaleLowerCase()); }
this.logger.info(`Authenticated on behalf of user ${this.plexUser} on Server ${server.object.mediaContainer.friendlyName} (version ${server.object.mediaContainer.version})`); return true; } catch (e) { if(e.message.includes('401') && e.message.includes('API error occurred')) { throw new Error('Plex Token was not valid for the specified server', {cause: e}); } else { throw e; } } } protected buildLibraryInfo = async () => { try { const libraries = await this.plexApi.library.getAllLibraries();
this.libraries = libraries.object.mediaContainer.directory.map(x => ({name: x.title, collectionType: x.type, uuid: x.uuid})); } catch (e) { if(e instanceof SDKValidationError) { if((e.rawValue as any).object?.MediaContainer?.Directory !== undefined) { // ensure directory has required values const ok = (e.rawValue as any).object?.MediaContainer?.Directory.every(x => x.title !== undefined && x.type !== undefined && x.uuid !== undefined); if(ok) { this.libraries = (e.rawValue as any).object.MediaContainer.Directory.map(x => ({name: x.title, collectionType: x.type, uuid: x.uuid})); return; } } this.logger.debug({ rawValue: e.rawValue }, 'Plex Response'); } throw new Error('Unable to get server libraries', {cause: e}); }
}
getAllowedLibraries = () => { if(this.librariesAllow.length === 0) { return []; } return this.libraries.filter(x => this.librariesAllow.includes(x.name.toLocaleLowerCase())); }
getBlockedLibraries = () => { if(this.librariesBlock.length === 0) { return []; } return this.libraries.filter(x => this.librariesBlock.includes(x.name.toLocaleLowerCase())); }
getValidLibraries = () => this.libraries.filter(x => x.collectionType === 'artist');
onPollPostAuthCheck = async () => { try { await this.buildLibraryInfo(); return true; } catch (e) { this.logger.error(new Error('Cannot start polling because Plex prerequisite data could not be built', {cause: e})); return false; } }
isActivityValid = (state: PlayerStateDataMaybePlay, session: GetSessionsMetadata): boolean | string => { if(this.usersAllow.length > 0 && !this.usersAllow.includes(state.platformId[1].toLocaleLowerCase())) { return `'usersAllow does not include user ${state.platformId[1]}`; } if(this.usersBlock.length > 0 && this.usersBlock.includes(state.platformId[1].toLocaleLowerCase())) { return `'usersBlock includes user ${state.platformId[1]}`; }
if(this.devicesAllow.length > 0 && !this.devicesAllow.some(x => state.platformId[0].toLocaleLowerCase().includes(x))) { return `'devicesAllow does not include a phrase found in ${state.platformId[0]}`; } if(this.devicesBlock.length > 0 && this.devicesBlock.some(x => state.platformId[0].toLocaleLowerCase().includes(x))) { return `'devicesBlock includes a phrase found in ${state.platformId[0]}`; }
if(state.play !== undefined) { const allowedLibraries = this.getAllowedLibraries(); if(allowedLibraries.length > 0 && !allowedLibraries.some(x => (state.play.meta.library ?? '').toLocaleLowerCase() === x.name.toLocaleLowerCase())) { return `media not included in librariesAllow`; } if(allowedLibraries.length === 0) { const blockedLibraries = this.getBlockedLibraries(); if(blockedLibraries.length > 0) { const blockedLibrary = blockedLibraries.find(x => (state.play.meta.library ?? '').toLocaleLowerCase() === x.name.toLocaleLowerCase()); if(blockedLibrary !== undefined) { return `media included in librariesBlock '${blockedLibrary.name}'`; } }
// this is inside this block because we SHOULD allow non-music libraries if // user specified name in librariesAllow // -- so only check for this if nothing is specified if(!this.getValidLibraries().some(x => (state.play.meta.library ?? '') === x.name)) { return `media not included in a valid library`; } } }
if(state.play !== undefined) { if(state.play.meta.mediaType !== 'track' ) { return `media detected as ${state.play.meta.mediaType} is not allowed`; } }
return true; }
formatPlayObjAware(obj: GetSessionsMetadata, options: FormatPlayObjectOptions = {}): PlayObject { const play = PlexApiSource.formatPlayObj(obj, options);
const thumb = getFirstNonEmptyString([obj.thumb, obj.parentThumb, obj.grandparentThumb]);
if(thumb !== undefined) { const res = parseRegexSingle(THUMB_REGEX, thumb) if(res !== undefined) { return { ...play, meta: { ...play.meta, art: { track: `/api/components/${this.componentId}/art?ratingKey=${res.named.ratingkey}` } } } } }
return play; }
static formatPlayObj(obj: GetSessionsMetadata, options: FormatPlayObjectOptions = {}): PlayObject {
const { type, viewOffset, title: track, parentTitle: album, grandparentTitle: artist, // OR album artist librarySectionTitle: library, duration, guid, sessionKey, player: { product, title: playerTitle, machineIdentifier } = {}, user: { title: userTitle, } = {}, // plex returns the track artist as originalTitle (when there is an album artist) // otherwise this is undefined originalTitle: trackArtist } = obj;
const realArtists: string[] = []; const albumArtists: string[] = [];
if(trackArtist !== undefined) { realArtists.push(trackArtist); albumArtists.push(artist); } else { realArtists.push(artist); }
const play: PlayObjectMinimal = { data: { artists: artistNamesToCredits(realArtists), albumArtists: artistNamesToCredits(albumArtists), album, track, // albumArtists: AlbumArtists !== undefined ? AlbumArtists.map(x => x.Name) : undefined, duration: duration / 1000 }, meta: { // If a user does not have to login to Plex (local IP and no Home Management(?)) then the User node is never populated // in this case we will use a special constant to signal this is the local user user: userTitle ?? LOCAL_USER, trackId: guid, // server: ServerId, mediaType: type, source: 'Plex', library, deviceId: combinePartsToString([shortDeviceId(machineIdentifier), product, playerTitle]), sessionId: sessionKey, trackProgressPosition: viewOffset / 1000, } } return baseFormatPlayObj(obj, play); }
getRecentlyPlayed = async (options = {}) => {
const result = await this.plexApi.sessions.getSessions();
const allSessions: [PlayerStateDataMaybePlay, GetSessionsMetadata][] = (result.object.mediaContainer?.metadata ?? []) .map(x => [this.sessionToPlayerState(x), x]); const validSessions: PlayerStateDataMaybePlay[] = [];
for(const sessionData of allSessions) { const validPlay = this.isActivityValid(sessionData[0], sessionData[1]); if(validPlay === true) { // Pull MBIDs for track, album, and artist. const [trackMbId, albumMbId, albumArtistMbId] = await Promise.all([ this.getMusicBrainzId(sessionData[1].ratingKey), this.getMusicBrainzId(sessionData[1].parentRatingKey), this.getMusicBrainzId(sessionData[1].grandparentRatingKey), ]); if (!sessionData[0].play.data.meta) { sessionData[0].play.data.meta = {}; } const computedBrainz: BrainzMeta = { ...(sessionData[0].play.data.meta.brainz ?? {}), track: trackMbId ?? sessionData[0].play.data?.meta?.brainz?.track, album: albumMbId ?? sessionData[0].play.data?.meta?.brainz?.album, } // Plex doesn't store MBIDs for track artists, so we use the // album artist MBID instead BUT ONLY if // * album artists aren't populated // * or artists == albumArtists // AND explicitly *do not* allow Various Artists MBID for artists // -- // otherwise we might accidentally set "Various Artists" like MBIDs as actual artist if(albumArtistMbId !== undefined && albumArtistMbId !== MBID_VARIOUS_ARTISTS && (sessionData[0].play.data.albumArtists.length === 0 || sessionData[0].play.data.artists.every(y => (sessionData[0].play.data.albumArtists ?? []).includes(y)))) { computedBrainz.artist = [...new Set([...(computedBrainz.artist ?? []), albumArtistMbId])];
// since we don't get artist and mbid at the same time we only be sure these are actually associated // if there is only one of each if(computedBrainz.artist.length === 1 && sessionData[0].play.data.artists.length === 1) { sessionData[0].play.data.artists[0].mbid = computedBrainz.artist[0]; } }
if(albumArtistMbId !== undefined) { computedBrainz.albumArtist = [...new Set([...(computedBrainz.albumArtist ?? []), albumArtistMbId])];
// since we don't get albumartist and mbid at the same time we only be sure these are actually associated // if there is only one of each if(computedBrainz.albumArtist.length === 1 && sessionData[0].play.data.albumArtists.length === 1) { sessionData[0].play.data.albumArtists[0].mbid = computedBrainz.albumArtist[0]; } }
sessionData[0].play.data.meta.brainz = computedBrainz;
// need to add this to original object since lifecycle has already been set in sessionToPlayerState //sessionData[0].play.meta.lifecycle.original = clone(sessionData[0].play); validSessions.push(sessionData[0]); } else if(this.logFilterFailure !== false) { const stateIdentifyingInfo = buildStatePlayerPlayIdententifyingInfo(sessionData[0]); const dropReason = `Player State for -> ${stateIdentifyingInfo} <-- is being dropped because ${validPlay}`; if(!this.uniqueDropReasons.data.some(x => x === dropReason)) { this.logger[this.logFilterFailure](dropReason); this.uniqueDropReasons.add(dropReason); } } } return await this.processRecentPlays(validSessions); }
getExternalArt = async (data: {ratingKey: string}): Promise<[Readable, string]> => { try { const resp = await this.plexApi.media.getThumbImage({ ratingKey: parseInt(data.ratingKey), width: 250, height: 250, minSize: 1, upscale: 0, xPlexToken: this.config.data.token });
// @ts-expect-error its fine return [Readable.fromWeb(resp.responseStream), resp.contentType] } catch (e) { throw new Error('Failed to get art', { cause: e }); } }
pickPlatformSession = (sessions: (PlayObject | PlayerStateDataMaybePlay)[], player: AbstractPlayerState): PlayObject | PlayerStateDataMaybePlay => { if(sessions.length === 1) { return sessions[0]; } // if all are player states and have session ids // then choose the player state with the "latest" session key if(sessions.every(x => asPlayerStateDataMaybePlay(x) && 'sessionId' in x)) { const pStateSessions = sessions as PlayerStateDataMaybePlay[]; pStateSessions.sort((a, b) => parseInt(a.sessionId) - parseInt(b.sessionId));
const validSession = pStateSessions[sessions.length - 1]; const droppingSessions = pStateSessions.filter(x => x.sessionId !== validSession.sessionId).map(x => buildStatePlayerPlayIdententifyingInfo(x)).join('\n'); player.logger.debug(`More than one data/state found in incoming data, dropping these sessions with "earlier" session keys:\n${droppingSessions}`);
return validSession; } return sessions[0]; }
sessionToPlayerState = (obj: GetSessionsMetadata): PlayerStateDataMaybePlay => {
const { // represents milliseconds position of player // * only available when player is PLAYING (not paused) // * when user seeks or pauses/stops -> plays the initial position is accurate // * when playing, is only updated every 15 seconds from the position of play viewOffset, player: { machineIdentifier, product, title, state } = {}, sessionKey } = obj;
const msDeviceId = combinePartsToString([shortDeviceId(machineIdentifier), product, title]);
const play: PlayObject = this.formatPlayObjAware(obj);
if((this.config.options.logPayload || isDebugMode()) && !this.mediaIdsSeen.data.includes(play.meta.trackId)) { this.logger.debug(`First time seeing media ${play.meta.trackId} on ${msDeviceId} => ${JSON.stringify(play)}Plex Payload:${JSON.stringify(obj)}`); this.mediaIdsSeen.add(play.meta.trackId); }
const reportedStatus = state !== 'playing' ? REPORTED_PLAYER_STATUSES.paused : REPORTED_PLAYER_STATUSES.playing; return { platformId: [msDeviceId, play.meta.user], sessionId: sessionKey, play, status: reportedStatus, position: viewOffset / 1000 } }
getNewPlayer = (logger: Logger, id: PlayPlatformId, opts: PlayerStateOptions) => new PlexPlayerState(logger, id, opts); getMusicBrainzId = async (ratingKey: string | undefined): Promise<string | undefined> => { if (ratingKey === undefined) { return undefined; } const cachedMbId = await this.mbIdCache.get(ratingKey); if (cachedMbId !== undefined && cachedMbId !== null) { return cachedMbId; } if(cachedMbId === null) { return undefined; } try { const signal = AbortSignal.timeout(5000); // reasonable 5s timeout
// The current version of plexjs (0.39.0) does not return the GUID // fields, so we make the call manually. const request = await this.httpClient.request( new Request( new URL(`/library/metadata/${ratingKey}`, this.address.url), { method: "GET", headers: { "X-Plex-Token": this.config.data.token, "Accept": "application/json", }, signal } ) ); const result = await request.json(); let mbid: string | null = null; // There shouldn't be multiple metadata or GUID objects, but we return // the first MBID to be safe. metadataLoop: for (const metadata of result?.MediaContainer?.Metadata ?? []) { this.logger.trace(`Guid: '${metadata.Guid?.map(g => g.id)?.join(", ")}', guid: '${metadata.guid}'`) if (Array.isArray(metadata.Guid)) { for (const guid of metadata.Guid) { if (typeof guid.id === "string" && guid.id.startsWith(MBID_PREFIX)) { mbid = guid.id.replace(MBID_PREFIX, ""); break metadataLoop; } } } // Some matched items store the MBID in the `MediaContainer.Metadata.guid` field, // so we check that as a fallback if there are no objects in the `Guid` field. if (typeof metadata.guid === "string" && metadata.guid.startsWith(MBID_PREFIX)) { mbid = metadata.guid.replace(MBID_PREFIX, ""); break metadataLoop; } } this.logger.trace(`Extracted MBID: '${mbid}'`); await this.mbIdCache.set(ratingKey, mbid); if (mbid === null) { return undefined } return mbid; } catch (e) { this.logger.warn(new Error(`Failed to get MusicBrainz IDs from Plex for item ${ratingKey}`, {cause: e})); } return undefined; }}
async function streamToString(stream: any) { const reader = stream.getReader(); const textDecoder = new TextDecoder(); let result = '';
async function read() { const { done, value } = await reader.read();
if (done) { return result; }
result += textDecoder.decode(value, { stream: true }); return read(); }
return read();}