Compare commits

...
18 Commits
Author SHA1 Message Date
FoxxMD 904ddfa5c6 docs(tautulli): Add deprecation notice and discussion link 2024-10-29 16:57:59 +00:00
FoxxMD 3ed6840607 feat: Improved filtering for platform players with multiple states
* Add session key dervied from source data to Play and PlayerState
* Discriminate states based on a session key, if possible
  * Log which states are being dropped based on session key (plex)
* Add session key to important logging (new player play, in UI for player info)
2024-10-29 14:59:55 +00:00
FoxxMD 93125dd7c8 feat: Reduce drop reason logging noise for jellyfin/plex API sources
Only log the first occurrence of a unique session drop so that subsequent drops don't make logging noisy
2024-10-28 18:33:52 +00:00
FoxxMD 61cd5fcf7b fix: Bump @foxxmd/logging to fix DST rolling file issue
#214
2024-10-28 18:02:04 +00:00
Matt Foxx f308f71b5d Merge pull request #212 from FoxxMD/playerStateCleanup
feat(player): Real time positional player state
2024-10-25 14:54:23 -04:00
FoxxMD c498bde1fd feat(ui): Show indeterminate state for non-positional players 2024-10-25 15:57:30 +00:00
FoxxMD 0c9b496137 feat(player): Implement real-time positional state tracking
* Introduce positional and non-positional data structures
  * Refactor state player into types of each
  * Refactor listen ranges and progress into types of each
* Implment (internal) real-time player and base positional player seeked/repeat on real-time drift instead of just reported position
* Refactor tests to use (emulated) real-time components
2024-10-24 20:37:41 +00:00
FoxxMD 9682d4f8f9 refactor(player): Cleaner player update signature 2024-10-24 14:53:30 +00:00
Matt Foxx da76945f5e Merge pull request #211 from FoxxMD/plexApi
feat(plex): Initial Plex API Source implementation
2024-10-24 09:45:17 -04:00
FoxxMD 51074a58f4 Add deprecation warning to plex webhook source 2024-10-24 13:41:10 +00:00
FoxxMD 3cc735b9c8 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
2024-10-23 20:30:33 +00:00
FoxxMD b74f31c26d feat(plex): Implement album art for UI 2024-10-23 18:42:55 +00:00
FoxxMD 4a96cbfb0e docs(plex): Clean up api/webhook sections and add migration guide 2024-10-23 15:18:13 +00:00
FoxxMD 77a3c352b1 feat(plex): Implement workaround for getting plex token details
Ignore sdk validation error and get raw value in order to parse authenticated user
2024-10-23 10:59:36 -04:00
FoxxMD 598ae01082 feat(plex): Add track progress tracking to api source 2024-10-23 13:40:50 +00:00
FoxxMD 54e11899a0 Fix missing comma 2024-10-22 18:29:11 +00:00
FoxxMD dc4962ade1 feat(plex): Initial Plex API Source implementation 2024-10-22 18:21:28 +00:00
FoxxMD ccf317c06f docs: Fix Jellyfin API migration version 2024-10-17 10:40:54 -04:00
46 changed files with 1803 additions and 419 deletions
+13 -7
View File
@@ -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"
}
}
}
]
+17
View File
@@ -0,0 +1,17 @@
[
// DEPRECATED, use API Source instead
// 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"
}
}
}
]
+97 -4
View File
@@ -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,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.8.7 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 from.
* The `servers` setting is no longer available as MS only scrobbles from the server the Plex token is from.
* If you need to scrobble for multiple servers set up each server as a separate Plex API source with a separate token.
* 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 |
@@ -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>
@@ -267,7 +349,7 @@ 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>
@@ -275,8 +357,19 @@ Check the [instructions](plex.md) on how to setup a [webhooks](https://support.p
</TabItem>
</Tabs>
</TabItem>
</Tabs>
### [Tautulli](https://tautulli.com)
:::warning[Potential Deprecated]
Multi-scrobbler >= 0.8.8 supports a Plex Source that [directly uses the API](#plex) and removes the need to use Tautulli since it does not require Plex Pass.
Please see [this issue](https://github.com/FoxxMD/multi-scrobbler/issues/217) for discussion on deprecating Tautulli and provide your input.
:::
Check the [instructions](plex.md) on how to setup a notification agent.
#### Configuration
@@ -361,7 +454,7 @@ Can use this source for any application that implements the [Subsonic API](http:
<details>
<summary>Migrating from Webhook (multi-scrobbler below `v0.9.0`) to API</summary>
<summary>Migrating from Webhook (multi-scrobbler below `v0.8.4`) to API</summary>
In multi-scrobbler **below v0.9.0** communication with Jellyfin was done using Jellyfin's **Webhook** plugin.
This has been deprecated in favor of directly using Jeyllfin's API for a better experience in multi-scrobbler.
+28 -17
View File
@@ -19,11 +19,12 @@
"@fortawesome/react-fontawesome": "^0.2.0",
"@foxxmd/chromecast-client": "^1.0.4",
"@foxxmd/get-version": "^0.0.3",
"@foxxmd/logging": "^0.2.1",
"@foxxmd/logging": "^0.2.2",
"@foxxmd/regex-buddy-core": "^0.1.2",
"@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",
@@ -1199,14 +1200,14 @@
}
},
"node_modules/@foxxmd/logging": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/@foxxmd/logging/-/logging-0.2.1.tgz",
"integrity": "sha512-O1w4PihX1tOD+mVJDUj7jxSJC5ZNbS3wnb4f1EKtGZ/sNoAARbDEty3sU98DDhn8LfGwYH4QJoxrHy+WWxDMzQ==",
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/@foxxmd/logging/-/logging-0.2.2.tgz",
"integrity": "sha512-aEbLh6kDqr5UJ1yBl/h/31bE2XyTfQob6EkgYBVaJb/T+qaVeB/T7T3nfKLZQhS31Y53/LxQKrwHU30ZkkBv4Q==",
"dependencies": {
"pino": "^9.2.0",
"pino-abstract-transport": "^1.2.0",
"pino-pretty": "^11.2.1",
"pino-roll": "^1.1.0",
"pino-roll": "^2.2.0",
"pump": "^3.0.0"
},
"engines": {
@@ -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",
@@ -4173,6 +4182,15 @@
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="
},
"node_modules/date-fns": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/dateformat": {
"version": "4.6.3",
"resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz",
@@ -8393,19 +8411,12 @@
}
},
"node_modules/pino-roll": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/pino-roll/-/pino-roll-1.3.0.tgz",
"integrity": "sha512-bEjnbuSNjHY44LJH9MNqnrLnLWwWlDrK5AE9WMDR1bhQYiikzPgIla1TQ75+J0cx6Im2CYe5kMKRJzbRGVQjVQ==",
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pino-roll/-/pino-roll-2.2.0.tgz",
"integrity": "sha512-PSigcOfIQHcPIRcQFuDFe5RVD37waq9T3No8lqPr4CdIaSa9pshUOPVCO1C1mmb3VQ1n5VXUGuKSNk6yDMUi6w==",
"dependencies": {
"sonic-boom": "^3.8.0"
}
},
"node_modules/pino-roll/node_modules/sonic-boom": {
"version": "3.8.1",
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-3.8.1.tgz",
"integrity": "sha512-y4Z8LCDBuum+PBP3lSV7RHrXscqksve/bi0as7mhwVnBW+/wUqKT/2Kb7um8yqcFy0duYbbPxzt89Zy2nOCaxg==",
"dependencies": {
"atomic-sleep": "^1.0.0"
"date-fns": "^4.1.0",
"sonic-boom": "^4.0.1"
}
},
"node_modules/pino-std-serializers": {
+2 -1
View File
@@ -49,11 +49,12 @@
"@fortawesome/react-fontawesome": "^0.2.0",
"@foxxmd/chromecast-client": "^1.0.4",
"@foxxmd/get-version": "^0.0.3",
"@foxxmd/logging": "^0.2.1",
"@foxxmd/logging": "^0.2.2",
"@foxxmd/regex-buddy-core": "^0.1.2",
"@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",
+5 -7
View File
@@ -4,7 +4,7 @@ import { Dayjs } from "dayjs";
import { Request, Response } from "express";
import { NextFunction, ParamsDictionary, Query } from "express-serve-static-core";
import { FixedSizeList } from 'fixed-size-list';
import { PlayMeta, PlayObject } from "../../../core/Atomic.js";
import { isPlayObject, PlayMeta, PlayObject } from "../../../core/Atomic.js";
import TupleMap from "../TupleMap.js";
export type SourceType =
@@ -116,19 +116,17 @@ export interface PlayerStateData extends PlayerStateDataMaybePlay {
export interface PlayerStateDataMaybePlay {
platformId: PlayPlatformId
/** The ID/Key for individual sessions on a device/platform */
sessionId?: string
play?: PlayObject
status?: ReportedPlayerStatus
position?: number
timestamp?: Dayjs
}
export const asPlayerStateData = (obj: object): obj is PlayerStateData => {
return 'platformId' in obj && 'play' in obj;
}
export const asPlayerStateData = (obj: object): obj is PlayerStateData => asPlayerStateDataMaybePlay(obj) && 'play' in obj && isPlayObject(obj.play)
export const asPlayerStateDataMaybePlay = (obj: object): obj is PlayerStateDataMaybePlay => {
return 'platformId' in obj;
}
export const asPlayerStateDataMaybePlay = (obj: object): obj is PlayerStateDataMaybePlay => 'platformId' in obj
export interface FormatPlayObjectOptions {
newFromSource?: boolean
@@ -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 {
/**
@@ -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;
@@ -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
+1 -2
View File
@@ -2,8 +2,7 @@ import { stringSameness } from '@foxxmd/string-sameness';
import dayjs from "dayjs";
import request, { Request, Response } from 'superagent';
import { PlayObject } from "../../../core/Atomic.js";
import { slice } from "../../../core/StringUtils.js";
import { combinePartsToString } from "../../utils.js";
import { combinePartsToString, slice } from "../../../core/StringUtils.js";
import {
findDelimiters,
normalizeStr,
+29
View File
@@ -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>> = {};
@@ -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
+2 -2
View File
@@ -34,7 +34,7 @@ import { difference, genGroupIdStr, parseBool } from "../utils.js";
import { findCauseByReference } from "../utils/ErrorUtils.js";
import { discoveryAvahi, discoveryNative } from "../utils/MDNSUtils.js";
import { RecentlyPlayedOptions } from "./AbstractSource.js";
import MemorySource from "./MemorySource.js";
import { MemoryPositionalSource } from "./MemoryPositionalSource.js";
interface ChromecastDeviceInfo {
mdns: MdnsDeviceInfo
@@ -46,7 +46,7 @@ interface ChromecastDeviceInfo {
applications: Map<string, PlatformApplicationWithContext>
}
export class ChromecastSource extends MemorySource {
export class ChromecastSource extends MemoryPositionalSource {
declare config: ChromecastSourceConfig;
+2 -2
View File
@@ -7,9 +7,9 @@ import { FormatPlayObjectOptions, InternalConfig } from "../common/infrastructur
import { JRiverSourceConfig } from "../common/infrastructure/config/source/jriver.js";
import { Info, JRiverApiClient, PLAYER_STATE } from "../common/vendor/JRiverApiClient.js";
import { RecentlyPlayedOptions } from "./AbstractSource.js";
import MemorySource from "./MemorySource.js";
import { MemoryPositionalSource } from "./MemoryPositionalSource.js";
export class JRiverSource extends MemorySource {
export class JRiverSource extends MemoryPositionalSource {
declare config: JRiverSourceConfig;
url: URL;
+19 -12
View File
@@ -41,7 +41,7 @@ import { nanoid } from "nanoid";
import pEvent from "p-event";
import { Simulate } from "react-dom/test-utils";
import { PlayObject } from "../../core/Atomic.js";
import { buildTrackString, truncateStringToLength } from "../../core/StringUtils.js";
import { buildTrackString, combinePartsToString, truncateStringToLength } from "../../core/StringUtils.js";
import {
FormatPlayObjectOptions,
InternalConfig,
@@ -50,15 +50,14 @@ import {
PlayPlatformId, REPORTED_PLAYER_STATUSES
} from "../common/infrastructure/Atomic.js";
import { JellyApiSourceConfig } from "../common/infrastructure/config/source/jellyfin.js";
import { combinePartsToString, genGroupIdStr, getPlatformIdFromData, joinedUrl, parseBool, } from "../utils.js";
import { genGroupIdStr, getPlatformIdFromData, joinedUrl, parseBool, } from "../utils.js";
import { parseArrayFromMaybeString } from "../utils/StringUtils.js";
import MemorySource from "./MemorySource.js";
import { PlayerStateOptions } from "./PlayerState/AbstractPlayerState.js";
import { JellyfinPlayerState } from "./PlayerState/JellyfinPlayerState.js";
import { MemoryPositionalSource } from "./MemoryPositionalSource.js";
import { FixedSizeList } from "fixed-size-list";
const shortDeviceId = truncateStringToLength(10, '');
export default class JellyfinApiSource extends MemorySource {
export default class JellyfinApiSource extends MemoryPositionalSource {
users: string[] = [];
client: Jellyfin
@@ -80,7 +79,8 @@ export default class JellyfinApiSource extends MemorySource {
logFilterFailure: false | 'debug' | 'warn';
mediaIdsSeen: string[] = [];
mediaIdsSeen: FixedSizeList<string>;
uniqueDropReasons: FixedSizeList<string>;
libraries: {name: string, paths: string[], collectionType: CollectionType}[] = [];
@@ -103,6 +103,9 @@ export default class JellyfinApiSource extends MemorySource {
id: this.deviceId
}
});
this.uniqueDropReasons = new FixedSizeList<string>(100);
this.mediaIdsSeen = new FixedSizeList<string>(100);
}
protected async doBuildInitData(): Promise<true | string | undefined> {
@@ -415,8 +418,13 @@ export default class JellyfinApiSource extends MemorySource {
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}`);
}
const dropReason = `Player State for -> ${stateIdentifyingInfo} <-- is being dropped because ${validPlay}`;
if(!this.uniqueDropReasons.data.some(x => x === dropReason)) {
this.logger[this.logFilterFailure](dropReason);
this.uniqueDropReasons.add(dropReason);
}
this.logger[this.logFilterFailure](dropReason);
}
}
return this.processRecentPlays(validSessions);
@@ -456,9 +464,9 @@ export default class JellyfinApiSource extends MemorySource {
}
}
if(this.config.options.logPayload && !this.mediaIdsSeen.includes(NowPlayingItem.Id)) {
if(this.config.options.logPayload && !this.mediaIdsSeen.data.includes(NowPlayingItem.Id)) {
this.logger.debug(`First time seeing media ${NowPlayingItem.Id} on ${msDeviceId} (play position ${playerPosition}) => ${JSON.stringify(NowPlayingItem)}`);
this.mediaIdsSeen.push(NowPlayingItem.Id);
this.mediaIdsSeen.add(NowPlayingItem.Id);
}
}
@@ -476,7 +484,6 @@ export default class JellyfinApiSource extends MemorySource {
}
}
getNewPlayer = (logger: Logger, id: PlayPlatformId, opts: PlayerStateOptions) => new JellyfinPlayerState(logger, id, opts)
}
/**
+6 -2
View File
@@ -2,11 +2,15 @@ import { Logger } from "@foxxmd/logging";
import dayjs from "dayjs";
import EventEmitter from "events";
import { PlayObject, TA_CLOSE } from "../../core/Atomic.js";
import { buildTrackString, splitByFirstFound, truncateStringToLength } from "../../core/StringUtils.js";
import {
buildTrackString,
combinePartsToString,
splitByFirstFound,
truncateStringToLength
} from "../../core/StringUtils.js";
import { FormatPlayObjectOptions, InternalConfig, PlayPlatformId } from "../common/infrastructure/Atomic.js";
import { JellySourceConfig } from "../common/infrastructure/config/source/jellyfin.js";
import {
combinePartsToString,
doubleReturnNewline,
parseBool,
parseDurationFromTimestamp,
+2 -2
View File
@@ -4,9 +4,9 @@ import { FormatPlayObjectOptions, InternalConfig } from "../common/infrastructur
import { KodiSourceConfig } from "../common/infrastructure/config/source/kodi.js";
import { KodiApiClient } from "../common/vendor/KodiApiClient.js";
import { RecentlyPlayedOptions } from "./AbstractSource.js";
import MemorySource from "./MemorySource.js";
import { MemoryPositionalSource } from "./MemoryPositionalSource.js";
export class KodiSource extends MemorySource {
export class KodiSource extends MemoryPositionalSource {
declare config: KodiSourceConfig;
client: KodiApiClient;
+2 -2
View File
@@ -19,7 +19,7 @@ import {
} from "../common/infrastructure/config/source/mpd.js";
import { isPortReachable } from "../utils/NetworkUtils.js";
import { RecentlyPlayedOptions } from "./AbstractSource.js";
import MemorySource from "./MemorySource.js";
import { MemoryPositionalSource } from "./MemoryPositionalSource.js";
const mpdClient = mpdapiNS.default;
@@ -29,7 +29,7 @@ const CLIENT_PLAYER_STATE: Record<PlayerState, ReportedPlayerStatus> = {
'stop': REPORTED_PLAYER_STATUSES.stopped,
}
export class MPDSource extends MemorySource {
export class MPDSource extends MemoryPositionalSource {
declare config: MPDSourceConfig;
host?: string
@@ -0,0 +1,10 @@
import { Logger } from "@foxxmd/logging";
import { PlayerStateDataMaybePlay, PlayPlatformId } from "../common/infrastructure/Atomic.js";
import MemorySource from "./MemorySource.js";
import { PlayerStateOptions } from "./PlayerState/AbstractPlayerState.js";
import { PositionalPlayerState } from "./PlayerState/PositionalPlayerState.js";
import { PlayObject } from "../../core/Atomic.js";
export class MemoryPositionalSource extends MemorySource {
getNewPlayer = (logger: Logger, id: PlayPlatformId, opts: PlayerStateOptions) => new PositionalPlayerState(logger, id, opts)
}
+20 -6
View File
@@ -90,7 +90,7 @@ export default class MemorySource extends AbstractSource {
return record;
}
getNewPlayer = (logger: Logger, id: PlayPlatformId, opts: PlayerStateOptions) => new GenericPlayerState(logger, id, opts)
getNewPlayer = (logger: Logger, id: PlayPlatformId, opts: PlayerStateOptions): AbstractPlayerState => new GenericPlayerState(logger, id, opts)
setNewPlayer = (idStr: string, logger: Logger, id: PlayPlatformId, opts: PlayerStateOptions = {}) => {
this.players.set(idStr, this.getNewPlayer(this.logger, id, {
@@ -122,6 +122,13 @@ export default class MemorySource extends AbstractSource {
this.emitEvent('playerDelete', {platformId: id});
}
pickPlatformSession = (sessions: (PlayObject | PlayerStateDataMaybePlay)[], player: AbstractPlayerState): PlayObject | PlayerStateDataMaybePlay => {
if(sessions.length > 1) {
player.logger.debug(`More than one data/state found in incoming data, will only use first found.`);
}
return sessions[0];
}
processRecentPlays = (datas: (PlayObject | PlayerStateDataMaybePlay)[]) => {
const {
@@ -162,12 +169,19 @@ export default class MemorySource extends AbstractSource {
if (relevantDatas.length > 0) {
this.lastActivityAt = dayjs();
if (relevantDatas.length > 1) {
this.logger.warn(`More than one data/state for Player ${player.platformIdStr} found in incoming data, will only use first found.`);
}
incomingData = relevantDatas[0];
incomingData = this.pickPlatformSession(relevantDatas, player);
const [currPlay, prevPlay] = asPlayerStateDataMaybePlay(incomingData) ? player.setState(incomingData.status, incomingData.play) : player.setState(undefined, incomingData);
let playerState: PlayerStateDataMaybePlay;
if(asPlayerStateDataMaybePlay(incomingData)) {
playerState = incomingData;
} else {
playerState = {play: incomingData, platformId: getPlatformIdFromData(incomingData)};
}
if(playerState.position === undefined && playerState.play !== undefined && playerState.play.meta.trackProgressPosition !== undefined) {
playerState.position = playerState.play.meta?.trackProgressPosition;
}
const [currPlay, prevPlay] = player.update(playerState);
const candidate = prevPlay !== undefined ? prevPlay : currPlay;
const playChanged = prevPlay !== undefined;
+2 -2
View File
@@ -15,9 +15,9 @@ import {
} from "../common/infrastructure/Atomic.js";
import { MopidySourceConfig } from "../common/infrastructure/config/source/mopidy.js";
import { RecentlyPlayedOptions } from "./AbstractSource.js";
import MemorySource from "./MemorySource.js";
import { MemoryPositionalSource } from "./MemoryPositionalSource.js";
export class MopidySource extends MemorySource {
export class MopidySource extends MemoryPositionalSource {
declare config: MopidySourceConfig;
albumBlacklist: string[] = [];
+2 -2
View File
@@ -22,7 +22,7 @@ import {
} from "../common/infrastructure/config/source/musikcube.js";
import { sleep } from "../utils.js";
import { RecentlyPlayedOptions } from "./AbstractSource.js";
import MemorySource from "./MemorySource.js";
import { MemoryPositionalSource } from "./MemoryPositionalSource.js";
const CLIENT_STATE = {
0: 'connecting',
@@ -31,7 +31,7 @@ const CLIENT_STATE = {
3: 'closed'
}
export class MusikcubeSource extends MemorySource {
export class MusikcubeSource extends MemoryPositionalSource {
declare config: MusikcubeSourceConfig;
url: URL;
@@ -1,10 +1,13 @@
import { childLogger, Logger } from "@foxxmd/logging";
import dayjs, { Dayjs } from "dayjs";
import { PlayObject, Second, SOURCE_SOT, SOURCE_SOT_TYPES, SourcePlayerObj } from "../../../core/Atomic.js";
import { PlayObject, PlayProgress, Second, SOURCE_SOT, SOURCE_SOT_TYPES, SourcePlayerObj } from "../../../core/Atomic.js";
import { buildTrackString } from "../../../core/StringUtils.js";
import {
asPlayerStateData,
CALCULATED_PLAYER_STATUSES,
CalculatedPlayerStatus,
PlayerStateData,
PlayerStateDataMaybePlay,
PlayPlatformId,
REPORTED_PLAYER_STATUSES,
ReportedPlayerStatus,
@@ -12,7 +15,7 @@ import {
import { PollingOptions } from "../../common/infrastructure/config/common.js";
import { formatNumber, genGroupIdStr, playObjDataMatch, progressBar } from "../../utils.js";
import { ListenProgress } from "./ListenProgress.js";
import { ListenRange } from "./ListenRange.js";
import { ListenRange, ListenRangePositional } from "./ListenRange.js";
export interface PlayerStateIntervals {
staleInterval?: number
@@ -20,6 +23,8 @@ export interface PlayerStateIntervals {
}
export interface PlayerStateOptions extends PlayerStateIntervals {
allowedDrift?: number
rtTruth?: boolean
}
export const DefaultPlayerStateOptions: PlayerStateOptions = {};
@@ -48,6 +53,7 @@ export abstract class AbstractPlayerState {
reportedStatus: ReportedPlayerStatus = REPORTED_PLAYER_STATUSES.unknown
calculatedStatus: CalculatedPlayerStatus = CALCULATED_PLAYER_STATUSES.unknown
platformId: PlayPlatformId
sessionId?: string
stateIntervalOptions: Required<PlayerStateIntervals>;
currentPlay?: PlayObject
playFirstSeenAt?: Dayjs
@@ -68,6 +74,9 @@ export abstract class AbstractPlayerState {
this.stateIntervalOptions = {staleInterval, orphanedInterval: orphanedInterval};
}
protected abstract newListenProgress(data?: Partial<PlayProgress>): ListenProgress;
protected abstract newListenRange(start?: ListenProgress, end?: ListenProgress, options?: object): ListenRange;
get platformIdStr() {
return genGroupIdStr(this.platformId);
}
@@ -119,11 +128,16 @@ export abstract class AbstractPlayerState {
return status !== 'paused' && status !== 'stopped';
}
setState(status?: ReportedPlayerStatus, play?: PlayObject, reportedTS?: Dayjs) {
update(state: PlayerStateDataMaybePlay, reportedTS?: Dayjs) {
this.stateLastUpdatedAt = dayjs();
if (play !== undefined) {
return this.setPlay(play, status, reportedTS);
} else if (status !== undefined) {
const {play, status} = state;
if (asPlayerStateData(state)) {
return this.setPlay(state, reportedTS);
}
if (status !== undefined) {
if (status === 'stopped' && this.reportedStatus !== 'stopped' && this.currentPlay !== undefined) {
this.stopPlayer();
const play = this.getPlayedObject(true);
@@ -137,19 +151,20 @@ export abstract class AbstractPlayerState {
return [];
}
protected setPlay(play: PlayObject, status?: ReportedPlayerStatus, reportedTS?: Dayjs): [PlayObject, PlayObject?] {
protected setPlay(state: PlayerStateData, reportedTS?: Dayjs): [PlayObject, PlayObject?] {
const {play, status, sessionId} = state;
this.playLastUpdatedAt = dayjs();
if (status !== undefined) {
this.reportedStatus = status;
}
this.sessionId = sessionId;
if (this.currentPlay !== undefined) {
const currentPlayMatches = playObjDataMatch(this.currentPlay, play);
if (!currentPlayMatches) { // TODO check new play date and listen range to see if they intersect
if (!this.incomingPlayMatchesExisting(play)) { // TODO check new play date and listen range to see if they intersect
this.logger.debug(`Incoming play state (${buildTrackString(play, {include: ['trackId', 'artist', 'track']})}) does not match existing state, removing existing: ${buildTrackString(this.currentPlay, {include: ['trackId', 'artist', 'track']})}`)
this.currentListenSessionEnd();
const played = this.getPlayedObject(true);
this.setCurrentPlay(play, {reportedTS});
this.setCurrentPlay(state, {reportedTS});
if (this.calculatedStatus !== CALCULATED_PLAYER_STATUSES.playing) {
this.calculatedStatus = CALCULATED_PLAYER_STATUSES.unknown;
}
@@ -157,27 +172,30 @@ export abstract class AbstractPlayerState {
} else if (status !== undefined && !AbstractPlayerState.isProgressStatus(status)) {
this.currentListenSessionEnd();
this.calculatedStatus = this.reportedStatus;
} else if (this.isSessionRepeat(play.meta.trackProgressPosition, reportedTS)) {
} else if (this.isSessionRepeat(state.position, reportedTS)) {
// if we detect the track has been restarted end listen session and treat as a new play
this.currentListenSessionEnd();
const played = this.getPlayedObject(true);
play.data.playDate = dayjs();
this.setCurrentPlay(play, {reportedTS});
this.setCurrentPlay(state, {reportedTS});
return [this.getPlayedObject(), played];
} else {
if(this.currentListenRange !== undefined) {
const [isSeeked, seekedPos] = this.currentListenRange.seeked(play.meta.trackProgressPosition, reportedTS);
const [isSeeked, seekedPos] = this.currentListenRange.seeked(state.position, reportedTS);
if (isSeeked !== false) {
this.logger.debug(`Detected player was seeked ${seekedPos.toFixed(2)}s, starting new listen range`);
this.logger.verbose(`Detected player was seeked ${(seekedPos / 1000).toFixed(2)}s, starting new listen range`);
if(state.position !== undefined && (this.currentListenRange as ListenRangePositional).end.position === state.position) {
this.calculatedStatus = CALCULATED_PLAYER_STATUSES.paused;
}
// if player has been seeked start a new listen range so our numbers don't get all screwy
this.currentListenSessionEnd();
}
}
this.currentListenSessionContinue(play.meta.trackProgressPosition, reportedTS);
this.currentListenSessionContinue(state.position, reportedTS);
}
} else {
this.setCurrentPlay(play);
this.setCurrentPlay(state);
this.calculatedStatus = CALCULATED_PLAYER_STATUSES.unknown;
}
@@ -188,6 +206,8 @@ export abstract class AbstractPlayerState {
return [this.getPlayedObject(), undefined];
}
protected incomingPlayMatchesExisting(play: PlayObject): boolean { return playObjDataMatch(this.currentPlay, play); }
protected clearPlayer() {
this.currentPlay = undefined;
this.playLastUpdatedAt = undefined;
@@ -238,63 +258,9 @@ export abstract class AbstractPlayerState {
return listenDur;
}
protected currentListenSessionContinue(position?: number, timestamp?: Dayjs) {
const now = dayjs();
if (this.currentListenRange === undefined) {
this.logger.debug('Started new Player listen range.');
let usedPosition = position;
if(this.calculatedStatus === CALCULATED_PLAYER_STATUSES.playing && position !== undefined && position <= 3) {
// likely the player has moved to a new track from a previous track (still calculated as playing)
// and polling/network delays means we did not catch absolute beginning of track
usedPosition = 1;
}
this.currentListenRange = new ListenRange(new ListenProgress(timestamp, usedPosition));
} 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}`);
}
} else if (position !== oldEndProgress.position && this.calculatedStatus !== 'playing') {
this.calculatedStatus = CALCULATED_PLAYER_STATUSES.playing;
if (this.reportedStatus !== this.calculatedStatus) {
this.logger.debug(`Reported status '${this.reportedStatus}' but track position has progressed between two updates. Calculated player status is now ${this.calculatedStatus}`);
} else {
this.logger.debug(`Player position changed between current -> last update. Updated calculated status to ${this.calculatedStatus}`);
}
}
} else {
this.calculatedStatus = CALCULATED_PLAYER_STATUSES.playing;
}
this.currentListenRange.setRangeEnd(newEndProgress);
}
}
protected abstract currentListenSessionContinue(position?: number | undefined, timestamp?: Dayjs);
protected currentListenSessionEnd() {
if (this.currentListenRange !== undefined && this.currentListenRange.getDuration() !== 0) {
this.logger.debug('Ended current Player listen range.')
if(this.calculatedStatus === CALCULATED_PLAYER_STATUSES.playing && this.currentListenRange.isPositional() && !this.currentListenRange.isInitial()) {
const {
data: {
duration,
} = {}
} = this.currentPlay;
if(duration !== undefined && (duration - this.currentListenRange.end.position) < 3) {
// likely the track was listened to until it ended
// but polling interval or network delays caused MS to not get data on the very end
// also...within 3 seconds of ending is close enough to call this complete IMO
this.currentListenRange.end.position = duration;
}
}
this.listenRanges.push(this.currentListenRange);
}
this.currentListenRange = undefined;
}
protected abstract currentListenSessionEnd();
protected isSessionRepeat(position?: number, reportedTS?: Dayjs) {
if(this.currentListenRange === undefined) {
@@ -327,11 +293,11 @@ export abstract class AbstractPlayerState {
repeatHint = `${repeatHint} and listened to more than 50% (${formatNumber((playerDur/trackDur)*100)}%).`
}
if (closeDurNum || closeDurPer) {
this.logger.debug(repeatHint);
this.logger.verbose(repeatHint);
return true;
}
if (trackDur !== undefined) {
const lastPos = this.currentListenRange.end.position;
if (trackDur !== undefined && this.currentListenRange.getPosition() !== undefined) {
const lastPos = this.currentListenRange.getPosition();
// or last position is within 10 seconds (or 10%) of end of track
const nearEndNum = (trackDur - lastPos < 12);
if(nearEndNum) {
@@ -342,7 +308,7 @@ export abstract class AbstractPlayerState {
repeatHint = `${repeatHint} and previous position was within 15% of track end (${formatNumber((lastPos/trackDur)*100)}%)`;
}
if(nearEndNum || nearEndPos) {
this.logger.debug(repeatHint);
this.logger.verbose(repeatHint);
return true;
}
}
@@ -350,7 +316,7 @@ export abstract class AbstractPlayerState {
return false;
}
protected setCurrentPlay(play: PlayObject, options?: CurrentPlayOptions) {
protected setCurrentPlay(state: PlayerStateData, options?: CurrentPlayOptions) {
const {
status,
@@ -358,19 +324,21 @@ export abstract class AbstractPlayerState {
listenSessionManaged = true
} = options || {};
const {play, position} = state;
this.currentPlay = play;
this.playFirstSeenAt = dayjs();
this.listenRanges = [];
this.currentListenRange = undefined;
this.logger.debug(`New Play: ${buildTrackString(play, {include: ['trackId', 'artist', 'track']})}`);
this.logger.verbose(`New Play: ${buildTrackString(play, {include: ['trackId', 'artist', 'track', 'session']})}`);
if (status !== undefined) {
this.reportedStatus = status;
}
if (listenSessionManaged && !['stopped'].includes(this.reportedStatus)) {
this.currentListenSessionContinue(play.meta.trackProgressPosition, reportedTS);
this.currentListenSessionContinue(position, reportedTS);
}
}
@@ -378,11 +346,11 @@ export abstract class AbstractPlayerState {
const parts = [''];
let play: string;
if (this.currentPlay !== undefined) {
parts.push(`${buildTrackString(this.currentPlay, {include: ['trackId', 'artist', 'track']})} @ ${this.playFirstSeenAt.toISOString()}`);
parts.push(`${buildTrackString(this.currentPlay, {include: ['trackId', 'artist', 'track', 'session']})} @ ${this.playFirstSeenAt.toISOString()}`);
}
parts.push(`Reported: ${this.reportedStatus.toUpperCase()} | Calculated: ${this.calculatedStatus.toUpperCase()} | Stale: ${this.isUpdateStale() ? 'Yes' : 'No'} | Orphaned: ${this.isOrphaned() ? 'Yes' : 'No'} | Last Update: ${this.stateLastUpdatedAt.toISOString()}`);
let progress = '';
if (this.currentListenRange !== undefined && this.currentListenRange.end.position !== undefined && this.currentPlay.data.duration !== undefined) {
if (this.currentListenRange !== undefined && this.currentListenRange instanceof ListenRangePositional && this.currentPlay.data.duration !== undefined) {
progress = `${progressBar(this.currentListenRange.end.position / this.currentPlay.data.duration, 1, 15)} ${formatNumber(this.currentListenRange.end.position, {toFixed: 0})}/${formatNumber(this.currentPlay.data.duration, {toFixed: 0})}s | `;
}
let listenedPercent = '';
@@ -400,20 +368,17 @@ export abstract class AbstractPlayerState {
this.logger.debug(this.textSummary());
}
public getPosition(): Second {
public getPosition(): Second | undefined {
if(this.calculatedStatus === 'stopped') {
return undefined;
}
let lastRange: ListenRange | undefined;
if(this.currentListenRange !== undefined) {
lastRange = this.currentListenRange;
} else if(this.listenRanges.length > 0) {
lastRange = this.listenRanges[this.listenRanges.length - 1];
return this.currentListenRange.getPosition();
}
if(lastRange === undefined || lastRange.end === undefined || lastRange.end.position === undefined) {
return undefined;
if(this.listenRanges.length > 0) {
return this.listenRanges[this.listenRanges.length - 1].getPosition();
}
return lastRange.end.position;
return undefined;
}
public getApiState(): SourcePlayerObj {
@@ -438,7 +403,7 @@ export abstract class AbstractPlayerState {
this.logger.debug(`Transferring state to new Player (${newPlayer.platformIdStr})`);
newPlayer.calculatedStatus = this.calculatedStatus;
if(this.currentPlay !== undefined) {
newPlayer.setCurrentPlay(this.currentPlay, {status: this.reportedStatus, listenSessionManaged: false});
newPlayer.setCurrentPlay({play: this.currentPlay, platformId: this.platformId}, {status: this.reportedStatus, listenSessionManaged: false});
}
newPlayer.currentListenRange = this.currentListenRange;
newPlayer.listenRanges = this.listenRanges;
@@ -1,9 +1,40 @@
import { Logger } from "@foxxmd/logging";
import { PlayPlatformId } from "../../common/infrastructure/Atomic.js";
import { CALCULATED_PLAYER_STATUSES, PlayPlatformId } from "../../common/infrastructure/Atomic.js";
import { AbstractPlayerState, PlayerStateOptions } from "./AbstractPlayerState.js";
import { PlayProgress } from "../../../core/Atomic.js";
import { ListenProgress, ListenProgressTS } from "./ListenProgress.js";
import { ListenRange, ListenRangeTS } from "./ListenRange.js";
import { Dayjs } from "dayjs";
export class GenericPlayerState extends AbstractPlayerState {
protected newListenProgress(data?: Partial<PlayProgress>): ListenProgress {
return new ListenProgressTS(data);
}
protected newListenRange(start?: ListenProgress, end?: ListenProgress, options?: object): ListenRange {
return new ListenRangeTS(start, end);
}
constructor(logger: Logger, platformId: PlayPlatformId, opts?: PlayerStateOptions) {
super(logger, platformId, opts);
}
protected currentListenSessionContinue(position?: number, timestamp?: Dayjs) {
if (this.currentListenRange === undefined) {
this.logger.debug('Started new Player listen range.');
this.currentListenRange = this.newListenRange(this.newListenProgress({timestamp}));
} else {
this.calculatedStatus = CALCULATED_PLAYER_STATUSES.playing;
this.currentListenRange.setRangeEnd(this.newListenProgress({timestamp}));
}
}
protected currentListenSessionEnd() {
if (this.currentListenRange !== undefined && this.currentListenRange.getDuration() !== 0) {
this.logger.debug('Ended current Player listen range.')
this.currentListenRange.finalize();
this.listenRanges.push(this.currentListenRange);
}
this.currentListenRange = undefined;
}
}
@@ -1,19 +1,20 @@
import { Logger } from "@foxxmd/logging";
import { PlayObject } from "../../../core/Atomic.js";
import { PlayPlatformId, ReportedPlayerStatus } from "../../common/infrastructure/Atomic.js";
import { PlayerStateDataMaybePlay, PlayPlatformId, ReportedPlayerStatus } from "../../common/infrastructure/Atomic.js";
import { PlayerStateOptions } from "./AbstractPlayerState.js";
import { GenericPlayerState } from "./GenericPlayerState.js";
import { PositionalPlayerState } from "./PositionalPlayerState.js";
export class JellyfinPlayerState extends GenericPlayerState {
export class JellyfinPlayerState extends PositionalPlayerState {
constructor(logger: Logger, platformId: PlayPlatformId, opts?: PlayerStateOptions) {
super(logger, platformId, opts);
}
setState(status?: ReportedPlayerStatus, play?: PlayObject) {
let stat: ReportedPlayerStatus = status;
if(status === undefined && play.meta?.event === 'PlaybackProgress') {
update(state: PlayerStateDataMaybePlay) {
let stat: ReportedPlayerStatus = state.status;
if(stat === undefined && state.play?.meta?.event === 'PlaybackProgress') {
stat = 'playing';
}
return super.setState(stat, play);
return super.update({...state, status: stat});
}
}
@@ -1,32 +1,53 @@
import dayjs, { Dayjs } from "dayjs";
import { PlayProgress, Second } from "../../../core/Atomic.js";
import { PlayProgress, PlayProgressPositional, Second } from "../../../core/Atomic.js";
export class ListenProgress implements PlayProgress {
export class ListenProgressTS implements PlayProgress {
public timestamp: Dayjs;
public position?: Second;
public positionPercent?: number;
constructor(timestamp?: Dayjs, position?: number, positionPercent?: number) {
constructor(data: Partial<PlayProgress> = {}) {
const {timestamp, positionPercent} = data;
this.timestamp = timestamp ?? dayjs();
this.position = position;
this.positionPercent = positionPercent;
}
getDuration(end: ListenProgress): Second {
if (this.position !== undefined && end.position !== undefined) {
return end.position - this.position;
} else {
return end.timestamp.diff(this.timestamp, 'seconds');
}
getDuration(end: ListenProgressTS): Second {
return end.timestamp.diff(this.timestamp, 'seconds');
}
toJSON() {
return {
timestamp: this.timestamp.toISOString(),
position: this.position,
position: undefined,
positionPercent: this.positionPercent
}
}
}
export class ListenProgressPositional extends ListenProgressTS implements PlayProgressPositional {
public position: Second;
constructor(data: PlayProgressPositional) {
super(data);
const {timestamp, position} = data;
this.timestamp = timestamp ?? dayjs();
this.position = position;
}
getDuration(end: ListenProgressPositional): Second {
return end.position - this.position;
}
toJSON() {
return {
timestamp: this.timestamp.toISOString(),
position: this.position,
positionPercent: undefined
}
}
}
export type ListenProgress = ListenProgressTS | ListenProgressPositional;
+140 -27
View File
@@ -1,67 +1,180 @@
import dayjs, { Dayjs } from "dayjs";
import { ListenRangeData, Second } from "../../../core/Atomic.js";
import { ListenProgress } from "./ListenProgress.js";
export class ListenRange implements ListenRangeData {
import { ListenRangeData, Millisecond, PlayProgress, PlayProgressPositional, Second } from "../../../core/Atomic.js";
import { ListenProgress, ListenProgressPositional, ListenProgressTS } from "./ListenProgress.js";
import { GenericRealtimePlayer, RealtimePlayer } from "./RealtimePlayer.js";
export abstract class ListenRange {
public start: ListenProgress;
public end: ListenProgress;
constructor(start?: ListenProgress, end?: ListenProgress) {
const s = start ?? new ListenProgress();
const s = start ?? new ListenProgressTS();
const e = end ?? s;
this.start = s;
this.end = e;
}
public abstract isPositional(): boolean;
public abstract isInitial(): boolean;
public abstract seeked(position?: number, reportedTS?: Dayjs): [boolean, Second?];
public abstract setRangeStart(data: ListenProgress | Partial<PlayProgress>);
public abstract setRangeEnd(data: ListenProgress | Partial<PlayProgress>);
public abstract getDuration(): Second;
public abstract getPosition(): Second | undefined;
public abstract finalize(position?: number);
public abstract toJSON();
}
export class ListenRangeTS extends ListenRange implements ListenRangeData {
declare public start: ListenProgressTS;
declare public end: ListenProgressTS;
isPositional() {
return this.start.position !== undefined && this.end.position !== undefined;
return false;
}
isInitial() {
if (this.isPositional()) {
return this.start.position === this.end.position;
}
return this.start.timestamp.isSame(this.end.timestamp);
}
seeked(position?: number, reportedTS: Dayjs = dayjs()): [boolean, Second?] {
if (position === undefined || this.isInitial() || !this.isPositional()) {
return [false];
return [false];
}
setRangeStart(data: ListenProgress | Partial<PlayProgress>) {
if (data instanceof ListenProgressTS) {
this.start = data;
} else {
const d = data || {};
this.start = new ListenProgressTS(d)
}
}
setRangeEnd(data: ListenProgress | Partial<PlayProgress>) {
if (data instanceof ListenProgressTS) {
this.end = data;
} else {
const d = data || {};
this.end = new ListenProgressTS(d)
}
}
getDuration(): Second {
return this.start.getDuration(this.end);
}
public getPosition(): Second {
return undefined;
}
public finalize(position?: number) {
}
toJSON() {
return [this.start, this.end];
}
}
export class ListenRangePositional extends ListenRange {
declare public start: ListenProgressPositional
declare public end: ListenProgressPositional;
public rtPlayer: RealtimePlayer;
protected finalized: boolean;
rtTruth: boolean;
protected allowedDrift: number;
constructor(start?: ListenProgressPositional, end?: ListenProgressPositional, options: {rtTruth?: boolean, allowedDrift?: number, rtImmediate?: boolean} = {}) {
super(start, end);
const { allowedDrift = 2000, rtTruth = false, rtImmediate = true } = options;
this.allowedDrift = allowedDrift;
this.rtTruth = rtTruth;
this.rtPlayer = new GenericRealtimePlayer();
this.rtPlayer.setPosition(start.position * 1000);
if(rtImmediate) {
this.rtPlayer.play();
}
this.finalized = false;
}
isPositional(): boolean {
return true;
}
isInitial() {
return this.start.position === this.end.position;
}
seeked(position: Second, reportedTS: Dayjs = dayjs()): [boolean, Millisecond?] {
// if (new) position is earlier than last stored position then the user has seeked backwards on the player
if (position < this.end.position) {
return [true, position - this.end.position];
return [true, (position - this.end.position) * 1000];
}
// if (new) position is more than a reasonable number of ms ahead of real time than they have seeked forwards on the player
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;
//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) {
return [true, position - this.end.position];
if (this.isOverDrifted(position)) {
return [true, this.getDrift(position)];
}
return [false];
}
setRangeStart(data: ListenProgress | { position?: number, timestamp?: Dayjs, positionPercent?: number }) {
if (data instanceof ListenProgress) {
setRangeStart(data: ListenProgressPositional | PlayProgressPositional) {
if (data instanceof ListenProgressPositional) {
this.start = data;
} else {
const d = data || {};
this.start = new ListenProgress(d.timestamp, d.position, d.positionPercent)
//const d = data || {};
this.start = new ListenProgressPositional(data)
}
this.rtPlayer.stop();
this.rtPlayer.play(this.start.position);
}
getDrift(position?: Second): Millisecond {
return ((position ?? this.end.position) * 1000) - this.rtPlayer.getPosition();
}
isOverDrifted(position: Second): boolean {
return Math.abs(this.getDrift((position ?? this.end.position))) > this.allowedDrift;
}
setRangeEnd(data: ListenProgressPositional | PlayProgressPositional/* , force?: boolean */) {
const endProgress = data instanceof ListenProgressPositional ? data : new ListenProgressPositional(data)
// if(this.rtTruth) {
// if(!this.isOverDrifted(endProgress.position) && !force) {
// endProgress.position = this.rtPlayer.getPosition();
// } else {
// // if we've drifted too far sync RT to reported position
// this.rtPlayer.setPosition(endProgress.position);
// }
// }
this.end = endProgress;
}
finalize(position?: number) {
this.rtPlayer.pause();
this.finalized = true;
let finalPosition = position;
if(finalPosition === undefined && this.rtTruth) {
finalPosition = this.rtPlayer.getPosition(true);
}
if(finalPosition !== undefined) {
this.end.position = finalPosition;
}
}
setRangeEnd(data: ListenProgress | { position?: number, timestamp?: Dayjs, positionPercent?: number }) {
if (data instanceof ListenProgress) {
this.end = data;
} else {
const d = data || {};
this.end = new ListenProgress(d.timestamp, d.position, d.positionPercent)
public getPosition(): Second | undefined {
if(this.rtTruth && !this.finalized) {
this.rtPlayer.getPosition();
}
return this.end.position;
}
getDuration(): Second {
@@ -71,4 +184,4 @@ export class ListenRange implements ListenRangeData {
toJSON() {
return [this.start, this.end];
}
}
}
@@ -0,0 +1,16 @@
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";
import { PositionalPlayerState } from "./PositionalPlayerState.js";
export class PlexPlayerState extends PositionalPlayerState {
constructor(logger: Logger, platformId: PlayPlatformId, opts?: PlayerStateOptions) {
super(logger, platformId, {allowedDrift: 17000, rtTruth: true, ...(opts || {})});
}
protected isSessionStillPlaying(position: number): boolean {
return this.reportedStatus === REPORTED_PLAYER_STATUSES.playing;
}
}
@@ -0,0 +1,114 @@
import { Logger } from "@foxxmd/logging";
import { CALCULATED_PLAYER_STATUSES, PlayPlatformId, REPORTED_PLAYER_STATUSES } from "../../common/infrastructure/Atomic.js";
import { AbstractPlayerState, PlayerStateOptions } from "./AbstractPlayerState.js";
import { GenericPlayerState } from "./GenericPlayerState.js";
import { GenericRealtimePlayer, RealtimePlayer } from "./RealtimePlayer.js";
import { PlayProgress, PlayProgressPositional, Second } from "../../../core/Atomic.js";
import { Dayjs } from "dayjs";
import { ListenProgress, ListenProgressPositional } from "./ListenProgress.js";
import { ListenRange, ListenRangePositional } from "./ListenRange.js";
export class PositionalPlayerState extends AbstractPlayerState {
protected allowedDrift: number;
protected rtTruth: boolean;
declare currentListenRange?: ListenRangePositional;
declare listenRanges: ListenRangePositional[];
constructor(logger: Logger, platformId: PlayPlatformId, opts?: PlayerStateOptions) {
super(logger, platformId, opts);
const {
allowedDrift = 3000,
rtTruth = false,
} = opts || {};
this.allowedDrift = allowedDrift;
this.rtTruth = rtTruth;
}
protected newListenProgress(data?: PlayProgressPositional): ListenProgressPositional {
return new ListenProgressPositional(data);
}
protected newListenRange(start?: ListenProgressPositional, end?: ListenProgressPositional, options: object = {}): ListenRangePositional {
return new ListenRangePositional(start, end, {allowedDrift: this.allowedDrift, rtTruth: this.rtTruth, ...options});
}
protected isSessionStillPlaying(position: number): boolean {
//return this.reportedStatus === REPORTED_PLAYER_STATUSES.playing;
if(!this.currentListenRange.isOverDrifted(position)) {
return true;
}
return position !== this.currentListenRange.end.position;
}
protected currentListenSessionContinue(position: number, timestamp?: Dayjs) {
if (this.currentListenRange === undefined) {
this.logger.debug('Started new Player listen range.');
let usedPosition = position;
if (this.calculatedStatus === CALCULATED_PLAYER_STATUSES.playing && position !== undefined && position <= 3) {
// likely the player has moved to a new track from a previous track (still calculated as playing)
// and polling/network delays means we did not catch absolute beginning of track
usedPosition = 1;
}
this.currentListenRange = this.newListenRange(this.newListenProgress({ timestamp, position: usedPosition }), undefined);
} else {
const oldEndProgress = this.currentListenRange.end;
const newEndProgress = this.newListenProgress({ timestamp, position });
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) {
this.logger.debug(`Reported status '${this.reportedStatus}' but track position has progressed between two updates. Calculated player status is now ${this.calculatedStatus}`);
} else {
this.logger.debug(`Player position changed between current -> last update. Updated calculated status to ${this.calculatedStatus}`);
}
}
this.currentListenRange.setRangeEnd(newEndProgress);
}
}
protected currentListenSessionEnd() {
if (this.currentListenRange !== undefined && this.currentListenRange.getDuration() !== 0) {
this.logger.debug('Ended current Player listen range.')
let finalPosition: number;
if(this.calculatedStatus === CALCULATED_PLAYER_STATUSES.playing && !this.currentListenRange.isInitial()) {
const {
data: {
duration,
} = {}
} = this.currentPlay;
if(duration !== undefined && (duration - this.currentListenRange.end.position) < 3) {
// likely the track was listened to until it ended
// but polling interval or network delays caused MS to not get data on the very end
// also...within 3 seconds of ending is close enough to call this complete IMO
finalPosition = duration;
//this.currentListenRange.end.position = duration;
}
}
this.currentListenRange.finalize(finalPosition);
this.listenRanges.push(this.currentListenRange);
}
this.currentListenRange = undefined;
}
public getPosition(): Second | undefined {
if(this.calculatedStatus !== 'stopped' && this.currentListenRange !== undefined && this.rtTruth) {
return this.currentListenRange.rtPlayer.getPosition(true);
}
return super.getPosition();
}
}
@@ -0,0 +1,57 @@
import { childLogger, Logger } from "@foxxmd/logging";
import { SimpleIntervalJob, Task, ToadScheduler } from "toad-scheduler";
const RT_TICK = 500;
export 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();
this.position = 0;
}
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(asSeconds: boolean = false) {
return !asSeconds ? this.position : this.position / 1000;
}
public setPosition(time: number) {
this.position = time;
}
}
export class GenericRealtimePlayer extends RealtimePlayer {
constructor(/* logger: Logger */) {
super();
}
}
@@ -0,0 +1,44 @@
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";
import { GenericRealtimePlayer, RealtimePlayer } from "./RealtimePlayer.js";
import { Second } from "../../../core/Atomic.js";
import { Dayjs } from "dayjs";
// export class RealtimePlayerState extends GenericPlayerState {
// rtPlayer: RealtimePlayer;
// //allowedDrift: number;
// constructor(logger: Logger, platformId: PlayPlatformId, opts?: PlayerStateOptions) {
// super(logger, platformId, opts);
// this.rtPlayer = new GenericRealtimePlayer(logger);
// const {
// allowedDrift = 3000
// } = opts || {};
// this.allowedDrift = 3000;
// }
// protected isSessionStillPlaying(position: number): boolean {
// return this.reportedStatus === REPORTED_PLAYER_STATUSES.playing;
// }
// public getPosition(): Second | undefined {
// if(this.calculatedStatus === 'stopped') {
// return undefined;
// }
// return this.rtPlayer.getPosition();
// }
// protected currentListenSessionEnd() {
// super.currentListenSessionEnd();
// this.rtPlayer.pause();
// }
// protected currentListenSessionContinue(position?: number, timestamp?: Dayjs) {
// const rt = this.rtPlayer.getPosition(true);
// if(Math.abs(position - rt) > this.allowedDrift) {
// this.logger.debug(`Reported position (${position}s) has drifted from real-time (${rt}s) more than allowed (${this.allowedDrift}ms)`);
// }
// }
// }
+425
View File
@@ -0,0 +1,425 @@
import objectHash from 'object-hash';
import EventEmitter from "events";
import { PlayObject } from "../../core/Atomic.js";
import { buildTrackString, combinePartsToString, truncateStringToLength } from "../../core/StringUtils.js";
import {
asPlayerStateDataMaybePlay,
FormatPlayObjectOptions,
InternalConfig,
PlayerStateData,
PlayerStateDataMaybePlay,
PlayPlatformId, REPORTED_PLAYER_STATUSES
} from "../common/infrastructure/Atomic.js";
import { genGroupIdStr, getFirstNonEmptyString, getPlatformIdFromData, joinedUrl, parseBool, } from "../utils.js";
import { buildStatePlayerPlayIdententifyingInfo, parseArrayFromMaybeString } from "../utils/StringUtils.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 { 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';
import { PlexPlayerState } from './PlayerState/PlexPlayerState.js';
import { AbstractPlayerState, PlayerStateOptions } from './PlayerState/AbstractPlayerState.js';
import { Logger } from '@foxxmd/logging';
import { MemoryPositionalSource } from './MemoryPositionalSource.js';
import { FixedSizeList } from 'fixed-size-list';
const shortDeviceId = truncateStringToLength(10, '');
const THUMB_REGEX = new RegExp(/\/library\/metadata\/(?<ratingkey>\d+)\/thumb\/\d+/)
export default class PlexApiSource extends MemoryPositionalSource {
users: string[] = [];
plexApi: PlexAPI;
plexUser: string;
deviceId: string;
address: URL;
usersAllow: string[] = [];
usersBlock: string[] = [];
devicesAllow: string[] = [];
devicesBlock: string[] = [];
librariesAllow: string[] = [];
librariesBlock: string[] = [];
logFilterFailure: false | 'debug' | 'warn';
mediaIdsSeen: FixedSizeList<string>;
uniqueDropReasons: FixedSizeList<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))}`;
this.uniqueDropReasons = new FixedSizeList<string>(100);
this.mediaIdsSeen = new FixedSizeList<string>(100);
}
protected async doBuildInitData(): Promise<true | string | undefined> {
const {
data: {
token,
interval = 5,
usersAllow = [],
usersBlock = [],
devicesAllow = [],
devicesBlock = [],
librariesAllow = [],
librariesBlock = [],
} = {},
options: {
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`);
}
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<true | string | undefined> {
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<boolean> => {
try {
const server = await this.plexApi.server.getServerCapabilities();
let userPlexAccount: GetTokenDetailsUserPlexAccount;
try {
const tokenDetails = await this.plexApi.authentication.getTokenDetails();
userPlexAccount = tokenDetails.userPlexAccount;
} catch (e) {
if(e instanceof SDKValidationError && 'UserPlexAccount' in (e.rawValue as object)) {
userPlexAccount = (e.rawValue as {UserPlexAccount: GetTokenDetailsUserPlexAccount}).UserPlexAccount as GetTokenDetailsUserPlexAccount;
} else {
throw new Error('Could not parse Plex Account details to determine authenticated username', {cause: e});
}
}
this.plexUser = getFirstNonEmptyString([userPlexAccount.username, userPlexAccount.title, userPlexAccount.friendlyName, userPlexAccount.email]);
if(this.usersAllow.length === 0) {
this.usersAllow.push(this.plexUser.toLocaleLowerCase());
}
this.logger.info(`Authenticated on behalf of user ${this.plexUser} on Server ${server.object.mediaContainer.friendlyName} (version ${server.object.mediaContainer.version})`);
return true;
} catch (e) {
if(e.message.includes('401') && e.message.includes('API error occurred')) {
throw new Error('Plex Token was not valid for the specified server', {cause: e});
} else {
throw e;
}
}
}
protected buildLibraryInfo = async () => {
try {
const libraries = await this.plexApi.library.getAllLibraries();
this.libraries = libraries.object.mediaContainer.directory.map(x => ({name: x.title, collectionType: x.type, uuid: x.uuid}));
} catch (e) {
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 {
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 {
const {
type,
viewOffset,
title: track,
parentTitle: album,
grandparentTitle: artist, // OR album artist
librarySectionTitle: library,
duration,
guid,
sessionKey,
player: {
product,
title: playerTitle,
machineIdentifier
} = {},
user: {
title: userTitle
} = {}
// plex returns the track artist as originalTitle (when there is an album artist)
// otherwise this is undefined
//originalTitle: trackArtist = 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]),
sessionId: sessionKey,
trackProgressPosition: viewOffset / 1000
}
}
}
getRecentlyPlayed = async (options = {}) => {
const result = await this.plexApi.sessions.getSessions();
const allSessions: [PlayerStateDataMaybePlay, GetSessionsMetadata][] = (result.object.mediaContainer?.metadata ?? [])
.map(x => [this.sessionToPlayerState(x), x]);
const validSessions: PlayerStateDataMaybePlay[] = [];
for(const sessionData of allSessions) {
const validPlay = this.isActivityValid(sessionData[0], sessionData[1]);
if(validPlay === true) {
validSessions.push(sessionData[0]);
} else if(this.logFilterFailure !== false) {
const stateIdentifyingInfo = buildStatePlayerPlayIdententifyingInfo(sessionData[0]);
const dropReason = `Player State for -> ${stateIdentifyingInfo} <-- is being dropped because ${validPlay}`;
if(!this.uniqueDropReasons.data.some(x => x === dropReason)) {
this.logger[this.logFilterFailure](dropReason);
this.uniqueDropReasons.add(dropReason);
}
}
}
return 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 });
}
}
pickPlatformSession = (sessions: (PlayObject | PlayerStateDataMaybePlay)[], player: AbstractPlayerState): PlayObject | PlayerStateDataMaybePlay => {
if(sessions.length === 1) {
return sessions[0];
}
// if all are player states and have session ids
// then choose the player state with the "latest" session key
if(sessions.every(x => asPlayerStateDataMaybePlay(x) && 'sessionId' in x)) {
const pStateSessions = sessions as PlayerStateDataMaybePlay[];
pStateSessions.sort((a, b) => parseInt(a.sessionId) - parseInt(b.sessionId));
const validSession = pStateSessions[sessions.length - 1];
const droppingSessions = pStateSessions.filter(x => x.sessionId !== validSession.sessionId).map(x => buildStatePlayerPlayIdententifyingInfo(x)).join('\n');
player.logger.debug(`More than one data/state found in incoming data, dropping these sessions with "earlier" session keys:\n${droppingSessions}`);
return validSession;
}
return sessions[0];
}
sessionToPlayerState = (obj: GetSessionsMetadata): PlayerStateDataMaybePlay => {
const {
viewOffset,
player: {
machineIdentifier,
product,
title,
state
} = {},
sessionKey
} = obj;
const msDeviceId = combinePartsToString([shortDeviceId(machineIdentifier), product, title]);
const play: PlayObject = this.formatPlayObjAware(obj);
if(this.config.options.logPayload && !this.mediaIdsSeen.data.includes(play.meta.trackId)) {
this.logger.debug(`First time seeing media ${play.meta.trackId} on ${msDeviceId} => ${JSON.stringify(play)}`);
this.mediaIdsSeen.add(play.meta.trackId);
}
const reportedStatus = state !== 'playing' ? REPORTED_PLAYER_STATUSES.paused : REPORTED_PLAYER_STATUSES.playing;
return {
platformId: [msDeviceId, play.meta.user],
sessionId: sessionKey,
play,
status: reportedStatus,
position: viewOffset / 1000
}
}
getNewPlayer = (logger: Logger, id: PlayPlatformId, opts: PlayerStateOptions) => new PlexPlayerState(logger, id, opts);
}
+3 -2
View File
@@ -5,10 +5,9 @@ import EventEmitter from "events";
import formidable, { Files, File } from 'formidable';
import { file } from "jscodeshift";
import { PlayObject } from "../../core/Atomic.js";
import { truncateStringToLength } from "../../core/StringUtils.js";
import { combinePartsToString, truncateStringToLength } from "../../core/StringUtils.js";
import { FormatPlayObjectOptions, InternalConfig, SourceType } from "../common/infrastructure/Atomic.js";
import { PlexSourceConfig } from "../common/infrastructure/config/source/plex.js";
import { combinePartsToString } from "../utils.js";
import { getFileIdentifier, getValidMultipartJsonFile } from "../utils/RequestUtils.js";
import AbstractSource from "./AbstractSource.js";
@@ -70,6 +69,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 {
+18 -4
View File
@@ -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);
+3 -4
View File
@@ -3,7 +3,7 @@ import EventEmitter from "events";
import SpotifyWebApi from "spotify-web-api-node";
import request from 'superagent';
import { PlayObject, SCROBBLE_TS_SOC_END, SCROBBLE_TS_SOC_START, ScrobbleTsSOC } from "../../core/Atomic.js";
import { truncateStringToLength } from "../../core/StringUtils.js";
import { combinePartsToString, truncateStringToLength } from "../../core/StringUtils.js";
import { isNodeNetworkException } from "../common/errors/NodeErrors.js";
import { hasUpstreamError, UpstreamError } from "../common/errors/UpstreamError.js";
import {
@@ -18,7 +18,6 @@ import {
} from "../common/infrastructure/Atomic.js";
import { SpotifySourceConfig } from "../common/infrastructure/config/source/spotify.js";
import {
combinePartsToString,
joinedUrl,
parseRetryAfterSecsFromObj,
readJson,
@@ -28,20 +27,20 @@ import {
} from "../utils.js";
import { findCauseByFunc } from "../utils/ErrorUtils.js";
import { RecentlyPlayedOptions } from "./AbstractSource.js";
import MemorySource from "./MemorySource.js";
import AlbumObjectSimplified = SpotifyApi.AlbumObjectSimplified;
import ArtistObjectSimplified = SpotifyApi.ArtistObjectSimplified;
import CurrentlyPlayingObject = SpotifyApi.CurrentlyPlayingObject;
import PlayHistoryObject = SpotifyApi.PlayHistoryObject;
import TrackObjectFull = SpotifyApi.TrackObjectFull;
import UserDevice = SpotifyApi.UserDevice;
import { MemoryPositionalSource } from "./MemoryPositionalSource.js";
const scopes = ['user-read-recently-played', 'user-read-currently-playing', 'user-read-playback-state', 'user-read-playback-position'];
const state = 'random';
const shortDeviceId = truncateStringToLength(10, '');
export default class SpotifySource extends MemorySource {
export default class SpotifySource extends MemoryPositionalSource {
spotifyApi: SpotifyWebApi;
workingCredsPath: string;
+1 -2
View File
@@ -2,10 +2,9 @@ import dayjs from "dayjs";
import EventEmitter from "events";
import { Request } from "express";
import { PlayObject } from "../../core/Atomic.js";
import { truncateStringToLength } from "../../core/StringUtils.js";
import { combinePartsToString, truncateStringToLength } from "../../core/StringUtils.js";
import { FormatPlayObjectOptions, InternalConfig } from "../common/infrastructure/Atomic.js";
import { TautulliSourceConfig } from "../common/infrastructure/config/source/tautulli.js";
import { combinePartsToString } from "../utils.js";
import PlexSource from "./PlexSource.js";
const shortDeviceId = truncateStringToLength(10, '');
+2 -2
View File
@@ -15,7 +15,7 @@ import { VlcAudioMeta, VLCSourceConfig, PlayerState } from "../common/infrastruc
import { isPortReachable } from "../utils/NetworkUtils.js";
import { firstNonEmptyStr } from "../utils/StringUtils.js";
import { RecentlyPlayedOptions } from "./AbstractSource.js";
import MemorySource from "./MemorySource.js";
import { MemoryPositionalSource } from "./MemoryPositionalSource.js";
const CLIENT_PLAYER_STATE: Record<PlayerState, ReportedPlayerStatus> = {
'playing': REPORTED_PLAYER_STATUSES.playing,
@@ -23,7 +23,7 @@ const CLIENT_PLAYER_STATE: Record<PlayerState, ReportedPlayerStatus> = {
'stopped': REPORTED_PLAYER_STATUSES.stopped,
}
export class VLCSource extends MemorySource {
export class VLCSource extends MemoryPositionalSource {
declare config: VLCSourceConfig;
host?: string
+130 -128
View File
@@ -1,22 +1,39 @@
import { loggerTest } from "@foxxmd/logging";
import { assert } from 'chai';
import clone from "clone";
import dayjs from "dayjs";
import dayjs, { Dayjs } from "dayjs";
import { describe, it } from 'mocha';
import {
CALCULATED_PLAYER_STATUSES,
NO_DEVICE,
NO_USER,
REPORTED_PLAYER_STATUSES
PlayerStateDataMaybePlay,
REPORTED_PLAYER_STATUSES,
SINGLE_USER_PLATFORM_ID
} from "../../common/infrastructure/Atomic.js";
import { GenericPlayerState } from "../../sources/PlayerState/GenericPlayerState.js";
import { playObjDataMatch } from "../../utils.js";
import { generatePlay } from "../utils/PlayTestUtils.js";
import { PositionalPlayerState } from "../../sources/PlayerState/PositionalPlayerState.js";
import { ListenProgressPositional } from "../../sources/PlayerState/ListenProgress.js";
import { ListenRangePositional } from "../../sources/PlayerState/ListenRange.js";
const logger = loggerTest;
const newPlay = generatePlay({duration: 300});
const testState = (data: Omit<PlayerStateDataMaybePlay, 'platformId'>): PlayerStateDataMaybePlay => ({...data, platformId: SINGLE_USER_PLATFORM_ID});
class TestPositionalPlayerState extends PositionalPlayerState {
protected newListenRange(start?: ListenProgressPositional, end?: ListenProgressPositional, options: object = {}): ListenRangePositional {
const range = super.newListenRange(start, end, {rtImmediate: false, ...options});
return range;
}
public testSessionRepeat(position: number, reportedTS?: Dayjs) {
return this.isSessionRepeat(position, reportedTS);
}
}
describe('Basic player state', function () {
it('Creates new play state when new', function () {
@@ -25,7 +42,7 @@ describe('Basic player state', function () {
assert.isUndefined(player.currentListenRange);
assert.isUndefined(player.currentPlay);
player.setState(undefined, newPlay);
player.update(testState({play: newPlay}));
assert.isDefined(player.currentListenRange);
assert.isDefined(player.currentPlay);
@@ -37,7 +54,7 @@ describe('Basic player state', function () {
assert.isUndefined(player.currentListenRange);
assert.isUndefined(player.currentPlay);
player.setState(undefined, newPlay);
player.update(testState({play: newPlay}));
assert.isDefined(player.currentListenRange);
assert.isDefined(player.currentPlay);
@@ -47,12 +64,12 @@ describe('Basic player state', function () {
it('Creates new play state when incoming play is not the same as stored play', function () {
const player = new GenericPlayerState(logger, [NO_DEVICE, NO_USER]);
player.setState(undefined, newPlay);
player.update(testState({play: newPlay}));
assert.isTrue(playObjDataMatch(player.currentPlay, newPlay));
const nextPlay = generatePlay({playDate: newPlay.data.playDate.add(2, 'seconds')});
const [returnedPlay, prevPlay] = player.setState(undefined, nextPlay);
const [returnedPlay, prevPlay] = player.update(testState({play: nextPlay}));
assert.isTrue(playObjDataMatch(prevPlay, newPlay));
assert.isTrue(playObjDataMatch(player.currentPlay, nextPlay));
@@ -64,10 +81,10 @@ describe('Player status', function () {
it('New player transitions from unknown to playing on n+1 states', function () {
const player = new GenericPlayerState(logger, [NO_DEVICE, NO_USER]);
player.setState(undefined, newPlay);
player.update(testState({play: newPlay}));
assert.equal(CALCULATED_PLAYER_STATUSES.unknown, player.calculatedStatus);
player.setState(undefined, newPlay, dayjs().add(10, 'seconds'));
player.update(testState({play: newPlay}), dayjs().add(10, 'seconds'));
assert.equal(CALCULATED_PLAYER_STATUSES.playing, player.calculatedStatus);
});
@@ -76,8 +93,8 @@ describe('Player status', function () {
it('Calculated state is playing when source reports playing', function () {
const player = new GenericPlayerState(logger, [NO_DEVICE, NO_USER]);
player.setState(REPORTED_PLAYER_STATUSES.playing, newPlay);
player.setState(REPORTED_PLAYER_STATUSES.playing, newPlay, dayjs().add(10, 'seconds'));
player.update(testState({play: newPlay, status: REPORTED_PLAYER_STATUSES.playing}));
player.update(testState({play: newPlay, status: REPORTED_PLAYER_STATUSES.playing}), dayjs().add(10, 'seconds'));
assert.equal(CALCULATED_PLAYER_STATUSES.playing, player.calculatedStatus);
});
@@ -85,18 +102,18 @@ describe('Player status', function () {
it('Calculated state is paused when source reports paused', function () {
const player = new GenericPlayerState(logger, [NO_DEVICE, NO_USER]);
player.setState(REPORTED_PLAYER_STATUSES.playing, newPlay);
player.setState(REPORTED_PLAYER_STATUSES.playing, newPlay, dayjs().add(10, 'seconds'));
player.setState(REPORTED_PLAYER_STATUSES.paused, newPlay, dayjs().add(20, 'seconds'));
player.update(testState({play: newPlay, status: REPORTED_PLAYER_STATUSES.playing}));
player.update(testState({play: newPlay, status: REPORTED_PLAYER_STATUSES.playing}), dayjs().add(10, 'seconds'));
player.update(testState({play: newPlay, status: REPORTED_PLAYER_STATUSES.paused}), dayjs().add(20, 'seconds'));
assert.equal(CALCULATED_PLAYER_STATUSES.paused, player.calculatedStatus);
});
it('Calculated state is stopped when source reports stopped', function () {
const player = new GenericPlayerState(logger, [NO_DEVICE, NO_USER]);
player.setState(REPORTED_PLAYER_STATUSES.playing, newPlay);
player.setState(REPORTED_PLAYER_STATUSES.playing, newPlay, dayjs().add(10, 'seconds'));
player.setState(REPORTED_PLAYER_STATUSES.stopped, newPlay, dayjs().add(20, 'seconds'));
player.update(testState({play: newPlay, status: REPORTED_PLAYER_STATUSES.playing}));
player.update(testState({play: newPlay, status: REPORTED_PLAYER_STATUSES.playing}), dayjs().add(10, 'seconds'));
player.update(testState({play: newPlay, status: REPORTED_PLAYER_STATUSES.stopped}), dayjs().add(20, 'seconds'));
assert.equal(CALCULATED_PLAYER_STATUSES.stopped, player.calculatedStatus);
});
@@ -105,31 +122,31 @@ describe('Player status', function () {
describe('When source provides playback position', function () {
it('Calculated state is playing when position moves forward', function () {
const player = new GenericPlayerState(logger, [NO_DEVICE, NO_USER]);
const player = new TestPositionalPlayerState(logger, [NO_DEVICE, NO_USER]);
const positioned = clone(newPlay);
positioned.meta.trackProgressPosition = 3;
player.setState(undefined, positioned);
player.update(testState({play: positioned, position: 3}));
positioned.meta.trackProgressPosition = 13;
player.setState(undefined, positioned, dayjs().add(10, 'seconds'));
player.currentListenRange.rtPlayer.setPosition(13000);
player.update(testState({play: positioned, position: 13}), dayjs().add(10, 'seconds'));
assert.equal(CALCULATED_PLAYER_STATUSES.playing, player.calculatedStatus);
});
it('Calculated state is paused when position does not change', function () {
const player = new GenericPlayerState(logger, [NO_DEVICE, NO_USER]);
it('Calculated state is paused when position does not change and rt overdrifts', function () {
const player = new TestPositionalPlayerState(logger, [NO_DEVICE, NO_USER]);
const positioned = clone(newPlay);
positioned.meta.trackProgressPosition = 3;
player.setState(undefined, positioned);
player.update(testState({play: positioned, position: 3}));
positioned.meta.trackProgressPosition = 13;
player.setState(undefined, positioned, dayjs().add(10, 'seconds'));
player.currentListenRange.rtPlayer.setPosition(13000);
player.update(testState({play: positioned, position: 13}), dayjs().add(10, 'seconds'));
player.setState(undefined, positioned, dayjs().add(20, 'seconds'));
player.currentListenRange.rtPlayer.setPosition(23000);
player.update(testState({play: positioned, position: 13}), dayjs().add(20, 'seconds'));
assert.equal(CALCULATED_PLAYER_STATUSES.paused, player.calculatedStatus);
});
@@ -144,17 +161,17 @@ describe('Player listen ranges', function () {
it('Duration is timestamp based for unknown/playing reported players', function () {
const player = new GenericPlayerState(logger, [NO_DEVICE, NO_USER]);
player.setState(REPORTED_PLAYER_STATUSES.playing, newPlay);
player.setState(REPORTED_PLAYER_STATUSES.playing, newPlay, dayjs().add(10, 'seconds'));
player.setState(REPORTED_PLAYER_STATUSES.playing, newPlay, dayjs().add(20, 'seconds'));
player.update(testState({play: newPlay, status: REPORTED_PLAYER_STATUSES.playing}));
player.update(testState({play: newPlay, status: REPORTED_PLAYER_STATUSES.playing}), dayjs().add(10, 'seconds'));
player.update(testState({play: newPlay, status: REPORTED_PLAYER_STATUSES.playing}), dayjs().add(20, 'seconds'));
assert.equal(player.getListenDuration(), 20);
const uplayer = new GenericPlayerState(logger, [NO_DEVICE, NO_USER]);
uplayer.setState(undefined, newPlay);
uplayer.setState(undefined, newPlay, dayjs().add(10, 'seconds'));
uplayer.setState(undefined, newPlay, dayjs().add(20, 'seconds'));
uplayer.update(testState({play: newPlay}));
uplayer.update(testState({play: newPlay}), dayjs().add(10, 'seconds'));
uplayer.update(testState({play: newPlay}), dayjs().add(20, 'seconds'));
assert.equal(uplayer.getListenDuration(), 20);
});
@@ -162,11 +179,11 @@ describe('Player listen ranges', function () {
it('Range ends if player reports paused', function () {
const player = new GenericPlayerState(logger, [NO_DEVICE, NO_USER]);
player.setState(REPORTED_PLAYER_STATUSES.playing, newPlay);
player.setState(REPORTED_PLAYER_STATUSES.playing, newPlay, dayjs().add(10, 'seconds'));
player.setState(REPORTED_PLAYER_STATUSES.playing, newPlay, dayjs().add(20, 'seconds'));
player.setState(REPORTED_PLAYER_STATUSES.paused, newPlay, dayjs().add(30, 'seconds'));
player.setState(REPORTED_PLAYER_STATUSES.paused, newPlay, dayjs().add(40, 'seconds'));
player.update(testState({play: newPlay, status: REPORTED_PLAYER_STATUSES.playing}));
player.update(testState({play: newPlay, status: REPORTED_PLAYER_STATUSES.playing}), dayjs().add(10, 'seconds'));
player.update(testState({play: newPlay, status: REPORTED_PLAYER_STATUSES.playing}), dayjs().add(20, 'seconds'));
player.update(testState({play: newPlay, status: REPORTED_PLAYER_STATUSES.paused}), dayjs().add(30, 'seconds'));
player.update(testState({play: newPlay, status: REPORTED_PLAYER_STATUSES.paused}), dayjs().add(40, 'seconds'));
assert.equal(player.getListenDuration(), 20);
});
@@ -174,15 +191,15 @@ describe('Player listen ranges', function () {
it('Listen duration continues when player resumes', function () {
const player = new GenericPlayerState(logger, [NO_DEVICE, NO_USER]);
player.setState(REPORTED_PLAYER_STATUSES.playing, newPlay);
player.setState(REPORTED_PLAYER_STATUSES.playing, newPlay, dayjs().add(10, 'seconds'));
player.setState(REPORTED_PLAYER_STATUSES.playing, newPlay, dayjs().add(20, 'seconds'));
player.setState(REPORTED_PLAYER_STATUSES.paused, newPlay, dayjs().add(30, 'seconds'));
player.setState(REPORTED_PLAYER_STATUSES.paused, newPlay, dayjs().add(40, 'seconds'));
player.update(testState({play: newPlay, status: REPORTED_PLAYER_STATUSES.playing}));
player.update(testState({play: newPlay, status: REPORTED_PLAYER_STATUSES.playing}), dayjs().add(10, 'seconds'));
player.update(testState({play: newPlay, status: REPORTED_PLAYER_STATUSES.playing}), dayjs().add(20, 'seconds'));
player.update(testState({play: newPlay, status: REPORTED_PLAYER_STATUSES.paused}), dayjs().add(30, 'seconds'));
player.update(testState({play: newPlay, status: REPORTED_PLAYER_STATUSES.paused}), dayjs().add(40, 'seconds'));
// For TS-only players the player must see two consecutive playing states to count the duration between them
// so it does NOT count above paused ^^ to below playing -- only playing-to-playing
player.setState(REPORTED_PLAYER_STATUSES.playing, newPlay, dayjs().add(50, 'seconds'));
player.setState(REPORTED_PLAYER_STATUSES.playing, newPlay, dayjs().add(60, 'seconds'));
player.update(testState({play: newPlay, status: REPORTED_PLAYER_STATUSES.playing}), dayjs().add(50, 'seconds'));
player.update(testState({play: newPlay, status: REPORTED_PLAYER_STATUSES.playing}), dayjs().add(60, 'seconds'));
assert.equal(player.getListenDuration(), 30);
});
@@ -191,163 +208,148 @@ describe('Player listen ranges', function () {
describe('When source does provide playback position', function () {
it('Duration is position based', function () {
const player = new GenericPlayerState(logger, [NO_DEVICE, NO_USER]);
const player = new TestPositionalPlayerState(logger, [NO_DEVICE, NO_USER]);
const positioned = clone(newPlay);
positioned.meta.trackProgressPosition = 3;
player.setState(undefined, positioned);
positioned.meta.trackProgressPosition = 10;
player.setState(undefined, positioned, dayjs().add(10, 'seconds'));
player.update(testState({play: positioned, position: 3}));
player.currentListenRange.rtPlayer.setPosition(10000);
player.update(testState({play: positioned, position: 10}), dayjs().add(10, 'seconds'));
assert.equal(player.getListenDuration(), 7);
});
it('Range ends if position does not move', function () {
const player = new GenericPlayerState(logger, [NO_DEVICE, NO_USER]);
it('Range ends if position over drifts', function () {
const player = new TestPositionalPlayerState(logger, [NO_DEVICE, NO_USER]);
const positioned = clone(newPlay);
positioned.meta.trackProgressPosition = 3;
player.setState(undefined, positioned);
player.update(testState({play: positioned, position: 3}));
positioned.meta.trackProgressPosition = 3;
player.setState(undefined, positioned, dayjs().add(10, 'seconds'));
player.currentListenRange.rtPlayer.setPosition(10000);
player.update(testState({play: positioned, position: 3}), dayjs().add(10, 'seconds'));
assert.equal(player.getListenDuration(), 0);
});
it('Range continues when position continues moving forward', function () {
const player = new GenericPlayerState(logger, [NO_DEVICE, NO_USER]);
const player = new TestPositionalPlayerState(logger, [NO_DEVICE, NO_USER]);
const positioned = clone(newPlay);
positioned.meta.trackProgressPosition = 3;
player.setState(undefined, positioned);
player.update(testState({play: positioned, position: 3}));
positioned.meta.trackProgressPosition = 7;
player.setState(undefined, positioned, dayjs().add(10, 'seconds'));
player.currentListenRange.rtPlayer.setPosition(7000);
player.update(testState({play: positioned, position: 7}), dayjs().add(4, 'seconds'));
player.setState(undefined, positioned, dayjs().add(20, 'seconds'));
positioned.meta.trackProgressPosition = 17;
player.setState(undefined, positioned, dayjs().add(30, 'seconds'));
player.currentListenRange.rtPlayer.setPosition(23000);
player.update(testState({play: positioned, position: 23}), dayjs().add(20, 'seconds'));
positioned.meta.trackProgressPosition = 27;
player.setState(undefined, positioned, dayjs().add(40, 'seconds'));
player.currentListenRange.rtPlayer.setPosition(33000);
player.update(testState({play: positioned, position: 33}), dayjs().add(30, 'seconds'));
assert.equal(player.getListenDuration(), 24);
player.currentListenRange.rtPlayer.setPosition(43000);
player.update(testState({play: positioned, position: 43}), dayjs().add(40, 'seconds'));
assert.equal(player.getListenDuration(), 40);
});
describe('Detects seeking', function () {
it('Detects seeking forward', function () {
const player = new GenericPlayerState(logger, [NO_DEVICE, NO_USER]);
const player = new TestPositionalPlayerState(logger, [NO_DEVICE, NO_USER]);
const positioned = clone(newPlay);
positioned.meta.trackProgressPosition = 3;
player.setState(undefined, positioned);
player.update(testState({play: positioned, position: 3}));
positioned.meta.trackProgressPosition = 13;
player.setState(undefined, positioned, dayjs().add(10, 'seconds'));
player.currentListenRange.rtPlayer.setPosition(13000);
player.update(testState({play: positioned, position: 13}), dayjs().add(10, 'seconds'));
positioned.meta.trackProgressPosition = 30;
player.setState(undefined, positioned, dayjs().add(20, 'seconds'));
assert.equal(player.currentListenRange.start.timestamp, player.currentListenRange.end.timestamp);
positioned.meta.trackProgressPosition = 40;
player.setState(undefined, positioned, dayjs().add(30, 'seconds'));
assert.equal(player.getListenDuration(), 20);
player.currentListenRange.rtPlayer.setPosition(17000);
const [isSeeked, time] = player.currentListenRange.seeked(24, dayjs().add(17, 'seconds'))
assert.isTrue(isSeeked);
assert.equal(time, 7000)
});
it('Detects seeking backwards', function () {
const player = new GenericPlayerState(logger, [NO_DEVICE, NO_USER]);
it('Detects seeking backwards when position is before last reported position', function () {
const player = new TestPositionalPlayerState(logger, [NO_DEVICE, NO_USER]);
const positioned = clone(newPlay);
positioned.meta.trackProgressPosition = 30;
player.setState(undefined, positioned);
player.update(testState({play: positioned, position: 3}));
positioned.meta.trackProgressPosition = 40;
player.setState(undefined, positioned, dayjs().add(10, 'seconds'));
player.currentListenRange.rtPlayer.setPosition(13000);
player.update(testState({play: positioned, position: 13}), dayjs().add(10, 'seconds'));
positioned.meta.trackProgressPosition = 20;
player.setState(undefined, positioned, dayjs().add(20, 'seconds'));
assert.equal(player.currentListenRange.start.timestamp, player.currentListenRange.end.timestamp);
positioned.meta.trackProgressPosition = 30;
player.setState(undefined, positioned, dayjs().add(30, 'seconds'));
assert.equal(player.getListenDuration(), 20);
player.currentListenRange.rtPlayer.setPosition(17000);
const [isSeeked, time] = player.currentListenRange.seeked(10, dayjs().add(17, 'seconds'))
assert.isTrue(isSeeked);
assert.equal(time, -3000)
});
});
describe('Detects repeating', function () {
it('Detects repeat when player was within 12 seconds of ending and seeked back to within 12 seconds of start', function () {
const player = new GenericPlayerState(logger, [NO_DEVICE, NO_USER]);
const player = new TestPositionalPlayerState(logger, [NO_DEVICE, NO_USER]);
const positioned = clone(newPlay);
positioned.data.duration = 70;
positioned.meta.trackProgressPosition = 45;
player.setState(undefined, positioned);
player.update(testState({play: positioned, position: 45}));
positioned.meta.trackProgressPosition = 55;
player.setState(undefined, positioned, dayjs().add(10, 'seconds'));
player.currentListenRange.rtPlayer.setPosition(65000);
player.update(testState({play: positioned, position: 65}), dayjs().add(20, 'seconds'));
positioned.meta.trackProgressPosition = 65;
player.setState(undefined, positioned, dayjs().add(20, 'seconds'));
const isRepeat = player.testSessionRepeat(5, dayjs().add(20, 'seconds'));
assert.isTrue(isRepeat);
positioned.meta.trackProgressPosition = 5;
const [curr, prevPlay] = player.setState(undefined, positioned, dayjs().add(30, 'seconds'));
player.currentListenRange.rtPlayer.setPosition(67000);
const [curr, prevPlay] = player.update(testState({play: positioned, position: 5}), dayjs().add(22, 'seconds'));
assert.isDefined(prevPlay);
assert.equal(player.getListenDuration(), 0);
});
it('Detects repeat when player was within 15% of ending and seeked back to within 15% of start', function () {
const player = new GenericPlayerState(logger, [NO_DEVICE, NO_USER]);
const player = new TestPositionalPlayerState(logger, [NO_DEVICE, NO_USER]);
const positioned = clone(newPlay);
positioned.data.duration = 300;
positioned.meta.trackProgressPosition = 351;
player.setState(undefined, positioned);
positioned.meta.trackProgressPosition = 361;
player.setState(undefined, positioned, dayjs().add(10, 'seconds'));
player.update(testState({play: positioned, position: 351}));
positioned.meta.trackProgressPosition = 371;
player.setState(undefined, positioned, dayjs().add(20, 'seconds'));
player.currentListenRange.rtPlayer.setPosition(361000);
player.update(testState({play: positioned, position: 361}), dayjs().add(10, 'seconds'));
positioned.meta.trackProgressPosition = 20;
const [curr, prevPlay] = player.setState(undefined, positioned, dayjs().add(30, 'seconds'));
player.currentListenRange.rtPlayer.setPosition(371000);
player.update(testState({play: positioned, position: 371}), dayjs().add(20, 'seconds'));
const isRepeat = player.testSessionRepeat(20, dayjs().add(30, 'seconds'));
assert.isTrue(isRepeat);
player.currentListenRange.rtPlayer.setPosition(381000);
const [curr, prevPlay] = player.update(testState({play: positioned, position: 20}), dayjs().add(30, 'seconds'));
assert.isDefined(prevPlay);
assert.equal(player.getListenDuration(), 0);
});
it('Detects repeat when player is seeked to start and a heft chunk of the track has already been played', function () {
const player = new GenericPlayerState(logger, [NO_DEVICE, NO_USER]);
it('Detects repeat when player is seeked to start and a hefty chunk of the track has already been played', function () {
const player = new TestPositionalPlayerState(logger, [NO_DEVICE, NO_USER]);
const positioned = clone(newPlay);
positioned.data.duration = 70;
positioned.meta.trackProgressPosition = 0;
player.setState(undefined, positioned);
positioned.meta.trackProgressPosition = 10;
player.setState(undefined, positioned, dayjs().add(10, 'seconds'));
player.update(testState({play: positioned, position: 0}));
positioned.meta.trackProgressPosition = 20;
player.setState(undefined, positioned, dayjs().add(20, 'seconds'));
player.currentListenRange.rtPlayer.setPosition(40000);
player.update(testState({play: positioned, position: 40}), dayjs().add(40, 'seconds'));
positioned.meta.trackProgressPosition = 30;
player.setState(undefined, positioned, dayjs().add(30, 'seconds'));
positioned.meta.trackProgressPosition = 40;
player.setState(undefined, positioned, dayjs().add(40, 'seconds'));
const isRepeat = player.testSessionRepeat(2, dayjs().add(50, 'seconds'));
assert.isTrue(isRepeat);
positioned.meta.trackProgressPosition = 2;
const [curr, prevPlay] = player.setState(undefined, positioned, dayjs().add(50, 'seconds'));
player.currentListenRange.rtPlayer.setPosition(50000);
const [curr, prevPlay] = player.update(testState({play: positioned, position: 2}), dayjs().add(50, 'seconds'));
assert.isDefined(prevPlay);
assert.equal(player.getListenDuration(), 0);
+180
View File
@@ -0,0 +1,180 @@
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 { 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 validSession = validSessionResponse.object.mediaContainer.metadata[0];
const createSource = async (data: PlexApiData, authedUser: string | false = 'MyUser'): Promise<PlexApiSource> => {
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 = '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 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);
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();
});
});
});
});
+116
View File
@@ -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"
}
}
]
}
}
}
+18 -30
View File
@@ -16,12 +16,10 @@ import pathUtil from "path";
import { TimeoutError, WebapiError } from "spotify-web-api-node/src/response-error.js";
import { PlayObject } from "../core/Atomic.js";
import {
asPlayerStateData,
asPlayerStateDataMaybePlay,
NO_DEVICE,
NO_USER,
numberFormatOptions,
PlayerStateData,
PlayerStateDataMaybePlay,
PlayPlatformId,
ProgressAwarePlayObject,
@@ -365,34 +363,6 @@ export const remoteHostStr = (req: Request): string => {
return `${host}${proxy !== undefined ? ` (${proxy})` : ''}${agent !== undefined ? ` (UA: ${agent})` : ''}`;
}
export const combinePartsToString = (parts: any[], glue: string = '-'): string | undefined => {
const cleanParts: string[] = [];
for (const part of parts) {
if (part === null || part === undefined) {
continue;
}
if (Array.isArray(part)) {
const nestedParts = combinePartsToString(part, glue);
if (nestedParts !== undefined) {
cleanParts.push(nestedParts);
}
} else if (typeof part === 'object') {
// hope this works
cleanParts.push(JSON.stringify(part));
} else if(typeof part === 'string') {
if(part.trim() !== '') {
cleanParts.push(part);
}
} else {
cleanParts.push(part.toString());
}
}
if (cleanParts.length > 0) {
return cleanParts.join(glue);
}
return undefined;
}
/**
* Remove duplicates based on trackId, deviceId, and play date
* */
@@ -739,6 +709,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 = <T = unknown>(values: unknown[], options: {ofType?: string, test?: (val: T) => boolean} = {}): NonNullable<T> | 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<string>(values, {ofType: 'string', test: (v) => v.trim() !== ''});
+11 -2
View File
@@ -1,7 +1,8 @@
import { strategies, stringSameness, StringSamenessResult } from "@foxxmd/string-sameness";
import { PlayObject } from "../../core/Atomic.js";
import { DELIMITERS } from "../common/infrastructure/Atomic.js";
import { parseRegexSingleOrFail } from "../utils.js";
import { asPlayerStateData, DELIMITERS, PlayerStateDataMaybePlay } from "../common/infrastructure/Atomic.js";
import { genGroupIdStr, getPlatformIdFromData, parseRegexSingleOrFail } from "../utils.js";
import { buildTrackString } from "../../core/StringUtils.js";
const {levenStrategy, diceStrategy} = strategies;
@@ -357,3 +358,11 @@ export const firstNonEmptyStr = (vals: unknown[]): string | undefined => {
}
}
}
export const buildStatePlayerPlayIdententifyingInfo = (data: PlayObject | PlayerStateDataMaybePlay): string => {
let idInfo = genGroupIdStr(getPlatformIdFromData(data));
if(asPlayerStateData(data)) {
idInfo = buildTrackString(data.play, {include: ['artist', 'track', 'platform', 'session']});
}
return idInfo;
}
+1 -1
View File
@@ -94,7 +94,7 @@ art = {},
<p className="subtitle">{calculated !== 'stopped' ? artists.join(' / ') : '-'}</p>
</div>
<PlayerTimestamp duration={duration} current={data.position || 0} />
<PlayerTimestamp duration={duration} indeterminate={calculated === 'playing' && data.position === undefined} current={data.position || 0} />
<div className="flex">
<p className="stats flex-1 text-left">Status: {capitalize(calculated)}</p>
<p className="stats flex-1 text-right">Listened: {calculated !== 'stopped' ? `${listenedDuration.toFixed(0)}s` : '-'}{durPer}</p>
+6 -1
View File
@@ -14,6 +14,11 @@ const PlayerInfo = (props: PlayerInfoProps) => {
data,
data: {
play,
play: {
meta: {
sessionId
} = {}
} = {},
status: {
calculated,
reported
@@ -34,7 +39,7 @@ const PlayerInfo = (props: PlayerInfoProps) => {
return (
<div className={["playlist", isHidden, 'bg-gray-600'].join(' ')}>
<div className="playlist_body">
<div className="full">Player ID: <small>{data.platformId}</small></div>
<div className="full">Player ID: <small>{data.platformId}{sessionId !== undefined ? ` (Session ${sessionId})` : null}</small></div>
<div className="full">Player Updated: <small>{isoToHuman(data.playerLastUpdatedAt, {includeRelative: true})}</small></div>
<div className="full">Track Seen: <small>{isoToHuman(data.playFirstSeenAt, {includeRelative: true})}</small></div>
<div className="full">Track Updated: <small>{isoToHuman(data.playLastUpdatedAt, {includeRelative: true})}</small></div>
@@ -4,6 +4,7 @@ import './timestamp.scss';
export interface TimestampProps {
current: number
duration: number
indeterminate?: boolean
}
const convertTime = (rawTime: number) => {
@@ -19,11 +20,11 @@ const convertTime = (rawTime: number) => {
const Timestamp = (props: TimestampProps) => {
return(
<div className="timestamp">
<div className="timestamp__current">
{convertTime(Math.floor(props.current))}
<div className="timestamp__current" style={{left: props.indeterminate ? '1em' : '0'}}>
{props.indeterminate ? '-' : convertTime(Math.floor(props.current))}
</div>
<div className="timestamp__progress">
<div style={{ width: (props.current === 0 && props.duration === 0 ? 0 : Math.floor((props.current / props.duration) * 100)) + "%" }}></div>
<div className={props.indeterminate ? 'indeterminate' : ''} style={{ width: props.indeterminate ? '100%' : (props.current === 0 && props.duration === 0 ? 0 : Math.floor((props.current / props.duration) * 100)) + "%" }}></div>
</div>
<div className="timestamp__total">
{convertTime(Math.floor(props.duration) - Math.floor(props.current))}
@@ -31,32 +32,5 @@ const Timestamp = (props: TimestampProps) => {
</div>
);
}
/*export class TimestampC extends React.Component {
convertTime(time) {
let mins = Math.floor(time / 60);
let seconds = time - (mins * 60);
if (seconds < 10) {
seconds = "0" + seconds;
}
time = mins + ":" + seconds;
return time;
}
render() {
return(
<div className="timestamp">
<div className="timestamp__current">
{this.convertTime(this.props.current)}
</div>
<div className="timestamp__progress">
<div style={{ width: Math.floor((this.props.current / this.props.duration) * 100) + "%" }}></div>
</div>
<div className="timestamp__total">
{this.convertTime(this.props.duration - this.props.current)}
</div>
</div>
);
}
}*/
export default Timestamp;
@@ -34,6 +34,13 @@ $primary: #556a77;
bottom: 0;
background: $primary;
}
> div.indeterminate {
background-color: #ECEFF1;
animation: indeterminateAnimation 3s infinite linear;
transform-origin: 0% 50%;
background: $primary;
}
}
&__current {
@@ -44,3 +51,15 @@ $primary: #556a77;
right: 0;
}
}
@keyframes indeterminateAnimation {
0% {
transform: translateX(0) scaleX(0);
}
50% {
transform: translateX(0) scaleX(0.5);
}
100% {
transform: translateX(100%) scaleX(0.5);
}
}
+12 -2
View File
@@ -31,7 +31,7 @@ export interface ClientStatusData {
initialized: boolean;
}
export type PlayObjectIncludeTypes = 'album' | 'time' | 'artist' | 'track' | 'timeFromNow' | 'trackId' | 'comment' | 'platform';
export type PlayObjectIncludeTypes = 'album' | 'time' | 'artist' | 'track' | 'timeFromNow' | 'trackId' | 'comment' | 'platform' | 'session';
export const recentIncludes: PlayObjectIncludeTypes[] = ['time', 'timeFromNow', 'track', 'album', 'artist', 'comment'];
export interface TrackStringOptions<T = string> {
@@ -43,7 +43,7 @@ export interface TrackStringOptions<T = string> {
time?: (t: Dayjs, i?: ScrobbleTsSOC) => T | string
timeFromNow?: (t: Dayjs) => T | string
comment?: (c: string | undefined) => T | string
platform?: (d: string | undefined, u: string | undefined) => T | string
platform?: (d: string | undefined, u: string | undefined, s: string | undefined) => T | string
reducer?: (arr: (T | string)[]) => T //(acc: T, curr: T | string) => T
}
}
@@ -54,6 +54,10 @@ export interface PlayProgress {
positionPercent?: number
}
export interface PlayProgressPositional extends PlayProgress {
position: number
}
export interface ListenRangeData {
start: ListenProgress
end: ListenProgress
@@ -148,6 +152,8 @@ export interface PlayMeta {
* A unique identifier for the device playing this track
* */
deviceId?: string
/** The ID/Key for individual sessions on a device/platform */
sessionId?: string
nowPlaying?: boolean
@@ -168,6 +174,10 @@ export interface AmbPlayObject {
meta: PlayMeta
}
export const isPlayObject = (obj: object): obj is PlayObject => {
return 'data' in obj && typeof obj.data === 'object' && 'meta' in obj && typeof obj.meta === 'object';
}
export interface PlayObject extends AmbPlayObject {
data: ObjectPlayData,
}
+33 -3
View File
@@ -38,7 +38,7 @@ export const defaultTimeFunc = (t: Dayjs | undefined, i?: ScrobbleTsSOC) => t ==
export const defaultTimeFromNowFunc = (t: Dayjs | undefined) => t === undefined ? undefined : `(${t.local().fromNow()})`;
export const defaultCommentFunc = (c: string | undefined) => c === undefined ? undefined : `(${c})`;
// TODO replace with genGroupIdStr and refactor Platform types/etc. into core Atomic
export const defaultPlatformFunc = (d: string | undefined, u: string | undefined) => `${d ?? 'NoDevice'}-${u ?? 'SingleUser'}`;
export const defaultPlatformFunc = (d: string | undefined, u: string | undefined, s: string | undefined) => combinePartsToString([d ?? 'NoDevice', u ?? 'SingleUser',s !== undefined ? `Session${s}` : undefined]);
export const defaultBuildTrackStringTransformers = {
artists: defaultArtistFunc,
track: defaultTrackTransformer,
@@ -75,7 +75,8 @@ export const buildTrackString = <T = string>(playObj: AmbPlayObject, options: Tr
scrobbleTsSOC = SCROBBLE_TS_SOC_START,
comment,
deviceId,
user
user,
sessionId
} = {},
} = playObj;
@@ -90,7 +91,9 @@ export const buildTrackString = <T = string>(playObj: AmbPlayObject, options: Tr
const strParts: (T | string)[] = [];
if(include.includes('platform')) {
strParts.push(platformFunc(deviceId, user))
strParts.push(platformFunc(deviceId, user, include.includes('session') ? sessionId : undefined))
} else if(include.includes('session') && sessionId !== undefined) {
strParts.push(`(Session ${sessionId})`);
}
if (include.includes('trackId') && trackId !== undefined) {
strParts.push(`(${trackId})`);
@@ -166,3 +169,30 @@ export const nonEmptyStringOrDefault = <T>(str: any, defaultVal: T = undefined):
}
return str;
}
export const combinePartsToString = (parts: any[], glue: string = '-'): string | undefined => {
const cleanParts: string[] = [];
for (const part of parts) {
if (part === null || part === undefined) {
continue;
}
if (Array.isArray(part)) {
const nestedParts = combinePartsToString(part, glue);
if (nestedParts !== undefined) {
cleanParts.push(nestedParts);
}
} else if (typeof part === 'object') {
// hope this works
cleanParts.push(JSON.stringify(part));
} else if (typeof part === 'string') {
if (part.trim() !== '') {
cleanParts.push(part);
}
} else {
cleanParts.push(part.toString());
}
}
if (cleanParts.length > 0) {
return cleanParts.join(glue);
}
return undefined;
}