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