import 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\/(?\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; uniqueDropReasons: FixedSizeList; libraries: {name: string, collectionType: string, uuid: string}[] = []; private mbIdCache: Keyv; 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(100); this.mediaIdsSeen = new FixedSizeList(100); this.mbIdCache = initMemoryCache({lruSize: 1000, ttl: '1m'}) as Keyv; } protected async doBuildInitData(): Promise { 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 { 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 => { 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 => { 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(); }