From dc4962ade1c14ec443515ab716137097c25de5fc Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Tue, 22 Oct 2024 18:21:28 +0000 Subject: [PATCH 1/8] 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 From 54e11899a002205df47f6ba814bac6b809653154 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Tue, 22 Oct 2024 18:29:11 +0000 Subject: [PATCH 2/8] Fix missing comma --- config/plex.json.example | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/plex.json.example b/config/plex.json.example index 45d2c1f2..79ff1359 100644 --- a/config/plex.json.example +++ b/config/plex.json.example @@ -5,7 +5,7 @@ "clients": [], "data": { "token": "1234", - "url": "http://192.168.0.120:32400" + "url": "http://192.168.0.120:32400", "usersAllow": ["FoxxMD","SomeOtherUser"], "usersBlock": ["AnotherUser"], "devicesAllow": ["firefox"], From 598ae01082d18fbe1d4fd79e092ced90c321eb9e Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Wed, 23 Oct 2024 13:40:50 +0000 Subject: [PATCH 3/8] feat(plex): Add track progress tracking to api source --- src/backend/sources/PlexApiSource.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/backend/sources/PlexApiSource.ts b/src/backend/sources/PlexApiSource.ts index 95cf9fc1..2f832ec5 100644 --- a/src/backend/sources/PlexApiSource.ts +++ b/src/backend/sources/PlexApiSource.ts @@ -237,6 +237,7 @@ export default class PlexApiSource extends MemorySource { const { type, + viewOffset, title: track, parentTitle: album, grandparentTitle: artist, // OR album artist @@ -271,7 +272,8 @@ export default class PlexApiSource extends MemorySource { mediaType: type, source: 'Plex', library, - deviceId: combinePartsToString([shortDeviceId(machineIdentifier), product, playerTitle]) + deviceId: combinePartsToString([shortDeviceId(machineIdentifier), product, playerTitle]), + trackProgressPosition: viewOffset / 1000 } } } @@ -302,6 +304,7 @@ export default class PlexApiSource extends MemorySource { sessionToPlayerState = (obj: GetSessionsMetadata): PlayerStateDataMaybePlay => { const { + viewOffset, player: { machineIdentifier, product, @@ -323,7 +326,8 @@ export default class PlexApiSource extends MemorySource { return { platformId: [msDeviceId, play.meta.user], play, - status: reportedStatus + status: reportedStatus, + position: viewOffset / 1000 } } } \ No newline at end of file From 77a3c352b1be444854c018ab92b8b58112f7b0c8 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Wed, 23 Oct 2024 10:59:36 -0400 Subject: [PATCH 4/8] feat(plex): Implement workaround for getting plex token details Ignore sdk validation error and get raw value in order to parse authenticated user --- src/backend/sources/PlexApiSource.ts | 31 +++++++++++++++++++++------- src/backend/tests/plex/plex.test.ts | 22 +++++++------------- src/backend/utils.ts | 18 ++++++++++++++++ 3 files changed, 49 insertions(+), 22 deletions(-) diff --git a/src/backend/sources/PlexApiSource.ts b/src/backend/sources/PlexApiSource.ts index 2f832ec5..8557256b 100644 --- a/src/backend/sources/PlexApiSource.ts +++ b/src/backend/sources/PlexApiSource.ts @@ -9,15 +9,18 @@ import { PlayerStateDataMaybePlay, PlayPlatformId, REPORTED_PLAYER_STATUSES } from "../common/infrastructure/Atomic.js"; -import { combinePartsToString, genGroupIdStr, getPlatformIdFromData, joinedUrl, parseBool, } from "../utils.js"; +import { combinePartsToString, genGroupIdStr, getFirstNonEmptyString, 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 { + SDKValidationError, + } from "@lukehagar/plexjs/sdk/models/errors"; 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'; +import { GetTokenDetailsResponse, GetTokenDetailsUserPlexAccount } from '@lukehagar/plexjs/sdk/models/operations/gettokendetails.js'; const shortDeviceId = truncateStringToLength(10, ''); @@ -25,7 +28,7 @@ export default class PlexApiSource extends MemorySource { users: string[] = []; plexApi: PlexAPI; - plexUser: MyPlex; + plexUser: string; deviceId: string; @@ -127,14 +130,26 @@ export default class PlexApiSource extends MemorySource { const server = await this.plexApi.server.getServerCapabilities(); - const account = (await this.plexApi.server.getMyPlexAccount()); - this.plexUser = account.object.myPlex; + 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.username.toLocaleLowerCase()); + this.usersAllow.push(this.plexUser.toLocaleLowerCase()); } - this.logger.info(`Authenticated on behalf of user ${this.plexUser.username} on Server ${server.object.mediaContainer.friendlyName} (version ${server.object.mediaContainer.version})`); + 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')) { @@ -330,4 +345,4 @@ export default class PlexApiSource extends MemorySource { position: viewOffset / 1000 } } -} \ No newline at end of file +} diff --git a/src/backend/tests/plex/plex.test.ts b/src/backend/tests/plex/plex.test.ts index 3600157e..4d814f23 100644 --- a/src/backend/tests/plex/plex.test.ts +++ b/src/backend/tests/plex/plex.test.ts @@ -6,24 +6,11 @@ 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 => { @@ -32,7 +19,7 @@ const createSource = async (data: PlexApiData, authedUser: string | false = 'MyU 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'} + source.plexUser = 'MyUser'; await source.buildInitData(); if(authedUser !== false && source.usersAllow.length === 0 && data.usersAllow !== true) { source.usersAllow.push(authedUser.toLocaleLowerCase()); @@ -85,6 +72,13 @@ describe("Plex API Source", function() { await s.destroy(); }); + it('Should include authenticating user as allowed when no others are set', async function () { + const s = await createSource({...defaultCreds}); + + expect(s.usersAllow).to.be.eql(['myuser']); + 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); diff --git a/src/backend/utils.ts b/src/backend/utils.ts index 3734a60d..29b73ddc 100644 --- a/src/backend/utils.ts +++ b/src/backend/utils.ts @@ -739,6 +739,24 @@ export const joinedUrl = (url: URL, ...paths: string[]): URL => { // https://github.com/jfromaniello/url-join#in-nodejs const finalUrl = new URL(url); finalUrl.pathname = joinPath(url.pathname, ...(paths.filter(x => x.trim() !== ''))); + const f = getFirstNonEmptyVal(['something']); return finalUrl; } +export const getFirstNonEmptyVal = (values: unknown[], options: {ofType?: string, test?: (val: T) => boolean} = {}): NonNullable | undefined => { + for(const v of values) { + if(v === undefined || v === null) { + continue; + } + if(options.ofType !== undefined && typeof v !== options.ofType) { + continue; + } + if(options.test !== undefined && options.test(v as T) === false) { + continue; + } + return v as T; + } + return undefined; +} + +export const getFirstNonEmptyString = (values: unknown[]) => getFirstNonEmptyVal(values, {ofType: 'string', test: (v) => v.trim() !== ''}); \ No newline at end of file From 4a96cbfb0e6231a253c67e4555e42f6b594db451 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Wed, 23 Oct 2024 15:18:13 +0000 Subject: [PATCH 5/8] docs(plex): Clean up api/webhook sections and add migration guide --- config/plex.webhook.json.example | 1 + docsite/docs/configuration/configuration.mdx | 30 ++++++++++++++++---- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/config/plex.webhook.json.example b/config/plex.webhook.json.example index 05938d5b..35e92071 100644 --- a/config/plex.webhook.json.example +++ b/config/plex.webhook.json.example @@ -1,4 +1,5 @@ [ + // DEPRECATED, use API Source instead // rename files to plex.json to use { "name": "MyPlex", diff --git a/docsite/docs/configuration/configuration.mdx b/docsite/docs/configuration/configuration.mdx index 2f42a58a..1b9446a5 100644 --- a/docsite/docs/configuration/configuration.mdx +++ b/docsite/docs/configuration/configuration.mdx @@ -241,11 +241,14 @@ If your Spotify player has [Automix](https://community.spotify.com/t5/FAQs/What- ### [Plex](https://plex.tv) + + + :::tip[Important Defaults] By default... -* multi-scrobbler will **only** scrobbling for the user authenticated with the Plex Token. +* multi-scrobbler will **only** scrobble 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 @@ -260,7 +263,7 @@ Find your [**Plex Token**](https://support.plex.tv/articles/204059436-finding-an | Environmental Variable | Required? | Default | Description | | ---------------------- | --------- | ------- | ---------------------------------------------------------------------- | - | `PLEX_URL` | **Yes** | | The URL of the Plex server IE `http://localhost:8096` | + | `PLEX_URL` | **Yes** | | The URL of the Plex server IE `http://localhost:32400` | | `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 | @@ -294,12 +297,28 @@ Find your [**Plex Token**](https://support.plex.tv/articles/204059436-finding-an -#### Legacy Webhooks + + + +:::warning[Deprecated] -Multi-scrobbler < 0.9.0 used [webhooks](https://support.plex.tv/articles/115002267687-webhooks) to support Plex scrobbling. The legacy docs are below. +Multi-scrobbler < 0.9.0 used [webhooks](https://support.plex.tv/articles/115002267687-webhooks) to support Plex scrobbling. This approach has been deprecated in favor of using Plex's API directly which has many benefits including **not requiring Plex Pass.** + +:::
+ Migrating to API + + * Follow the instructions in the API tab + * The `user` (`PLEX_USER`) setting has been renamed `usersAllow` (`PLEX_USERS_ALLOW`) + * If you were using this filter to ensure only scrobbles from yourself were registered then you no longer need this setting -- by default MS will only scrobble for the user the Plex Token is used for. + * The `servers` setting is no longer available as MS only scrobbles from the server using the API anyways. + * If you need to scrobble for multiple servers set up each server as a separate Plex API source + * The `libraries` setting has been renamed to `librariesAllow` + +
+ * 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) @@ -338,7 +357,8 @@ Multi-scrobbler < 0.9.0 used [webhooks](https://support.plex.tv/articles/1150022
- + + ### [Tautulli](https://tautulli.com) From b74f31c26dfc396c672b18c9e9bb14eb4416df75 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Wed, 23 Oct 2024 18:42:55 +0000 Subject: [PATCH 6/8] feat(plex): Implement album art for UI --- src/backend/server/api.ts | 29 ++++++++++++++++++ src/backend/sources/PlexApiSource.ts | 44 ++++++++++++++++++++++++++-- 2 files changed, 71 insertions(+), 2 deletions(-) diff --git a/src/backend/server/api.ts b/src/backend/server/api.ts index 6335015a..af3b2923 100644 --- a/src/backend/server/api.ts +++ b/src/backend/server/api.ts @@ -28,6 +28,7 @@ import { makeClientCheckMiddle, makeSourceCheckMiddle } from "./middleware.js"; import { setupPlexRoutes } from "./plexRoutes.js"; import { setupTautulliRoutes } from "./tautulliRoutes.js"; import { setupWebscrobblerRoutes } from "./webscrobblerRoutes.js"; +import { Readable } from 'node:stream'; const maxBufferSize = 300; const output: Record> = {}; @@ -281,6 +282,34 @@ export const setupApi = (app: ExpressWithAsync, logger: Logger, appLoggerStream: return res.json(result); }); + app.getAsync('/api/source/art', sourceMiddleFunc(false), async (req, res, next) => { + const { + // @ts-expect-error TS(2339): Property 'scrobbleSource' does not exist on type '... Remove this comment to see the full error message + scrobbleSource, + query: { + data + } + } = req; + + const source = scrobbleSource as AbstractSource; + if(!(source instanceof MemorySource)) { + return res.status(500).json({message: 'Source does not support players'}); + } + + if('getSourceArt' in source && typeof source.getSourceArt === 'function') { + const [stream, contentType] = await source.getSourceArt(data); + res.writeHead(200, {'Content-Type': contentType}); + try { + return stream.pipe(res); + } catch (e) { + logger.error(new Error(`Error occurred while trying to stream art for ${source.name} (${source.type}) | Data ${data}`, {cause: e})); + return res.status(500).json({message: 'Error during art retrieval'}); + } + } else { + return res.status(500).json({message: `Source ${source.name} (${source.type} does not support art retrieval`}); + } + }); + app.getAsync('/api/dead', clientMiddleFunc(true), async (req, res, next) => { const { // @ts-expect-error TS(2339): Property 'scrobbleSource' does not exist on type '... Remove this comment to see the full error message diff --git a/src/backend/sources/PlexApiSource.ts b/src/backend/sources/PlexApiSource.ts index 8557256b..0681ac89 100644 --- a/src/backend/sources/PlexApiSource.ts +++ b/src/backend/sources/PlexApiSource.ts @@ -21,9 +21,13 @@ import { PlexApiSourceConfig } from "../common/infrastructure/config/source/plex import { isPortReachable } from '../utils/NetworkUtils.js'; import normalizeUrl from 'normalize-url'; import { GetTokenDetailsResponse, GetTokenDetailsUserPlexAccount } from '@lukehagar/plexjs/sdk/models/operations/gettokendetails.js'; +import { parseRegexSingle } from '@foxxmd/regex-buddy-core'; +import { Readable } from 'node:stream'; const shortDeviceId = truncateStringToLength(10, ''); +const THUMB_REGEX = new RegExp(/\/library\/metadata\/(?\d+)\/thumb\/\d+/) + export default class PlexApiSource extends MemorySource { users: string[] = []; @@ -244,8 +248,26 @@ export default class PlexApiSource extends MemorySource { } formatPlayObjAware(obj: GetSessionsMetadata, options: FormatPlayObjectOptions = {}): PlayObject { - // TODO - return PlexApiSource.formatPlayObj(obj, options); + 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/source/art?name=${this.name}&type=${this.type}&data=${res.named.ratingkey}` + } + } + } + } + } + + return play; } static formatPlayObj(obj: GetSessionsMetadata, options: FormatPlayObjectOptions = {}): PlayObject { @@ -316,6 +338,24 @@ export default class PlexApiSource extends MemorySource { return this.processRecentPlays(validSessions); } + getSourceArt = async (data: string): Promise<[Readable, string]> => { + try { + const resp = await this.plexApi.media.getThumbImage({ + ratingKey: parseInt(data), + 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 }); + } + } + sessionToPlayerState = (obj: GetSessionsMetadata): PlayerStateDataMaybePlay => { const { From 3cc735b9c864950eb1ed39cf3fd4a9cd9895bf6c Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Wed, 23 Oct 2024 20:30:33 +0000 Subject: [PATCH 7/8] fix(plex): Refactor player state to allow for drift and tracking pause based on Source behavior Plex only updates player position every 15 seconds (of played track) so player state needs to be adapted to not detect this as a pause or seek. * Allow per-source drift allowed before triggering seek * Allow per-source pause detection --- .../infrastructure/config/source/plex.ts | 3 +- .../PlayerState/AbstractPlayerState.ts | 20 ++++---- .../sources/PlayerState/GenericPlayerState.ts | 4 ++ .../sources/PlayerState/ListenRange.ts | 6 ++- .../sources/PlayerState/PlexPlayerState.ts | 15 ++++++ .../sources/PlayerState/RealtimePlayer.ts | 48 +++++++++++++++++++ src/backend/sources/PlexApiSource.ts | 10 +++- 7 files changed, 94 insertions(+), 12 deletions(-) create mode 100644 src/backend/sources/PlayerState/PlexPlayerState.ts create mode 100644 src/backend/sources/PlayerState/RealtimePlayer.ts diff --git a/src/backend/common/infrastructure/config/source/plex.ts b/src/backend/common/infrastructure/config/source/plex.ts index 27424f99..9a5b6867 100644 --- a/src/backend/common/infrastructure/config/source/plex.ts +++ b/src/backend/common/infrastructure/config/source/plex.ts @@ -1,3 +1,4 @@ +import { PollingOptions } from "../common.js"; import { CommonSourceConfig, CommonSourceData, CommonSourceOptions } from "./index.js"; export interface PlexSourceData extends CommonSourceData { @@ -35,7 +36,7 @@ export interface PlexSourceAIOConfig extends PlexSourceConfig { type: 'plex' } -export interface PlexApiData extends CommonSourceData { +export interface PlexApiData extends CommonSourceData, PollingOptions { token?: string /** * http(s)://HOST:PORT of the Plex server to connect to diff --git a/src/backend/sources/PlayerState/AbstractPlayerState.ts b/src/backend/sources/PlayerState/AbstractPlayerState.ts index 0ab5bdd6..77d528bf 100644 --- a/src/backend/sources/PlayerState/AbstractPlayerState.ts +++ b/src/backend/sources/PlayerState/AbstractPlayerState.ts @@ -57,6 +57,8 @@ export abstract class AbstractPlayerState { createdAt: Dayjs = dayjs(); stateLastUpdatedAt: Dayjs = dayjs(); + protected allowedDrift?: number; + protected constructor(logger: Logger, platformId: PlayPlatformId, opts: PlayerStateOptions = DefaultPlayerStateOptions) { this.platformId = platformId; this.logger = childLogger(logger, `Player ${this.platformIdStr}`); @@ -248,18 +250,18 @@ export abstract class AbstractPlayerState { // and polling/network delays means we did not catch absolute beginning of track usedPosition = 1; } - this.currentListenRange = new ListenRange(new ListenProgress(timestamp, usedPosition)); + this.currentListenRange = new ListenRange(new ListenProgress(timestamp, usedPosition), undefined, this.allowedDrift); } else { const oldEndProgress = this.currentListenRange.end; const newEndProgress = new ListenProgress(timestamp, position); if (position !== undefined && oldEndProgress !== undefined) { - if (position === oldEndProgress.position && !['paused', 'stopped'].includes(this.calculatedStatus)) { - this.calculatedStatus = this.reportedStatus === 'stopped' ? CALCULATED_PLAYER_STATUSES.stopped : CALCULATED_PLAYER_STATUSES.paused; - if (this.reportedStatus !== this.calculatedStatus) { - this.logger.debug(`Reported status '${this.reportedStatus}' but track position has not progressed between two updates. Calculated player status is now ${this.calculatedStatus}`); - } else { - this.logger.debug(`Player position is equal between current -> last update. Updated calculated status to ${this.calculatedStatus}`); - } + if (!this.isSessionStillPlaying(position) && !['paused', 'stopped'].includes(this.calculatedStatus)) { + this.calculatedStatus = this.reportedStatus === 'stopped' ? CALCULATED_PLAYER_STATUSES.stopped : CALCULATED_PLAYER_STATUSES.paused; + if (this.reportedStatus !== this.calculatedStatus) { + this.logger.debug(`Reported status '${this.reportedStatus}' but track position has not progressed between two updates. Calculated player status is now ${this.calculatedStatus}`); + } else { + this.logger.debug(`Player position is equal between current -> last update. Updated calculated status to ${this.calculatedStatus}`); + } } else if (position !== oldEndProgress.position && this.calculatedStatus !== 'playing') { this.calculatedStatus = CALCULATED_PLAYER_STATUSES.playing; if (this.reportedStatus !== this.calculatedStatus) { @@ -275,6 +277,8 @@ export abstract class AbstractPlayerState { } } + protected abstract isSessionStillPlaying(position: number): boolean; + protected currentListenSessionEnd() { if (this.currentListenRange !== undefined && this.currentListenRange.getDuration() !== 0) { this.logger.debug('Ended current Player listen range.') diff --git a/src/backend/sources/PlayerState/GenericPlayerState.ts b/src/backend/sources/PlayerState/GenericPlayerState.ts index bafb6b5f..265077db 100644 --- a/src/backend/sources/PlayerState/GenericPlayerState.ts +++ b/src/backend/sources/PlayerState/GenericPlayerState.ts @@ -6,4 +6,8 @@ export class GenericPlayerState extends AbstractPlayerState { constructor(logger: Logger, platformId: PlayPlatformId, opts?: PlayerStateOptions) { super(logger, platformId, opts); } + + protected isSessionStillPlaying(position: number): boolean { + return position !== this.currentListenRange.end.position; + } } diff --git a/src/backend/sources/PlayerState/ListenRange.ts b/src/backend/sources/PlayerState/ListenRange.ts index 6a2dc050..64d51d19 100644 --- a/src/backend/sources/PlayerState/ListenRange.ts +++ b/src/backend/sources/PlayerState/ListenRange.ts @@ -6,13 +6,15 @@ export class ListenRange implements ListenRangeData { public start: ListenProgress; public end: ListenProgress; + protected allowedDrift: number; - constructor(start?: ListenProgress, end?: ListenProgress) { + constructor(start?: ListenProgress, end?: ListenProgress, allowedDrift: number = 2500) { const s = start ?? new ListenProgress(); const e = end ?? s; this.start = s; this.end = e; + this.allowedDrift = allowedDrift; } isPositional() { @@ -39,7 +41,7 @@ export class ListenRange implements ListenRangeData { const realTimeDiff = Math.max(0, reportedTS.diff(this.end.timestamp, 'ms')); // 0 max used so TS from testing doesn't cause "backward" diff const positionDiff = (position - this.end.position) * 1000; // if user is more than 2.5 seconds ahead of real time - if (positionDiff - realTimeDiff > 2500) { + if (positionDiff - realTimeDiff > this.allowedDrift) { return [true, position - this.end.position]; } diff --git a/src/backend/sources/PlayerState/PlexPlayerState.ts b/src/backend/sources/PlayerState/PlexPlayerState.ts new file mode 100644 index 00000000..497784d2 --- /dev/null +++ b/src/backend/sources/PlayerState/PlexPlayerState.ts @@ -0,0 +1,15 @@ +import { Logger } from "@foxxmd/logging"; +import { PlayPlatformId, REPORTED_PLAYER_STATUSES } from "../../common/infrastructure/Atomic.js"; +import { AbstractPlayerState, PlayerStateOptions } from "./AbstractPlayerState.js"; +import { GenericPlayerState } from "./GenericPlayerState.js"; + +export class PlexPlayerState extends GenericPlayerState { + constructor(logger: Logger, platformId: PlayPlatformId, opts?: PlayerStateOptions) { + super(logger, platformId, opts); + this.allowedDrift = 17000; + } + + protected isSessionStillPlaying(position: number): boolean { + return this.reportedStatus === REPORTED_PLAYER_STATUSES.playing; + } +} diff --git a/src/backend/sources/PlayerState/RealtimePlayer.ts b/src/backend/sources/PlayerState/RealtimePlayer.ts new file mode 100644 index 00000000..f4b30d37 --- /dev/null +++ b/src/backend/sources/PlayerState/RealtimePlayer.ts @@ -0,0 +1,48 @@ +import { childLogger, Logger } from "@foxxmd/logging"; +import { SimpleIntervalJob, Task, ToadScheduler } from "toad-scheduler"; + +const RT_TICK = 500; + +abstract class RealtimePlayer { + + logger: Logger; + scheduler: ToadScheduler = new ToadScheduler(); + + protected position: number = 0; + + protected constructor(logger: Logger) { + this.logger = childLogger(logger, `RT`); + const job = new SimpleIntervalJob({ + milliseconds: RT_TICK, + runImmediately: true + }, new Task('updatePos', () => this.position += RT_TICK), { id: 'rt' }); + this.scheduler.addSimpleIntervalJob(job); + this.scheduler.stop(); + } + + public play(position?: number) { + if (position !== undefined) { + this.position = position; + } + this.scheduler.startById('rt'); + } + + public pause() { + this.scheduler.stop(); + } + + public stop() { + this.pause(); + this.position = 0; + } + + public seek(position: number) { + this.position = position; + } + + public getPosition() { + return this.position; + } +} + +export class GenericRealtimePlayer extends RealtimePlayer {} \ No newline at end of file diff --git a/src/backend/sources/PlexApiSource.ts b/src/backend/sources/PlexApiSource.ts index 0681ac89..ab33fec4 100644 --- a/src/backend/sources/PlexApiSource.ts +++ b/src/backend/sources/PlexApiSource.ts @@ -23,6 +23,9 @@ import normalizeUrl from 'normalize-url'; import { GetTokenDetailsResponse, 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.js'; +import { PlayerStateOptions } from './PlayerState/AbstractPlayerState.js'; +import { Logger } from '@foxxmd/logging'; const shortDeviceId = truncateStringToLength(10, ''); @@ -66,6 +69,7 @@ export default class PlexApiSource extends MemorySource { const { data: { token, + interval = 5, usersAllow = [], usersBlock = [], devicesAllow = [], @@ -74,10 +78,12 @@ export default class PlexApiSource extends MemorySource { librariesBlock = [], } = {}, options: { - logFilterFailure = (parseBool(process.env.DEBUG_MODE) ? 'debug' : 'warn') + logFilterFailure = (parseBool(process.env.DEBUG_MODE) ? 'debug' : 'warn'), } = {} } = this.config; + this.config.data.interval = interval; + if((token === undefined || token.trim() === '')) { throw new Error(`'token' must be specified in config data`); } @@ -385,4 +391,6 @@ export default class PlexApiSource extends MemorySource { position: viewOffset / 1000 } } + + getNewPlayer = (logger: Logger, id: PlayPlatformId, opts: PlayerStateOptions) => new PlexPlayerState(logger, id, opts); } From 51074a58f419391767375c0aa9e833cc9ca52c12 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Thu, 24 Oct 2024 13:41:10 +0000 Subject: [PATCH 8/8] Add deprecation warning to plex webhook source --- src/backend/sources/PlexSource.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/backend/sources/PlexSource.ts b/src/backend/sources/PlexSource.ts index 51015a8f..52036167 100644 --- a/src/backend/sources/PlexSource.ts +++ b/src/backend/sources/PlexSource.ts @@ -70,6 +70,8 @@ export default class PlexSource extends AbstractSource { } else { this.logger.info(`Initializing with the following filters => Users: ${this.users.length === 0 ? 'N/A' : this.users.join(', ')} | Libraries: ${this.libraries.length === 0 ? 'N/A' : this.libraries.join(', ')} | Servers: ${this.servers.length === 0 ? 'N/A' : this.servers.join(', ')}`); } + + this.logger.warn('Plex WEBHOOK source is DEPRECATED! Please switch to Plex API Source as soon as possible.'); } static formatPlayObj(obj: any, options: FormatPlayObjectOptions = {}): PlayObject {