Skip to content

Commit

Permalink
Merge pull request #211 from FoxxMD/plexApi
Browse files Browse the repository at this point in the history
feat(plex): Initial Plex API Source implementation
  • Loading branch information
FoxxMD authored Oct 24, 2024
2 parents ccf317c + 51074a5 commit da76945
Show file tree
Hide file tree
Showing 19 changed files with 1,034 additions and 26 deletions.
20 changes: 13 additions & 7 deletions config/plex.json.example
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
[
{
"name": "MyPlex",
"name": "MyPlexApi",
"enable": true,
"clients": [],
"data": {
"user": ["[email protected]","[email protected]"],
"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"
}
}
}
]
17 changes: 17 additions & 0 deletions config/plex.webhook.json.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
[
// DEPRECATED, use API Source instead
// rename files to plex.json to use
{
"name": "MyPlex",
"enable": true,
"clients": [],
"data": {
"user": ["[email protected]","[email protected]"],
"libraries": ["music","my podcasts"],
"servers": ["myServer","anotherServer"],
"options": {
"logFilterFailure": "warn"
}
}
}
]
91 changes: 88 additions & 3 deletions docsite/docs/configuration/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -240,10 +241,91 @@ 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.
<Tabs groupId="plexType" queryString>
<TabItem value="api" label="API">

:::tip[Important Defaults]

By default...

* 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

:::

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

<Tabs groupId="configType" queryString>
<TabItem value="env" label="ENV">
| Environmental Variable | Required? | Default | Description |
| ---------------------- | --------- | ------- | ---------------------------------------------------------------------- |
| `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 |
| `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 |
</TabItem>
<TabItem value="file" label="File">
<details>

<summary>Example</summary>

<CodeBlock title="CONFIG_DIR/plex.json" language="json5">{PlexConfig}</CodeBlock>

</details>

or <SchemaLink lower objectName="PlexApiSourceConfig"/>

</TabItem>
<TabItem value="aio" label="AIO">
<details>

<summary>Example</summary>

<AIOExample data={PlexConfig} name="plex"/>

</details>

or <SchemaLink lower objectName="PlexApiSourceConfig"/>
</TabItem>
</Tabs>

</TabItem>
<TabItem value="webhook" label="Webhook (Deprecated)">

:::warning[Deprecated]

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.**

:::

<details>

<summary>Migrating to API</summary>

* 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`

</details>

* 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

<Tabs groupId="configType" queryString>
<TabItem value="env" label="ENV">
| Environmental Variable | Required | Default | Description |
Expand All @@ -256,7 +338,7 @@ Check the [instructions](plex.md) on how to setup a [webhooks](https://support.p

<summary>Example</summary>

<CodeBlock title="CONFIG_DIR/plex.json" language="json5">{PlexConfig}</CodeBlock>
<CodeBlock title="CONFIG_DIR/plex.json" language="json5">{PlexWebhookConfig}</CodeBlock>

</details>

Expand All @@ -267,14 +349,17 @@ Check the [instructions](plex.md) on how to setup a [webhooks](https://support.p

<summary>Example</summary>

<AIOExample data={PlexConfig} name="plex"/>
<AIOExample data={PlexWebhookConfig} name="plex"/>

</details>

or <SchemaLink lower objectName="PlexSourceConfig"/>
</TabItem>
</Tabs>

</TabItem>
</Tabs>

### [Tautulli](https://tautulli.com)

Check the [instructions](plex.md) on how to setup a notification agent.
Expand Down
9 changes: 9 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
62 changes: 61 additions & 1 deletion src/backend/common/infrastructure/config/source/plex.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { CommonSourceConfig, CommonSourceData } from "./index.js";
import { PollingOptions } from "../common.js";
import { CommonSourceConfig, CommonSourceData, CommonSourceOptions } from "./index.js";

export interface PlexSourceData extends CommonSourceData {
/**
Expand Down Expand Up @@ -34,3 +35,62 @@ export interface PlexSourceConfig extends CommonSourceConfig {
export interface PlexSourceAIOConfig extends PlexSourceConfig {
type: 'plex'
}

export interface PlexApiData extends CommonSourceData, PollingOptions {
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;
4 changes: 3 additions & 1 deletion src/backend/common/infrastructure/config/source/sources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -21,6 +21,7 @@ import { YTMusicSourceAIOConfig, YTMusicSourceConfig } from "./ytmusic.js";
export type SourceConfig =
SpotifySourceConfig
| PlexSourceConfig
| PlexApiSourceConfig
| TautulliSourceConfig
| DeezerSourceConfig
| SubSonicSourceConfig
Expand All @@ -42,6 +43,7 @@ export type SourceConfig =
export type SourceAIOConfig =
SpotifySourceAIOConfig
| PlexSourceAIOConfig
| PlexApiSourceAIOConfig
| TautulliSourceAIOConfig
| DeezerSourceAIOConfig
| SubsonicSourceAIOConfig
Expand Down
29 changes: 29 additions & 0 deletions src/backend/server/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number, FixedSizeList<LogDataPretty>> = {};
Expand Down Expand Up @@ -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
Expand Down
20 changes: 12 additions & 8 deletions src/backend/sources/PlayerState/AbstractPlayerState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
Expand Down Expand Up @@ -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) {
Expand All @@ -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.')
Expand Down
4 changes: 4 additions & 0 deletions src/backend/sources/PlayerState/GenericPlayerState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Loading

0 comments on commit da76945

Please sign in to comment.