mirror of
https://github.com/FoxxMD/multi-scrobbler.git
synced 2026-09-03 05:10:00 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
904ddfa5c6 | ||
|
|
3ed6840607 | ||
|
|
93125dd7c8 | ||
|
|
61cd5fcf7b | ||
|
|
f308f71b5d | ||
|
|
c498bde1fd | ||
|
|
0c9b496137 | ||
|
|
9682d4f8f9 | ||
|
|
da76945f5e | ||
|
|
51074a58f4 | ||
|
|
3cc735b9c8 | ||
|
|
b74f31c26d | ||
|
|
4a96cbfb0e | ||
|
|
77a3c352b1 | ||
|
|
598ae01082 | ||
|
|
54e11899a0 | ||
|
|
dc4962ade1 | ||
|
|
ccf317c06f |
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -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.
|
||||
|
||||
Generated
+28
-17
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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;
|
||||
@@ -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)`);
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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, '');
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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() !== ''});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user