From dc4962ade1c14ec443515ab716137097c25de5fc Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Tue, 22 Oct 2024 18:21:28 +0000 Subject: [PATCH] feat(plex): Initial Plex API Source implementation --- config/plex.json.example | 20 +- config/plex.webhook.json.example | 16 + docsite/docs/configuration/configuration.mdx | 71 +++- package-lock.json | 9 + package.json | 1 + .../infrastructure/config/source/plex.ts | 61 +++- .../infrastructure/config/source/sources.ts | 4 +- src/backend/sources/PlexApiSource.ts | 329 ++++++++++++++++++ src/backend/sources/ScrobbleSources.ts | 22 +- src/backend/tests/plex/plex.test.ts | 186 ++++++++++ src/backend/tests/plex/validSession.json | 116 ++++++ 11 files changed, 819 insertions(+), 16 deletions(-) create mode 100644 config/plex.webhook.json.example create mode 100644 src/backend/sources/PlexApiSource.ts create mode 100644 src/backend/tests/plex/plex.test.ts create mode 100644 src/backend/tests/plex/validSession.json diff --git a/config/plex.json.example b/config/plex.json.example index c8350cc0..45d2c1f2 100644 --- a/config/plex.json.example +++ b/config/plex.json.example @@ -1,15 +1,21 @@ [ { - "name": "MyPlex", + "name": "MyPlexApi", "enable": true, "clients": [], "data": { - "user": ["username@gmail.com","anotherUser@gmail.com"], - "libraries": ["music","my podcasts"], - "servers": ["myServer","anotherServer"], - "options": { - "logFilterFailure": "warn" + "token": "1234", + "url": "http://192.168.0.120:32400" + "usersAllow": ["FoxxMD","SomeOtherUser"], + "usersBlock": ["AnotherUser"], + "devicesAllow": ["firefox"], + "devicesBlock": ["google-home"], + "librariesAllow": ["GoodMusic"], + "librariesBlock": ["BadMusic"] + }, + "options": { + "logPayload": true, + "logFilterFailure": "debug" } - } } ] diff --git a/config/plex.webhook.json.example b/config/plex.webhook.json.example new file mode 100644 index 00000000..05938d5b --- /dev/null +++ b/config/plex.webhook.json.example @@ -0,0 +1,16 @@ +[ + // rename files to plex.json to use + { + "name": "MyPlex", + "enable": true, + "clients": [], + "data": { + "user": ["username@gmail.com","anotherUser@gmail.com"], + "libraries": ["music","my podcasts"], + "servers": ["myServer","anotherServer"], + "options": { + "logFilterFailure": "warn" + } + } + } +] diff --git a/docsite/docs/configuration/configuration.mdx b/docsite/docs/configuration/configuration.mdx index 74391fd0..2f42a58a 100644 --- a/docsite/docs/configuration/configuration.mdx +++ b/docsite/docs/configuration/configuration.mdx @@ -23,6 +23,7 @@ import MprisConfig from '!!raw-loader!../../../config/mpris.json.example'; import MusikcubeConfig from '!!raw-loader!../../../config/musikcube.json.example'; import MPDConfig from '!!raw-loader!../../../config/mpd.json.example'; import PlexConfig from '!!raw-loader!../../../config/plex.json.example'; +import PlexWebhookConfig from '!!raw-loader!../../../config/plex.webhook.json.example'; import SpotifyConfig from '!!raw-loader!../../../config/spotify.json.example'; import SubsonicConfig from '!!raw-loader!../../../config/subsonic.json.example'; import TautulliConfig from '!!raw-loader!../../../config/tautulli.json.example'; @@ -240,10 +241,72 @@ If your Spotify player has [Automix](https://community.spotify.com/t5/FAQs/What- ### [Plex](https://plex.tv) -Check the [instructions](plex.md) on how to setup a [webhooks](https://support.plex.tv/articles/115002267687-webhooks) to scrobble your plays. +:::tip[Important Defaults] + +By default... + +* multi-scrobbler will **only** scrobbling for the user authenticated with the Plex Token. + * Allowed Users (`usersAllow` or `PLEX_USERS_ALLOW`) are only necessary if you want to scrobble for additional users. +* multi-scrobbler will only scrobble media found in Plex libraries that are labelled as **Music.** + * `librariesAllow` or `PLEX_LIBRARIES_ALLOW` will override this + +::: + +Find your [**Plex Token**](https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/) and make note of the **URL** and **Port** used to connect to your Plex instance. #### Configuration + + + | Environmental Variable | Required? | Default | Description | + | ---------------------- | --------- | ------- | ---------------------------------------------------------------------- | + | `PLEX_URL` | **Yes** | | The URL of the Plex server IE `http://localhost:8096` | + | `PLEX_TOKEN` | **Yes** | | The **Plex Token** to use with the API | + | `PLEX_USERS_ALLOW` | No | | Comma-separated list of usernames (from Plex) to scrobble for | + | `PLEX_USERS_BLOCK` | No | | Comma-separated list of usernames (from Plex) to disallow scrobble for | + | `PLEX_DEVICES_ALLOW` | No | | Comma-separated list of devices to scrobble from | + | `PLEX_DEVICES_BLOCK` | No | | Comma-separated list of devices to disallow scrobbles from | + | `PLEX_LIBRARIES_ALLOW` | No | | Comma-separated list of libraries to allow scrobbles from | + | `PLEX_LIBRARIES_BLOCK` | No | | Comma-separated list of libraries to disallow scrobbles from | + + +
+ + Example + + {PlexConfig} + +
+ + or + +
+ +
+ + Example + + + +
+ + or +
+
+ +#### Legacy Webhooks + +Multi-scrobbler < 0.9.0 used [webhooks](https://support.plex.tv/articles/115002267687-webhooks) to support Plex scrobbling. The legacy docs are below. + +
+ +* In the Plex dashboard Navigate to your **Account/Settings** and find the **Webhooks** page +* Click **Add Webhook** +* URL -- `http://localhost:9078/plex` (substitute your domain if different than the default) +* **Save Changes** + +##### Configuration + | Environmental Variable | Required | Default | Description | @@ -256,7 +319,7 @@ Check the [instructions](plex.md) on how to setup a [webhooks](https://support.p Example - {PlexConfig} + {PlexWebhookConfig}
@@ -267,7 +330,7 @@ Check the [instructions](plex.md) on how to setup a [webhooks](https://support.p Example - + @@ -275,6 +338,8 @@ Check the [instructions](plex.md) on how to setup a [webhooks](https://support.p + + ### [Tautulli](https://tautulli.com) Check the [instructions](plex.md) on how to setup a notification agent. diff --git a/package-lock.json b/package-lock.json index 23f50f0b..87f9dc03 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "@foxxmd/string-sameness": "^0.4.0", "@jellyfin/sdk": "^0.10.0", "@kenyip/backoff-strategies": "^1.0.4", + "@lukehagar/plexjs": "^0.23.5", "@react-nano/use-event-source": "^0.13.0", "@reduxjs/toolkit": "^1.9.5", "@supercharge/promise-pool": "^3.0.0", @@ -1634,6 +1635,14 @@ "resolved": "https://registry.npmjs.org/@kenyip/backoff-strategies/-/backoff-strategies-1.0.4.tgz", "integrity": "sha512-vduQZw2ctS3kIuSnCSSRiE4J90Y8WShR9xVG+e1lvFWksU2aTxjdkArcQqJ+XLm22JS380OZmrIPY1U06TAsng==" }, + "node_modules/@lukehagar/plexjs": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@lukehagar/plexjs/-/plexjs-0.23.5.tgz", + "integrity": "sha512-ai0RrICHb7dTOOMUn7KWhKeCxqyFSEUvEy1Xa0U9nXlXpDckPNO3Z2BK0vE/sgFTR/Z1YB9oCo1SVBWvs1fxRQ==", + "peerDependencies": { + "zod": ">= 3" + } + }, "node_modules/@mswjs/interceptors": { "version": "0.35.9", "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.35.9.tgz", diff --git a/package.json b/package.json index 16c30fad..3045cb3b 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "@foxxmd/string-sameness": "^0.4.0", "@jellyfin/sdk": "^0.10.0", "@kenyip/backoff-strategies": "^1.0.4", + "@lukehagar/plexjs": "^0.23.5", "@react-nano/use-event-source": "^0.13.0", "@reduxjs/toolkit": "^1.9.5", "@supercharge/promise-pool": "^3.0.0", diff --git a/src/backend/common/infrastructure/config/source/plex.ts b/src/backend/common/infrastructure/config/source/plex.ts index c3c64cb5..27424f99 100644 --- a/src/backend/common/infrastructure/config/source/plex.ts +++ b/src/backend/common/infrastructure/config/source/plex.ts @@ -1,4 +1,4 @@ -import { CommonSourceConfig, CommonSourceData } from "./index.js"; +import { CommonSourceConfig, CommonSourceData, CommonSourceOptions } from "./index.js"; export interface PlexSourceData extends CommonSourceData { /** @@ -34,3 +34,62 @@ export interface PlexSourceConfig extends CommonSourceConfig { export interface PlexSourceAIOConfig extends PlexSourceConfig { type: 'plex' } + +export interface PlexApiData extends CommonSourceData { + token?: string + /** + * http(s)://HOST:PORT of the Plex server to connect to + * */ + url: string + + /** + * Only scrobble for specific users (case-insensitive) + * + * If `true` MS will scrobble activity from all users + * */ + usersAllow?: string | true | string[] + /** + * Do not scrobble for these users (case-insensitive) + * */ + usersBlock?: string | string[] + + /** + * Only scrobble if device or application name contains strings from this list (case-insensitive) + * */ + devicesAllow?: string | string[] + /** + * Do not scrobble if device or application name contains strings from this list (case-insensitive) + * */ + devicesBlock?: string | string[] + + /** + * Only scrobble if library name contains string from this list (case-insensitive) + * */ + librariesAllow?: string | string[] + /** + * Do not scrobble if library name contains strings from this list (case-insensitive) + * */ + librariesBlock?: string | string[] +} + +export interface PlexApiOptions extends CommonSourceOptions { + /* + * Outputs JSON for session data the first time a new media ID is seen + * + * For use when troubleshooting issues + * + * @default false + */ + logPayload?: boolean +} + +export interface PlexApiSourceConfig extends CommonSourceConfig { + data: PlexApiData + options: PlexApiOptions +} + +export interface PlexApiSourceAIOConfig extends PlexApiSourceConfig { + type: 'plex' +} + +export type PlexCompatConfig = PlexApiSourceConfig | PlexSourceConfig; \ No newline at end of file diff --git a/src/backend/common/infrastructure/config/source/sources.ts b/src/backend/common/infrastructure/config/source/sources.ts index c91b3bf2..77cfbbd9 100644 --- a/src/backend/common/infrastructure/config/source/sources.ts +++ b/src/backend/common/infrastructure/config/source/sources.ts @@ -9,7 +9,7 @@ import { MopidySourceAIOConfig, MopidySourceConfig } from "./mopidy.js"; import { MPDSourceAIOConfig, MPDSourceConfig } from "./mpd.js"; import { MPRISSourceAIOConfig, MPRISSourceConfig } from "./mpris.js"; import { MusikcubeSourceAIOConfig, MusikcubeSourceConfig } from "./musikcube.js"; -import { PlexSourceAIOConfig, PlexSourceConfig } from "./plex.js"; +import { PlexSourceAIOConfig, PlexSourceConfig, PlexApiSourceConfig, PlexApiSourceAIOConfig } from "./plex.js"; import { SpotifySourceAIOConfig, SpotifySourceConfig } from "./spotify.js"; import { SubsonicSourceAIOConfig, SubSonicSourceConfig } from "./subsonic.js"; import { TautulliSourceAIOConfig, TautulliSourceConfig } from "./tautulli.js"; @@ -21,6 +21,7 @@ import { YTMusicSourceAIOConfig, YTMusicSourceConfig } from "./ytmusic.js"; export type SourceConfig = SpotifySourceConfig | PlexSourceConfig + | PlexApiSourceConfig | TautulliSourceConfig | DeezerSourceConfig | SubSonicSourceConfig @@ -42,6 +43,7 @@ export type SourceConfig = export type SourceAIOConfig = SpotifySourceAIOConfig | PlexSourceAIOConfig + | PlexApiSourceAIOConfig | TautulliSourceAIOConfig | DeezerSourceAIOConfig | SubsonicSourceAIOConfig diff --git a/src/backend/sources/PlexApiSource.ts b/src/backend/sources/PlexApiSource.ts new file mode 100644 index 00000000..95cf9fc1 --- /dev/null +++ b/src/backend/sources/PlexApiSource.ts @@ -0,0 +1,329 @@ +import objectHash from 'object-hash'; +import EventEmitter from "events"; +import { PlayObject } from "../../core/Atomic.js"; +import { buildTrackString, truncateStringToLength } from "../../core/StringUtils.js"; +import { + FormatPlayObjectOptions, + InternalConfig, + PlayerStateData, + PlayerStateDataMaybePlay, + PlayPlatformId, REPORTED_PLAYER_STATUSES +} from "../common/infrastructure/Atomic.js"; +import { combinePartsToString, genGroupIdStr, getPlatformIdFromData, joinedUrl, parseBool, } from "../utils.js"; +import { parseArrayFromMaybeString } from "../utils/StringUtils.js"; +import MemorySource from "./MemorySource.js"; +import { GetSessionsMetadata } from "@lukehagar/plexjs/sdk/models/operations/getsessions.js"; +import { PlexAPI } from "@lukehagar/plexjs"; +import { PlexApiSourceConfig } from "../common/infrastructure/config/source/plex.js"; +import { MyPlex } from "@lukehagar/plexjs/sdk/models/operations/getmyplexaccount.js"; +import { isPortReachable } from '../utils/NetworkUtils.js'; +import normalizeUrl from 'normalize-url'; + +const shortDeviceId = truncateStringToLength(10, ''); + +export default class PlexApiSource extends MemorySource { + users: string[] = []; + + plexApi: PlexAPI; + plexUser: MyPlex; + + deviceId: string; + + address: URL; + + usersAllow: string[] = []; + usersBlock: string[] = []; + devicesAllow: string[] = []; + devicesBlock: string[] = []; + librariesAllow: string[] = []; + librariesBlock: string[] = []; + + logFilterFailure: false | 'debug' | 'warn'; + + mediaIdsSeen: string[] = []; + + libraries: {name: string, collectionType: string, uuid: 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, '')(objectHash.sha1(config))}`; + } + + protected async doBuildInitData(): Promise { + const { + data: { + token, + usersAllow = [], + usersBlock = [], + devicesAllow = [], + devicesBlock = [], + librariesAllow = [], + librariesBlock = [], + } = {}, + options: { + logFilterFailure = (parseBool(process.env.DEBUG_MODE) ? 'debug' : 'warn') + } = {} + } = this.config; + + 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}); + + const normal = normalizeUrl(this.config.data.url, {removeSingleSlash: true}); + this.address = new URL(normal); + this.logger.debug(`Config URL: ${this.config.data.url} | Normalized: ${this.address.toString()}`); + + this.plexApi = new PlexAPI({ + serverURL: this.address.toString(), + accessToken: this.config.data.token, + xPlexClientIdentifier: this.deviceId, + }); + + return true; + } + + protected async doCheckConnection(): Promise { + try { + const reachable = await isPortReachable(parseInt(this.address.port ?? '80'), {host: this.address.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(); + + const account = (await this.plexApi.server.getMyPlexAccount()); + this.plexUser = account.object.myPlex; + + if(this.usersAllow.length === 0) { + this.usersAllow.push(this.plexUser.username.toLocaleLowerCase()); + } + + this.logger.info(`Authenticated on behalf of user ${this.plexUser.username} 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) { + 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; + }5 + } + + 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().includes(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().includes(x.name.toLocaleLowerCase())); + if(blockedLibrary !== undefined) { + return `media included in librariesBlock '${blockedLibrary.name}'`; + } + } + + 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 { + // TODO + return PlexApiSource.formatPlayObj(obj, options); + } + + static formatPlayObj(obj: GetSessionsMetadata, options: FormatPlayObjectOptions = {}): PlayObject { + + const { + type, + title: track, + parentTitle: album, + grandparentTitle: artist, // OR album artist + librarySectionTitle: library, + duration, + guid, + 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 = undefined + } = obj; + + return { + data: { + artists: [artist], + album, + track, + // albumArtists: AlbumArtists !== undefined ? AlbumArtists.map(x => x.Name) : undefined, + duration: duration / 1000 + }, + meta: { + user: userTitle, + trackId: guid, + // server: ServerId, + mediaType: type, + source: 'Plex', + library, + deviceId: combinePartsToString([shortDeviceId(machineIdentifier), product, playerTitle]) + } + } + } + + getRecentlyPlayed = async (options = {}) => { + + const result = await this.plexApi.sessions.getSessions(); + + const nonMSSessions: [PlayerStateDataMaybePlay, GetSessionsMetadata][] = (result.object.mediaContainer?.metadata ?? []) + .map(x => [this.sessionToPlayerState(x), x]); + const validSessions: PlayerStateDataMaybePlay[] = []; + + for(const sessionData of nonMSSessions) { + const validPlay = this.isActivityValid(sessionData[0], sessionData[1]); + if(validPlay === true) { + validSessions.push(sessionData[0]); + } else if(this.logFilterFailure !== false) { + let stateIdentifyingInfo: string = genGroupIdStr(getPlatformIdFromData(sessionData[0])); + if(sessionData[0].play !== undefined) { + stateIdentifyingInfo = buildTrackString(sessionData[0].play, {include: ['artist', 'track', 'platform']}); + } + this.logger[this.logFilterFailure](`Player State for -> ${stateIdentifyingInfo} <-- is being dropped because ${validPlay}`); + } + } + return this.processRecentPlays(validSessions); + } + + sessionToPlayerState = (obj: GetSessionsMetadata): PlayerStateDataMaybePlay => { + + const { + player: { + machineIdentifier, + product, + title, + state + } = {} + } = obj; + + const msDeviceId = combinePartsToString([shortDeviceId(machineIdentifier), product, title]); + + const play: PlayObject = this.formatPlayObjAware(obj); + + if(this.config.options.logPayload && !this.mediaIdsSeen.includes(play.meta.trackId)) { + this.logger.debug(`First time seeing media ${play.meta.trackId} on ${msDeviceId} => ${JSON.stringify(play)}`); + this.mediaIdsSeen.push(play.meta.trackId); + } + + const reportedStatus = state !== 'playing' ? REPORTED_PLAYER_STATUSES.paused : REPORTED_PLAYER_STATUSES.playing; + return { + platformId: [msDeviceId, play.meta.user], + play, + status: reportedStatus + } + } +} \ No newline at end of file diff --git a/src/backend/sources/ScrobbleSources.ts b/src/backend/sources/ScrobbleSources.ts index 2ecbfbe1..097a9f84 100644 --- a/src/backend/sources/ScrobbleSources.ts +++ b/src/backend/sources/ScrobbleSources.ts @@ -19,7 +19,7 @@ import { MopidySourceConfig } from "../common/infrastructure/config/source/mopid import { MPDSourceConfig } from "../common/infrastructure/config/source/mpd.js"; import { MPRISData, MPRISSourceConfig } from "../common/infrastructure/config/source/mpris.js"; import { MusikcubeData, MusikcubeSourceConfig } from "../common/infrastructure/config/source/musikcube.js"; -import { PlexSourceConfig } from "../common/infrastructure/config/source/plex.js"; +import { PlexApiSourceConfig, PlexCompatConfig, PlexSourceConfig } from "../common/infrastructure/config/source/plex.js"; import { SourceAIOConfig, SourceConfig } from "../common/infrastructure/config/source/sources.js"; import { SpotifySourceConfig, SpotifySourceData } from "../common/infrastructure/config/source/spotify.js"; import { SubsonicData, SubSonicSourceConfig } from "../common/infrastructure/config/source/subsonic.js"; @@ -52,6 +52,7 @@ import { WebScrobblerSource } from "./WebScrobblerSource.js"; import YTMusicSource from "./YTMusicSource.js"; import { Definition } from 'ts-json-schema-generator'; import { getTypeSchemaFromConfigGenerator } from '../utils/SchemaUtils.js'; +import PlexApiSource from './PlexApiSource.js'; type groupedNamedConfigs = {[key: string]: ParsedConfig[]}; @@ -118,7 +119,7 @@ export default class ScrobbleSources { this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("SpotifySourceConfig"); break; case 'plex': - this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("PlexSourceConfig"); + this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("PlexCompatConfig"); break; case 'tautulli': this.schemaDefinitions[type] = getTypeSchemaFromConfigGenerator("TautulliSourceConfig"); @@ -266,7 +267,15 @@ export default class ScrobbleSources { break; case 'plex': const p = { - user: process.env.PLEX_USER + user: process.env.PLEX_USER, + url: process.env.PLEX_URL, + token: process.env.PLEX_TOKEN, + usersAllow: process.env.PLEX_USERS_ALLOW, + usersBlock: process.env.PLEX_USERS_BLOCK, + devicesAllow: process.env.PLEX_DEVICES_ALLOW, + deviceBlock: process.env.PLEX_DEVICES_BLOCK, + librariesAllow: process.env.PLEX_LIBRARIES_ALLOW, + librariesBlock: process.env.PLEX_LIBRARIES_BLOCK }; if (!Object.values(p).every(x => x === undefined)) { configs.push({ @@ -594,7 +603,12 @@ export default class ScrobbleSources { newSource = new SpotifySource(name, compositeConfig as SpotifySourceConfig, this.internalConfig, this.emitter); break; case 'plex': - newSource = await new PlexSource(name, compositeConfig as PlexSourceConfig, this.internalConfig, 'plex', this.emitter); + const plexConfig = compositeConfig as PlexCompatConfig; + if(plexConfig.data.token !== undefined) { + newSource = await new PlexApiSource(name, compositeConfig as PlexApiSourceConfig, this.internalConfig, this.emitter); + } else { + newSource = await new PlexSource(name, compositeConfig as PlexSourceConfig, this.internalConfig, 'plex', this.emitter); + } break; case 'tautulli': newSource = await new TautulliSource(name, compositeConfig as TautulliSourceConfig, this.internalConfig, this.emitter); diff --git a/src/backend/tests/plex/plex.test.ts b/src/backend/tests/plex/plex.test.ts new file mode 100644 index 00000000..3600157e --- /dev/null +++ b/src/backend/tests/plex/plex.test.ts @@ -0,0 +1,186 @@ +import { loggerTest } from "@foxxmd/logging"; +import { assert, expect } from 'chai'; +import EventEmitter from "events"; +import { describe, it } from 'mocha'; +import { JsonPlayObject, PlayMeta, PlayObject } from "../../../core/Atomic.js"; + +import validSessionResponse from './validSession.json'; +import { generatePlay } from "../utils/PlayTestUtils.js"; +import { + // @ts-expect-error weird typings? + SessionInfo, +} from "@jellyfin/sdk/lib/generated-client/index.js"; +import { PlayerStateDataMaybePlay } from "../../common/infrastructure/Atomic.js"; +import { PlexApiData } from "../../common/infrastructure/config/source/plex.js"; +import PlexApiSource from "../../sources/PlexApiSource.js"; +import { GetSessionsMetadata } from "@lukehagar/plexjs/sdk/models/operations/getsessions.js"; + +const dataAsFixture = (data: any): TestFixture => { + return data as TestFixture; +} + +interface TestFixture { + data: any + expected: JsonPlayObject +} + +const validSession = validSessionResponse.object.mediaContainer.metadata[0]; + +const createSource = async (data: PlexApiData, authedUser: string | false = 'MyUser'): Promise => { + const source = new PlexApiSource('Test', { + data, + options: {} + }, { localUrl: new URL('http://test'), configDir: 'test', logger: loggerTest, version: 'test' }, new EventEmitter()); + source.libraries = [{name: 'Music', collectionType: 'artist', uuid: 'dfsdf'}]; + source.plexUser = {username: 'MyUser'} + await source.buildInitData(); + if(authedUser !== false && source.usersAllow.length === 0 && data.usersAllow !== true) { + source.usersAllow.push(authedUser.toLocaleLowerCase()); + } + return source; +} + +const defaultCreds = {url: 'http://example.com', token: '1234'}; + +const validPlayerState: PlayerStateDataMaybePlay = { + platformId: ['1234', 'MyUser'], + play: generatePlay({}, {mediaType: 'track', user: 'MyUser', deviceId: '1234', library: 'Music'}) +} +const playWithMeta = (meta: PlayMeta): PlayerStateDataMaybePlay => { + const {user, deviceId} = meta; + const platformId = validPlayerState.platformId; + return { + ...validPlayerState, + platformId: [deviceId ?? platformId[0], user ?? platformId[1]], + play: { + ...validPlayerState.play, + meta: { + ...validPlayerState.play?.meta, + ...meta + } + } +}}// ({...validPlayerState, meta: {...validPlayerState.meta, ...meta}}); + +const nowPlayingSession = (data: object = {}): GetSessionsMetadata => ({...validSession, ...data}); + +describe("Plex API Source", function() { + describe('Parses config allow/block correctly', function () { + + it('Should parse users, devices, and libraries, and library types as lowercase from config', async function () { + const s = await createSource({ + usersAllow: ['MyUser', 'AnotherUser'], + usersBlock: ['SomeUser'], + devicesAllow: ['Web Player'], + devicesBlock: ['Bad Player'], + librariesAllow: ['MuSiCoNe'], + librariesBlock: ['MuSiCbAd'], + ...defaultCreds}); + + expect(s.usersAllow).to.be.eql(['myuser', 'anotheruser']); + expect(s.usersBlock).to.be.eql(['someuser']); + expect(s.devicesAllow).to.be.eql(['web player']); + expect(s.devicesBlock).to.be.eql(['bad player']); + expect(s.librariesAllow).to.be.eql(['musicone']); + expect(s.librariesBlock).to.be.eql(['musicbad']); + await s.destroy(); + }); + + it('Should set allowed users to empty array (allow all) when usersAllow is true', async function () { + const s = await createSource({...defaultCreds, usersAllow: true}, false); + + expect(s.usersAllow).to.be.empty; + await s.destroy(); + }); + + it('Should set allowed users to empty array (allow all) when usersAllow is an array with only one value equal to true', async function () { + const s = await createSource({...defaultCreds, usersAllow: ['true']}, false); + + expect(s.usersAllow).to.be.empty; + await s.destroy(); + }); + }); + + describe('Correctly detects activity as valid/invalid', function() { + + describe('Filters from Configuration', function() { + + it('Should allow activity based on user allow', async function () { + const s = await createSource({...defaultCreds}); + + expect(s.isActivityValid(playWithMeta({user: 'SomeOtherUser'}), validSession)).to.not.be.true; + expect(s.isActivityValid(validPlayerState, validSession)).to.be.true; + expect(s.isActivityValid(playWithMeta({user: 'myuser'}), validSession)).to.be.true; + await s.destroy(); + }); + + it('Should disallow activity based on user block', async function () { + const s = await createSource({...defaultCreds, usersBlock: ['BadUser']}); + + expect(s.isActivityValid(playWithMeta({user: 'BadUser'}), validSession)).to.not.be.true; + expect(s.isActivityValid(validPlayerState, validSession)).to.be.true; + expect(s.isActivityValid(playWithMeta({user: 'myuser'}), validSession)).to.be.true; + await s.destroy(); + }); + + it('Should allow activity based on devices allow', async function () { + const s = await createSource({...defaultCreds, devicesAllow: ['WebPlayer']}); + + expect(s.isActivityValid(validPlayerState, validSession)).to.not.be.true; + expect(s.isActivityValid(playWithMeta({deviceId: 'WebPlayer'}), validSession)).to.be.true; + await s.destroy(); + }); + + it('Should disallow activity based on devices block', async function () { + const s = await createSource({...defaultCreds, devicesBlock: ['WebPlayer']}); + + expect(s.isActivityValid(validPlayerState, validSession)).to.be.true; + expect(s.isActivityValid(playWithMeta({deviceId: 'WebPlayer'}), validSession)).to.not.be.true; + await s.destroy(); + }); + + it('Should allow activity based on libraries allow', async function () { + const s = await createSource({...defaultCreds, librariesAllow: ['music']}); + + expect(s.isActivityValid(validPlayerState, validSession)).to.be.true; + expect(s.isActivityValid(playWithMeta({library: 'SomeOtherLibrary'}), nowPlayingSession({librarySectionTitle: 'SomeOtherLibrary'}))).to.not.be.true; + await s.destroy(); + }); + + it('Should disallow activity based on libraries block', async function () { + const s = await createSource({...defaultCreds, librariesBlock: ['music']}); + s.libraries.push({name: 'CoolVideos', collectionType: 'artist', uuid: '43543'}); + + expect(s.isActivityValid(validPlayerState, validSession)).to.not.be.true; + expect(s.isActivityValid(playWithMeta({library: 'CoolVideos'}), nowPlayingSession({librarySectionTitle: 'CoolVideos'}))).to.be.true; + await s.destroy(); + }); + + }); + + describe('Detection by Session/Media/Library Type', function() { + + it('Should allow activity with valid MediaType and valid Library', async function () { + const s = await createSource({...defaultCreds}); + + expect(s.isActivityValid(validPlayerState, validSession)).to.be.true; + await s.destroy(); + }); + + it('Should disallow activity with invalid library type', async function () { + const s = await createSource({...defaultCreds}); + s.libraries.push({name: 'CoolVideos', uuid: '64564', collectionType: 'shows'}); + + expect(s.isActivityValid(playWithMeta({library: 'CoolVideos'}), nowPlayingSession({librarySectionTitle: 'CoolVideos'}))).to.not.be.true; + await s.destroy(); + }); + + it('Should disallow Play that is not valid MediaType', async function () { + const s = await createSource({...defaultCreds}); + + expect(s.isActivityValid(playWithMeta({mediaType: 'book'}), validSession)).to.not.be.true; + await s.destroy(); + }); + + }); + }); +}); diff --git a/src/backend/tests/plex/validSession.json b/src/backend/tests/plex/validSession.json new file mode 100644 index 00000000..8c2bf321 --- /dev/null +++ b/src/backend/tests/plex/validSession.json @@ -0,0 +1,116 @@ +{ + "contentType": "application/json", + "object": { + "mediaContainer": { + "size": 1, + "metadata": [ + { + "addedAt": 1726672934, + "art": "/library/metadata/61152/art/1699291483", + "duration": 136411, + "grandparentArt": "/library/metadata/61152/art/1699291483", + "grandparentGuid": "plex://artist/5d07bbfc403c6402904a5ec9", + "grandparentKey": "/library/metadata/61152", + "grandparentRatingKey": "61152", + "grandparentThumb": "/library/metadata/61152/thumb/1699291483", + "grandparentTitle": "Various Artists", + "guid": "plex://track/5d07cdbb403c640290f5881e", + "index": 19, + "key": "/library/metadata/73894", + "librarySectionID": "10", + "librarySectionKey": "/library/sections/10", + "librarySectionTitle": "Music", + "parentGuid": "plex://album/5d07c208403c640290899b4e", + "parentIndex": 1, + "parentKey": "/library/metadata/73701", + "parentRatingKey": "73701", + "parentStudio": "A&M Records", + "parentThumb": "/library/metadata/73701/thumb/1727511278", + "parentTitle": "Good Morning, Vietnam", + "parentYear": 1987, + "ratingCount": 1194979, + "ratingKey": "73894", + "sessionKey": "326", + "thumb": "/library/metadata/73701/thumb/1727511278", + "title": "What a Wonderful World", + "type": "track", + "updatedAt": 1728182712, + "viewOffset": 9000, + "media": [ + { + "audioChannels": 2, + "audioCodec": "mp3", + "bitrate": 188, + "container": "mp3", + "duration": 136411, + "id": "89344", + "selected": true, + "part": [ + { + "container": "mp3", + "duration": 136411, + "file": "/mnt/audio/music/Louis Armstrong/Good Morning Vietnam/19 - What a Wonderful World.mp3", + "id": "96866", + "key": "/library/parts/96866/1550814498/file.mp3", + "size": 3219411, + "decision": "directplay", + "selected": true, + "stream": [ + { + "albumGain": "-3.66", + "albumPeak": "0.999969", + "albumRange": "8.224801", + "audioChannelLayout": "stereo", + "bitrate": 188, + "channels": 2, + "codec": "mp3", + "displayTitle": "MP3 (Stereo)", + "extendedDisplayTitle": "MP3 (Stereo)", + "gain": "-3.66", + "id": "247971", + "index": 0, + "loudness": "-17.78", + "lra": "5.68", + "peak": "0.569977", + "samplingRate": 44100, + "selected": true, + "streamType": 2, + "location": "direct" + } + ] + } + ] + } + ], + "user": { + "id": "1", + "thumb": "https://plex.tv/users/fsdfdsfd/avatar?c=sfds", + "title": "MyUser" + }, + "player": { + "address": "192.168.0.XXX", + "machineIdentifier": "wrbcnasdasdj0bwdfacqw9", + "model": "bundled", + "platform": "Firefox", + "platformVersion": "131.0", + "product": "Plex Web", + "profile": "Firefox", + "remotePublicAddress": "XX.177.95.XXX", + "state": "playing", + "title": "Firefox", + "version": "4.136.1", + "local": true, + "relayed": false, + "secure": true, + "userID": 1 + }, + "session": { + "id": "k77lg8r8m2jdvjvu1g8t8rp0", + "bandwidth": 193, + "location": "lan" + } + } + ] + } + } +} \ No newline at end of file