Merge pull request #472 from FoxxMD/discordPresence

feat: Discord rich presence
This commit is contained in:
Matt Foxx
2026-02-17 16:35:30 -05:00
committed by GitHub
34 changed files with 1897 additions and 102 deletions
+1
View File
@@ -0,0 +1 @@
* Attribution for [`default-artwork.png`](/assets/default-artwork.png): https://www.flaticon.com/free-icon/music_15795471
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

+11
View File
@@ -0,0 +1,11 @@
[
{
"name": "MS",
"enable": true,
"data": {
"token": "Ma7XBvzRERK3gQdkX4dkb3OQ.6aaGmJ.rvFiiVrzG4VXHHL3LyhZYhpWdG3Ph_if9aKz5E",
"applicationId": "8190211179716453570"
}
}
]
@@ -12,6 +12,7 @@ A **Client** is an application that stores the historical information about what
| Name | Now Playing |
| :-------------------------------------------------- | :---------- |
| [Discord](/configuration/clients/discord) | ✅ |
| [Last.fm](/configuration/clients/lastfm) | ✅ |
| [Libre.fm](/configuration/clients/librefm) | ✅ |
| [Listenbrainz](/configuration/clients/listenbrainz) | ✅ |
@@ -0,0 +1,319 @@
---
title: Discord
toc_min_heading_level: 2
toc_max_heading_level: 5
---
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
import CodeBlock from '@theme/CodeBlock';
import JsonConfig from '!!raw-loader!@site/../config/discord.json.example';
This scrobbler uses **[Now Playing](/configuration/clients#now-playing)** functionality to set your Discord [Rich Presence](https://docs.discord.com/developers/rich-presence/overview) to the music you are currently monitoring with multi-scrobbler.
<img src={require('/img/discord_presence_art.png').default} height="127"/>
:::warning[Discord TOS Violation]
This functionality requires you to use your own **User Token** in a way that is **against Discord's policies/terms of service.** If you use this scrobbler you do so **at your risk**.
Refer to the **How MS Uses Discord API** section below to gauge whether this risk is acceptable to you.
:::
<DetailsAdmo type="note" summary="How MS Uses Discord API">
Multi-scrobbler does its upmost to implement, to-spec, [Gateway API communication](https://docs.discord.com/developers/events/gateway) and respect usage. It requires no [permissions](https://docs.discord.com/developers/events/gateway#gateway-intents) and reads no data other than monitoring what other user sessions are active.
Aside from the on-paper violation of using a user token programmatically (on your behalf), there is no misuse of the API to achieve rich presence.
Additionally, MS uses the state of non-MS sessions to conservatively update presence. [By default it will not conflict](#configure-when-presence-is-used) with any other official presence activities and is only used when you are actually active on a device.
</DetailsAdmo>
<DetailsAdmo type="note" summary="REQUIRED Interaction after Starting Multi-Scrobbler">
Every time multi-scrobbler is started a discord session update needs to be triggered so that [MS can capture the signals it uses to determine if presence can be updated.](#configure-when-presence-is-used) This only needs to be *once*, after starting multi-scrobbler. But it does need to be *every time* multi-scrobbler is (re)started. Without these signals MS will not update presence.
This session update can happen automatically after *some* time so you may not need to do anything. If you want to force the update then do one of the following:
* Open discord on a device it was not recently active on
* Exit discord on an active device
* Change your status (online, idle, etc...)
* Set a custom status
* Cause a presence update with a different app (listening to..., playing..., etc.)
</DetailsAdmo>
## Required Setup
### User Token
You must provide a User Token for this scrobbler to work.
No instructions will be provided for obtaining a User Token because it is against Discord's policies to use it. Instead, you can search online for "how to obtain discord user token".
:::warning
Treat your User Token like a login credential. Anyone who has your token can access Discord as you.
When finding instructions for obtaining this token only run commands, or follow steps, that you can understand and trust. Do not run commands you do not understand. Never give your token to someone else.
:::
## Optional Setup
### Configure When Presence Is Used
MS uses several signals from your "real" Discord sessions to determine if it should update presence. The default configuration is **extremely** conservative to ensure that MS does not conflict with other apps using presence. Additionally, it only updates if a "real" user device is active on discord.
:::tip[TLDR]
Without any additional configuration, MS will only update presence if:
* you have discord open on a real device
* your status is either: online, idle, or dnd (not invisible)
* no other apps are broadcasting presence (no other listening, playing, competing, etc... activities on your profile)
:::
#### Online Status
Configure if MS is allowed to update presence based on your online status. If this setting is not defined MS will only update if your status is **online**, **idle**, or **dnd**.
Allowed Values: `online` `idle` `dnd` `invisible`
<details>
<summary>Example</summary>
Only allow presence updating when all of your "real" sessions are either online or idle.
In File/AIO:
```json
[
{
"name": "MS",
"enable": true,
"data": {
"token": "Ma7XBvzRERK3gQdkX4dkb3OQ.6aaGmJ.rvFiiVrzG4VXHHL3LyhZYhpWdG3Ph_if9aKz5E",
"applicationId": "8190211179716453570",
"statusOverrideAllow": ["online", "idle"]
}
}
]
```
In ENV:
```
DISCORD_STATUS_OVERRIDE_ALLOW=online,idle
```
</details>
#### Activity Type
Configure the activity types of **other** activities that MS is allowed to broadcast at the same time as. If this setting is not defined MS will only update if no other activities or only `custom`.
Note: Discord shows both `custom` and other non `custom` activities at the same time. All other activities are mutually exclusive.
Allowed Values:
* `playing` `streaming` `listening` `custom` `competing`
* `true` => allow presence during *any*
* `false` => allow presence during *none*
<details>
<summary>Example</summary>
You want to allow MS to update presence when you are normally playing any game but not when any other activity (streaming, listening, competing) is happening.
In File/AIO:
```json
[
{
"name": "MS",
"enable": true,
"data": {
"token": "Ma7XBvzRERK3gQdkX4dkb3OQ.6aaGmJ.rvFiiVrzG4VXHHL3LyhZYhpWdG3Ph_if9aKz5E",
"applicationId": "8190211179716453570",
"activitiesOverrideAllow": ["playing", "custom"]
}
}
]
```
In ENV:
```
DISCORD_ACTIVITIES_OVERRIDE_ALLOW=playing,custom
```
___
You **do not** want to allow MS to update presence if **any** other activity is occurring, including `custom`.
```json
[
{
"name": "MS",
"enable": true,
"data": {
"token": "Ma7XBvzRERK3gQdkX4dkb3OQ.6aaGmJ.rvFiiVrzG4VXHHL3LyhZYhpWdG3Ph_if9aKz5E",
"applicationId": "8190211179716453570",
"activitiesOverrideAllow": false
}
}
]
```
In ENV:
```
DISCORD_ACTIVITIES_OVERRIDE_ALLOW=false
```
</details>
#### Activity Name
Configure the names of **other** activities that MS is **not** allowed to broadcast at the same time as.
It is not required to use [Activity Type](#activity-type) with this setting, but it is useful.
<details>
<summary>Example</summary>
You want to allow MS to update presence when you are normally playing any game but not when any other activity (streaming, listening, competing) is happening. You also do not want to allow MS to update presence if the game is fortnite.
In File/AIO:
```json
[
{
"name": "MS",
"enable": true,
"data": {
"token": "Ma7XBvzRERK3gQdkX4dkb3OQ.6aaGmJ.rvFiiVrzG4VXHHL3LyhZYhpWdG3Ph_if9aKz5E",
"applicationId": "8190211179716453570",
"activitiesOverrideAllow": ["playing", "custom"],
"applicationsOverrideDisallow": ["fortnite"]
}
}
]
```
In ENV:
```
DISCORD_ACTIVITIES_OVERRIDE_ALLOW=playing,custom
DISCORD_APPNAME_OVERRIDE_DISALLOW=fortnite
```
</details>
### Discord Application
Creating a [Discord App](https://docs.discord.com/developers/quick-start/overview-of-apps) enables you to use custom album cover art and secondary status images for more links. Creating an App is free and simple, it is recommended you do this to get the most functionality of our Rich Presence.
<details>
<summary>Create An Application</summary>
Go to the [Discord Developer Portal](https://discord.com/developers/applications)
* Click on "New Application" and give it a name
* Copy the **Application Id** (Client Id) shown after creation
* Add this Id to your Multi-Scrobbler config
* Env Config => `DISCORD_APPLICATION_ID=12345678`
* File Config => `"applicationId": "12345678"`
</details>
The created application is used to enable the features/config below:
* [Artwork](#artwork)
### Artwork
For some Sources, MS parses album art and displays it in the dashboard. This image can be displayed on Discord alongside your listening status.
:::note
If no [**Application Id**](#discord-application) is configured the status art will **always** be the [Discord Default](./?artworkUrl=discord-default#artwork-url-types).
:::
#### Artwork URL Types
<Tabs groupId="artworkUrl" queryString>
<TabItem label="Discord Default" value="discord-default">
When [**Application Id**](#discord-application) is **not** configured Discord will show its own default image. No custom artwork can be used in this configuration.
<img src={require('/img/discord_presence_discord_default.png').default} height="127"/>
</TabItem>
<TabItem label="MS Default" value="ms-default">
When [**Application Id**](#discord-application) **is** configured and any of these conditions is true:
* Play does not contain artwork information (no artwork shown in dashboard)
* or artwork url is not a known music service domain and...
* `artwork` is set to false or **not defined**
* or `artwork` is a list of custom domains keywords and external artwork url does not contain any of these keywords
* or external artwork url is `http`
The status artwork will use a default URL instead of your artwork URL. This is to preserve *your* privacy because Discord does not host status images, it only links to them and the URL is visible to other users.
Without any other configuration, the default URL is for the the MS logo:
<img src={require('/img/discord_presence_ms_default.png').default} height="127"/>
This default image can be customized:
* ENV Config => `DISCORD_ARTWORK_DEFAULT_URL=https://cooldomain.com/art.png`
* File Config => `artworkDefaultUrl`
</TabItem>
<TabItem label="External URLs" value="external">
When [**Application Id**](#discord-application) **is** configured MS can use external URLS for artwork.
External URLs that are not from known music services are **disabled by default.** This is to preserve *your* privacy because Discord does not host status images, it only links to them and the URL is visible to other users.
<details>
<summary>Why Do I Care?</summary>
There are some scenarios where you may not want your artwork URLs to be visible, for example:
* A Source may be accessible only on your LAN (Jellyfin at `http://192.168.0.101`)
* A Source may be internet-facing but you do not want to expose this information to discord/other users
* A Source may be internet-facing but requires authentication to view the image
</details>
Regardless of the config below MS will **never** use an external URL if it it not `https`.
MS will use your artwork URLs when:
* `artwork` (`DISCORD_ARTWORK`) is `true`
* `artwork` is a list of custom domains keywords and external artwork url contains any of these keywords like `DISCORD_ARTWORK=mycdn,jellyfin` (or in file as `"artwork": ["mycdn","jellyfin"]`)
<img src={require('/img/discord_presence_art.png').default} height="127"/>
</TabItem>
</Tabs>
## Configuration
<Config config="DiscordClientConfig" fileContent={JsonConfig} client name="discord">
| Environmental Variable | Required? | Default | Description |
| :---------------------------------- | --------- | ----------------- | :--------------------------------------------------------------------------------------------------------------- |
| `DISCORD_TOKEN` | Yes | | User Token acquired from an active Discord sessions |
| `DISCORD_APPLICATION_ID` | No | | Application ID used to display album art |
| `DISCORD_ARTWORK` | No | | A boolean indicating if external artwork URLs should be used. Or a comma-separated list of allowed domains |
| `DISCORD_ARTWORK_DEFAULT_URL` | No | | A URL of an image to use as a fallback if the album art URL cannot be used. Or `false` to use discord's default. |
| `DISCORD_STATUS_OVERRIDE_ALLOW` | No | `online,idle,dnd` | A comma-separated list of statuses are allowed to have presence. |
| `DISCORD_ACTIVITIES_OVERRIDE_ALLOW` | No | `custom` | A comma-seperated list of *other* activity types MS can broadcast presence at the same time as. |
| `DISCORD_APPNAME_OVERRIDE_DISALLOW` | No | | A commera-seperated list of activity names MS is *not* allowed to broadcast presence at the same time as. |
</Config>
+1
View File
@@ -42,6 +42,7 @@ A dockerized app that monitors your music listening activity from *everywhere* a
* [Yamaha MusicCast](/configuration/sources/yamaha-musiccast)
* [Youtube Music](/configuration/sources/youtube-music)
* Supports scrobbling to many [**Clients**](/configuration/clients)
* [Discord](/configuration/clients/discord) (Now Playing)
* [Koito](/configuration/clients/koito)
* [Last.fm](/configuration/clients/lastfm)
* [Libre.fm](/configuration/clients/librefm)
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

+259 -3
View File
@@ -53,6 +53,7 @@
"cors": "^2.8.5",
"dayjs": "^1.10.4",
"dbus-ts": "^0.0.7",
"discord.js": "^14.25.1",
"dotenv": "^10.0.0",
"express": "^4.17.1",
"express-session": "^1.17.2",
@@ -1073,6 +1074,154 @@
"@dbus-types/dbus": "^0.0.4"
}
},
"node_modules/@discordjs/builders": {
"version": "1.13.1",
"resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.13.1.tgz",
"integrity": "sha512-cOU0UDHc3lp/5nKByDxkmRiNZBpdp0kx55aarbiAfakfKJHlxv/yFW1zmIqCAmwH5CRlrH9iMFKJMpvW4DPB+w==",
"license": "Apache-2.0",
"dependencies": {
"@discordjs/formatters": "^0.6.2",
"@discordjs/util": "^1.2.0",
"@sapphire/shapeshift": "^4.0.0",
"discord-api-types": "^0.38.33",
"fast-deep-equal": "^3.1.3",
"ts-mixer": "^6.0.4",
"tslib": "^2.6.3"
},
"engines": {
"node": ">=16.11.0"
},
"funding": {
"url": "https://github.com/discordjs/discord.js?sponsor"
}
},
"node_modules/@discordjs/collection": {
"version": "1.5.3",
"resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-1.5.3.tgz",
"integrity": "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=16.11.0"
}
},
"node_modules/@discordjs/formatters": {
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.6.2.tgz",
"integrity": "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ==",
"license": "Apache-2.0",
"dependencies": {
"discord-api-types": "^0.38.33"
},
"engines": {
"node": ">=16.11.0"
},
"funding": {
"url": "https://github.com/discordjs/discord.js?sponsor"
}
},
"node_modules/@discordjs/rest": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.6.0.tgz",
"integrity": "sha512-RDYrhmpB7mTvmCKcpj+pc5k7POKszS4E2O9TYc+U+Y4iaCP+r910QdO43qmpOja8LRr1RJ0b3U+CqVsnPqzf4w==",
"license": "Apache-2.0",
"dependencies": {
"@discordjs/collection": "^2.1.1",
"@discordjs/util": "^1.1.1",
"@sapphire/async-queue": "^1.5.3",
"@sapphire/snowflake": "^3.5.3",
"@vladfrangu/async_event_emitter": "^2.4.6",
"discord-api-types": "^0.38.16",
"magic-bytes.js": "^1.10.0",
"tslib": "^2.6.3",
"undici": "6.21.3"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/discordjs/discord.js?sponsor"
}
},
"node_modules/@discordjs/rest/node_modules/@discordjs/collection": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz",
"integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==",
"license": "Apache-2.0",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/discordjs/discord.js?sponsor"
}
},
"node_modules/@discordjs/rest/node_modules/undici": {
"version": "6.21.3",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.21.3.tgz",
"integrity": "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==",
"license": "MIT",
"engines": {
"node": ">=18.17"
}
},
"node_modules/@discordjs/util": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.2.0.tgz",
"integrity": "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==",
"license": "Apache-2.0",
"dependencies": {
"discord-api-types": "^0.38.33"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/discordjs/discord.js?sponsor"
}
},
"node_modules/@discordjs/ws": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@discordjs/ws/-/ws-1.2.3.tgz",
"integrity": "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw==",
"license": "Apache-2.0",
"dependencies": {
"@discordjs/collection": "^2.1.0",
"@discordjs/rest": "^2.5.1",
"@discordjs/util": "^1.1.0",
"@sapphire/async-queue": "^1.5.2",
"@types/ws": "^8.5.10",
"@vladfrangu/async_event_emitter": "^2.2.4",
"discord-api-types": "^0.38.1",
"tslib": "^2.6.2",
"ws": "^8.17.0"
},
"engines": {
"node": ">=16.11.0"
},
"funding": {
"url": "https://github.com/discordjs/discord.js?sponsor"
}
},
"node_modules/@discordjs/ws/node_modules/@discordjs/collection": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz",
"integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==",
"license": "Apache-2.0",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/discordjs/discord.js?sponsor"
}
},
"node_modules/@discordjs/ws/node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@dmsnell/diff-match-patch": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@dmsnell/diff-match-patch/-/diff-match-patch-1.1.0.tgz",
@@ -2812,6 +2961,39 @@
"win32"
]
},
"node_modules/@sapphire/async-queue": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz",
"integrity": "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==",
"license": "MIT",
"engines": {
"node": ">=v14.0.0",
"npm": ">=7.0.0"
}
},
"node_modules/@sapphire/shapeshift": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@sapphire/shapeshift/-/shapeshift-4.0.0.tgz",
"integrity": "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"lodash": "^4.17.21"
},
"engines": {
"node": ">=v16"
}
},
"node_modules/@sapphire/snowflake": {
"version": "3.5.3",
"resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.3.tgz",
"integrity": "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ==",
"license": "MIT",
"engines": {
"node": ">=v14.0.0",
"npm": ">=7.0.0"
}
},
"node_modules/@sec-ant/readable-stream": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz",
@@ -4193,6 +4375,16 @@
"vite": "^4.2.0 || ^5.0.0"
}
},
"node_modules/@vladfrangu/async_event_emitter": {
"version": "2.4.7",
"resolved": "https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.4.7.tgz",
"integrity": "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==",
"license": "MIT",
"engines": {
"node": ">=v14.0.0",
"npm": ">=7.0.0"
}
},
"node_modules/@yarnpkg/lockfile": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz",
@@ -5676,6 +5868,51 @@
"node": ">=8"
}
},
"node_modules/discord-api-types": {
"version": "0.38.39",
"resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.39.tgz",
"integrity": "sha512-XRdDQvZvID1XvcFftjSmd4dcmMi/RL/jSy5sduBDAvCGFcNFHThdIQXCEBDZFe52lCNEzuIL0QJoKYAmRmxLUA==",
"license": "MIT",
"workspaces": [
"scripts/actions/documentation"
]
},
"node_modules/discord.js": {
"version": "14.25.1",
"resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.25.1.tgz",
"integrity": "sha512-2l0gsPOLPs5t6GFZfQZKnL1OJNYFcuC/ETWsW4VtKVD/tg4ICa9x+jb9bkPffkMdRpRpuUaO/fKkHCBeiCKh8g==",
"license": "Apache-2.0",
"dependencies": {
"@discordjs/builders": "^1.13.0",
"@discordjs/collection": "1.5.3",
"@discordjs/formatters": "^0.6.2",
"@discordjs/rest": "^2.6.0",
"@discordjs/util": "^1.2.0",
"@discordjs/ws": "^1.2.3",
"@sapphire/snowflake": "3.5.3",
"discord-api-types": "^0.38.33",
"fast-deep-equal": "3.1.3",
"lodash.snakecase": "4.1.1",
"magic-bytes.js": "^1.10.0",
"tslib": "^2.6.3",
"undici": "6.21.3"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/discordjs/discord.js?sponsor"
}
},
"node_modules/discord.js/node_modules/undici": {
"version": "6.21.3",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.21.3.tgz",
"integrity": "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==",
"license": "MIT",
"engines": {
"node": ">=18.17"
}
},
"node_modules/doctrine": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
@@ -8978,6 +9215,12 @@
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
"dev": true
},
"node_modules/lodash.snakecase": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz",
"integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==",
"license": "MIT"
},
"node_modules/log-symbols": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz",
@@ -9045,6 +9288,12 @@
"lz-string": "bin/bin.js"
}
},
"node_modules/magic-bytes.js": {
"version": "1.13.0",
"resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.13.0.tgz",
"integrity": "sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg==",
"license": "MIT"
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -12723,6 +12972,12 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/ts-mixer": {
"version": "6.0.4",
"resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz",
"integrity": "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==",
"license": "MIT"
},
"node_modules/tslib": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.0.tgz",
@@ -13793,9 +14048,10 @@
"peer": true
},
"node_modules/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w==",
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
+1
View File
@@ -88,6 +88,7 @@
"cors": "^2.8.5",
"dayjs": "^1.10.4",
"dbus-ts": "^0.0.7",
"discord.js": "^14.25.1",
"dotenv": "^10.0.0",
"express": "^4.17.1",
"express-session": "^1.17.2",
+7 -3
View File
@@ -113,7 +113,8 @@ export type ClientType =
| 'listenbrainz'
| 'koito'
| 'tealfm'
| 'rocksky';
| 'rocksky'
| 'discord';
export const clientTypes: ClientType[] = [
'maloja',
'lastfm',
@@ -121,7 +122,8 @@ export const clientTypes: ClientType[] = [
'listenbrainz',
'koito',
'tealfm',
'rocksky'
'rocksky',
'discord'
];
export const clientInterfaces = [
@@ -132,7 +134,8 @@ export const clientInterfaces = [
'ListenBrainzClientConfig',
'KoitoClientConfig',
'TealClientConfig',
'RockSkyClientConfig'
'RockSkyClientConfig',
'DiscordClientConfig'
];
export const isClientType = (data: string): data is ClientType => {
@@ -230,6 +233,7 @@ export interface ProgressAwarePlayObject extends PlayObject {
export type DeviceId = string;
export type PlayUserId = string;
export type PlayPlatformId = [DeviceId, PlayUserId];
export type PlayPlatformIdStr = string;
export type GroupedPlays = TupleMap<DeviceId,PlayUserId,ProgressAwarePlayObject[]>;
@@ -5,7 +5,8 @@ import { MalojaClientAIOConfig, MalojaClientConfig } from "./maloja.js";
import { TealClientAIOConfig, TealClientConfig } from "./tealfm.js";
import { RockSkyClientAIOConfig, RockSkyClientConfig } from "./rocksky.js";
import { LibrefmClientConfig, LibrefmClientAIOConfig } from "./librefm.js";
import { DiscordClientAIOConfig, DiscordClientConfig } from "./discord.js";
export type ClientConfig = MalojaClientConfig | LastfmClientConfig | LibrefmClientConfig | ListenBrainzClientConfig | KoitoClientConfig | TealClientConfig | RockSkyClientConfig;
export type ClientConfig = MalojaClientConfig | LastfmClientConfig | LibrefmClientConfig | ListenBrainzClientConfig | KoitoClientConfig | TealClientConfig | RockSkyClientConfig | DiscordClientConfig;
export type ClientAIOConfig = MalojaClientAIOConfig | LastfmClientAIOConfig | LibrefmClientAIOConfig | ListenBrainzClientAIOConfig | KoitoClientAIOConfig | TealClientAIOConfig | RockSkyClientAIOConfig;
export type ClientAIOConfig = MalojaClientAIOConfig | LastfmClientAIOConfig | LibrefmClientAIOConfig | ListenBrainzClientAIOConfig | KoitoClientAIOConfig | TealClientAIOConfig | RockSkyClientAIOConfig | DiscordClientAIOConfig;
@@ -0,0 +1,40 @@
import { CommonClientConfig, CommonClientData } from "./index.js"
export interface DiscordData {
token: string
applicationId?: string
artwork?: boolean | string | string[]
artworkDefaultUrl?: string | boolean
statusOverrideAllow?: string | StatusType[]
activitiesOverrideAllow?: boolean | string | ActivityType[]
applicationsOverrideDisallow?: string | string[]
}
export interface DiscordClientData extends DiscordData, CommonClientData {}
export interface DiscordClientConfig extends CommonClientConfig {
/**
* Should always be `client` when using Koito as a client
*
* @default client
* @examples ["client"]
* */
configureAs?: 'client' | 'source'
data: DiscordClientData
}
export interface DiscordClientAIOConfig extends DiscordClientConfig {
type: 'discord'
}
export type ActivityType = 'playing' | 'streaming' | 'listening' | 'watching' | 'custom' | 'competing';
export const ActivityTypes: ActivityType[] = ['playing','streaming','listening','watching','custom','competing'];
export type StatusType = 'online' | 'idle' | 'dnd' | 'invisible';
export interface DiscordStrongData extends DiscordData {
artwork?: boolean | string[]
artworkDefaultUrl?: string | false
statusOverrideAllow?: StatusType[]
activitiesOverrideAllow?: ActivityType[]
applicationsOverrideDisallow?: string[]
}
+17
View File
@@ -819,6 +819,23 @@ export const musicServiceToCononical = (str?: string): string | undefined => {
return undefined;
}
/**
* Returns a known music service based on the given URL
* @see https://listenbrainz.readthedocs.io/en/latest/users/json.html#payload-json-details
* */
export const urlToMusicService = (url?: string): string | undefined => {
if(url === undefined) {
return undefined;
}
const lower = url.trim().toLocaleLowerCase();
for(const [k, v] of Object.entries(musicServices)) {
if(url.includes(v)) {
return k;
}
}
return undefined;
}
export const playToSubmitPayload = (play: PlayObject, options: SubmitOptions = {}): SubmitPayload => {
const { listenType = 'single'} = options;
const listenPayload: SubmitPayload = {listen_type: listenType, payload: [playToListenPayload(play)]};
+878
View File
@@ -0,0 +1,878 @@
import { childLogger } from "@foxxmd/logging";
import { WS } from 'iso-websocket'
import { DiscordClientData, DiscordData, DiscordStrongData, StatusType, ActivityType as MSActivityType, ActivityTypes } from "../../infrastructure/config/client/discord.js";
import { _DataPayload, _NonDispatchPayload, ActivityType, APIUser, GatewayActivity, GatewayActivityButton, GatewayActivityUpdateData, GatewayCloseCodes, GatewayDispatchEvents, GatewayHeartbeatRequest, GatewayHelloData, GatewayIdentify, GatewayIdentifyData, GatewayInvalidSessionData, GatewayOpcodes, GatewayPresenceUpdateData, GatewayReadyDispatchData, GatewayResumeData, GatewayUpdatePresence, PresenceUpdateStatus } from "discord.js";
import { isDebugMode, parseBool, removeUndefinedKeys, sleep } from "../../../utils.js";
import pEvent from 'p-event';
import EventEmitter from "events";
import { randomInt } from "crypto";
import request from 'superagent';
import AbstractApiClient from "../AbstractApiClient.js";
import { AbstractApiOptions, asPlayerStateData, SourceData } from "../../infrastructure/Atomic.js";
import { isPlayObject, PlayObject } from "../../../../core/Atomic.js";
import dayjs from "dayjs";
import { capitalize } from "../../../../core/StringUtils.js";
import { parseArrayFromMaybeString, parseBoolOrArrayFromMaybeString } from "../../../utils/StringUtils.js";
import { getRoot } from "../../../ioc.js";
import { MSCache } from "../../Cache.js";
import { isSuperAgentResponseError } from "../../errors/ErrorUtils.js";
import { urlToMusicService } from "../ListenbrainzApiClient.js";
import { fa } from "@faker-js/faker";
import { urlContainsKnownMediaDomain } from "../../../utils/RequestUtils.js";
const ARTWORK_PLACEHOLDER = 'https://raw.githubusercontent.com/FoxxMD/multi-scrobbler/master/assets/default-artwork.png';
const MS_ART = 'https://raw.githubusercontent.com/FoxxMD/multi-scrobbler/master/assets/icon.png';
const API_GATEWAY_ENDPOINT = 'https://discord.com/api/gateway';
/**
* Implementation largely based on
*
* https://github.com/n0thhhing/Discord-rich-presence
* https://github.com/logixism/navicord
*
* Existing implementations of Rich Presence all use the local RPC gateway from a running Discord app
* and the impl that actually uses the remote gateway, @discord/ws, is only built for bot use
* so we need to roll our own Gateway API interface https://docs.discord.com/developers/events/gateway
*
*/
export class DiscordWSClient extends AbstractApiClient {
declare config: DiscordStrongData;
heartbeatInterval: NodeJS.Timeout
acknowledged: boolean = true;
// https://docs.discord.com/developers/events/gateway#ready-event
// used for resuming session, if possible
session_id: string;
resume_gateway_url: string;
sequence: number;
initialGatewayUrl?: string;
user: APIUser;
declare client: WS;
canReconnect?: boolean = false;
ready: boolean = false;
authOK?: boolean
closeEvents: number = 0;
lastActiveStatus?: PresenceUpdateStatus = PresenceUpdateStatus.Offline;
lastActivities: GatewayActivity[] = [];
activityTimeout: NodeJS.Timeout;
emitter: EventEmitter;
cache: MSCache;
artFail: boolean = false;
artFailCount = 0;
constructor(name: any, config: DiscordStrongData, options: AbstractApiOptions) {
super('Discord', name, config, options);
this.logger = childLogger(options.logger, 'WS Gateway');
this.emitter = new EventEmitter();
this.cache = getRoot().items.cache();
}
initClient = async () => {
let baseUrl: string;
if (this.resume_gateway_url !== undefined) {
baseUrl = this.resume_gateway_url;
} else {
if (this.initialGatewayUrl === undefined) {
try {
await this.fetchGatewayUrl();
baseUrl = this.initialGatewayUrl;
} catch (e) {
throw new Error('Could not get initial gateway url', { cause: e });
}
} else {
baseUrl = this.initialGatewayUrl;
}
}
const gatewayUrl = `${baseUrl}?encoding=json&v=10`;
this.logger.debug(`Using Gateway URL ${gatewayUrl}`);
this.client = new WS(gatewayUrl, {
automaticOpen: false,
retry: {
retries: 0
}
});
this.client.addEventListener('retry', (e) => {
this.logger.verbose(`Retrying connection, attempt ${e.attempt}`);
});
this.client.addEventListener('close', async (e) => {
// should receive a close code https://docs.discord.com/developers/topics/opcodes-and-status-codes#gateway-gateway-close-event-codes
// which determines if reconnect is possible
this.logger.warn(`Connection was closed: ${e.code} => ${e.reason}`);
if ([
GatewayCloseCodes.AuthenticationFailed,
GatewayCloseCodes.InvalidShard,
GatewayCloseCodes.ShardingRequired,
GatewayCloseCodes.InvalidAPIVersion,
GatewayCloseCodes.InvalidIntents,
GatewayCloseCodes.DisallowedIntents
].includes(e.code)) {
this.canReconnect = false;
}
if (GatewayCloseCodes.AuthenticationFailed === e.code) {
await this.cleanupConnection();
this.authOK = false;
this.emitter.emit('stopped', { authFailure: true });
// don't attempt to reconnect, will always fail
} else if (this.closeEvents < 3) {
this.closeEvents++;
await this.handleReconnect();
} else {
await this.cleanupConnection();
this.emitter.emit('stopped', { authFailure: false });
}
});
this.client.addEventListener('open', (e) => {
this.logger.verbose(`Connection was established.`);
if (this.canReconnect && this.session_id !== undefined) {
// using resume
this.handleResume();
} else {
// initial identify
this.handleIdentify();
}
});
this.client.addEventListener('error', (e) => {
this.logger.error(new Error(`Error from Discord Gateway`, { cause: e.error }));
});
this.client.addEventListener('message', async (e) => {
try {
await this.handleMessage(JSON.parse(e.data));
} catch (e) {
this.logger.error(e);
}
});
}
fetchGatewayUrl = async () => {
const resp = await request.get(API_GATEWAY_ENDPOINT);
this.initialGatewayUrl = resp.body.url;
this.logger.debug(`Got Initial Gateway Base: ${this.initialGatewayUrl}`);
}
connect = async () => {
try {
this.client.open();
const opened = await pEvent(this.client, 'open');
return true;
} catch (e) {
this.client.close();
throw new Error(`Could not connect to Discord Gateway`, { cause: e.error ?? e });
}
}
handleIdentify() {
const data: GatewayIdentify = {
op: GatewayOpcodes.Identify,
d: {
token: this.config.token,
intents: 0,
properties: {
os: "linux",
device: "Discord Client",
browser: "Discord Client"
}
}
};
this.client.send(JSON.stringify(data));
}
async handleHello(data: GatewayHelloData) {
// jitter
// https://docs.discord.com/developers/events/gateway#heartbeat-interval
const sleepTime = randomInt(data.heartbeat_interval - 1);
this.logger.debug(`Heartbeat Interval: ${data.heartbeat_interval}ms (${Math.floor(data.heartbeat_interval / 1000)}s), waiting ${Math.floor(sleepTime / 1000)}s before sending first heartbeat.`);
await sleep(sleepTime);
const [isOk] = this.checkOkToSend();
if (!isOk) {
return;
}
this.sendHeartbeat();
this.heartbeatInterval = setInterval(() => {
if (this.client.OPEN !== this.client.readyState) {
if (this.heartbeatInterval !== undefined) {
clearInterval(this.heartbeatInterval);
}
return;
}
if (!this.acknowledged) {
// zombied!
return this.handleReconnect().then(() => null).catch((e) => this.logger.error(e));
}
this.sendHeartbeat();
}, data.heartbeat_interval);
}
sendHeartbeat() {
const heartbeatRequest: GatewayHeartbeatRequest = {
op: GatewayOpcodes.Heartbeat,
// @ts-expect-error
d: this.sequence ?? null
}
this.acknowledged = false;
if (this.client.OPEN !== this.client.readyState) {
this.logger.debug('Cannot send heartbeat because connection is closed.');
return;
}
this.client.send(JSON.stringify(heartbeatRequest));
if (isDebugMode()) {
this.logger.debug('Sent heartbeat');
}
}
/**
* https://docs.discord.com/developers/events/gateway#identifying
* https://docs.discord.com/developers/events/gateway#ready-event
* https://docs.discord.com/developers/events/gateway-events#ready */
handleReady(data: GatewayReadyDispatchData) {
this.session_id = data.session_id;
this.resume_gateway_url = data.resume_gateway_url;
this.user = data.user;
this.canReconnect = true;
this.ready = true;
this.authOK = true;
this.closeEvents = 0;
this.logger.verbose(`Gateway Connection READY for ${this.user.username}`);
}
/** https://docs.discord.com/developers/events/gateway-events#invalid-session */
handleInvalidSession(data: GatewayInvalidSessionData) {
this.canReconnect = data !== false;
return this.handleReconnect().then(() => null).catch((e) => this.logger.error(e));
}
async handleReconnect() {
await this.cleanupConnection();
// maybe don't do this if we've failed N times
this.initClient();
this.connect();
}
handleResume() {
const data: GatewayResumeData = {
token: this.config.token,
session_id: this.session_id,
seq: this.sequence
}
this.client.send(JSON.stringify({ op: GatewayOpcodes.Resume, d: data }));
}
async cleanupConnection() {
clearInterval(this.heartbeatInterval);
this.heartbeatInterval = undefined;
clearTimeout(this.activityTimeout);
this.activityTimeout = undefined;
if (this.client.CLOSED !== this.client.readyState) {
this.client.close();
// wait for close or just give it a few seconds
await Promise.race([
pEvent(this.client, 'close'),
sleep(3000),
]);
}
this.ready = false;
if (!this.canReconnect) {
this.session_id = undefined;
this.sequence = undefined;
this.resume_gateway_url = undefined;
this.user = undefined;
this.lastActiveStatus = PresenceUpdateStatus.Offline;
this.lastActivities = [];
}
}
handleUserSessionUpdates = (data: UserSession[]) => {
this.logger.debug('Recieved updated user sessions');
if (data.filter(x => x.session_id !== this.session_id && x.session_id !== 'all').length === 0) {
this.logger.debug('No other user sessions exist, marking our session presence as inactive');
this.lastActiveStatus = PresenceUpdateStatus.Offline;
this.lastActivities = [];
return;
}
const otherSessions = data.filter(x => x.session_id !== this.session_id);
const sessionSummaries = otherSessions.map(x => {
let sessionId = `${x.session_id === 'all' ? '(All) | ' : ''}OS ${x.client_info.os} | Client ${x.client_info.client} | Status ${x.status} | Active ${x.active === true}`;
if(x.activities.length === 0) {
sessionId += " | 0 Activities"
} else {
const activitySummary = x.activities.map(x => x.type === 4 ? 'Custom Status' : `${activityIdToStr(x.type)} ${x.name}`).join(', ');
sessionId += ` | Activities => ${activitySummary}`;
};
return sessionId;
});
this.logger.debug(sessionSummaries.join('\n'));
const last = this.lastActiveStatus;
if (otherSessions.some(x => x.status === 'online')) {
this.lastActiveStatus = PresenceUpdateStatus.Online;
} else if (otherSessions.some(x => x.status === 'dnd')) {
this.lastActiveStatus = PresenceUpdateStatus.DoNotDisturb;
} else if (otherSessions.some(x => x.status === 'idle')) {
this.lastActiveStatus = PresenceUpdateStatus.Idle;
} else if (otherSessions.some(x => x.status === 'invisible')) {
this.lastActiveStatus = PresenceUpdateStatus.Invisible;
} else {
this.lastActiveStatus = PresenceUpdateStatus.Offline;
}
this.logger.debug(`Best status found: ${this.lastActiveStatus}`);
this.lastActivities = otherSessions.filter(x => x.session_id !== 'all').map(x => x.activities).flat(1);
const [allowed, reason] = this.presenceIsAllowed();
if(!allowed) {
// if updated sessions now disallow updating presence
// and we have a current presence in our session
// then we need to remove it so it doesn't override anything
const ourSession = data.find(x => x.session_id === this.session_id);
if(ourSession !== undefined && ourSession.activities.length > 0) {
this.logger.debug(`Clearing our session presence, MS presence no longer allowed because ${reason}`);
this.clearActivity();
}
}
}
async handleMessage(message: _DataPayload<GatewayDispatchEvents> | _NonDispatchPayload) {
try {
const { op, s } = message;
if (s !== null && s !== undefined) {
this.sequence = s;
}
if (isDebugMode()) {
const friendlyOp = opcodeToFriendly(op);
let handleHint = `Got opcode ${op}${friendlyOp !== op ? ` (${friendlyOp})` : ''}`;
if (op === GatewayOpcodes.Dispatch) {
handleHint += ` w/ Dispatch Event ${message.t}`;
}
this.logger.debug(handleHint);
}
switch (op) {
case GatewayOpcodes.Hello:
this.handleHello(message.d as GatewayHelloData).catch(e => this.logger.error(e));
break;
case GatewayOpcodes.HeartbeatAck:
if (isDebugMode()) {
this.logger.debug("Heartbeat acknowledged");
}
this.acknowledged = true;
break;
case GatewayOpcodes.Heartbeat:
if (isDebugMode()) {
this.logger.debug("Received Heartbeat");
}
this.sendHeartbeat();
break;
case GatewayOpcodes.Dispatch:
const { t } = message;
switch (t) {
case GatewayDispatchEvents.Ready:
this.handleReady(message.d as GatewayReadyDispatchData);
break;
// @ts-expect-error
case 'SESSIONS_REPLACE':
if (isDebugMode()) {
// @ts-expect-error
this.logger.debug(`${t} => ${JSON.stringify(message.d)}`);
}
// @ts-expect-error
this.handleUserSessionUpdates(message.d as UserSession[]);
break;
};
break;
case GatewayOpcodes.InvalidSession:
this.logger.debug('Recieved invalid session opcode');
this.handleInvalidSession(message.d as GatewayInvalidSessionData);
break;
case GatewayOpcodes.Reconnect:
this.logger.debug('Recieved reconnect opcode');
await this.handleReconnect();
break;
case GatewayOpcodes.Resume:
this.logger.debug({ data: message.d }, 'Recieved Resumed session');
this.canReconnect = true;
this.ready = true;
this.authOK = true;
this.closeEvents = 0;
break;
case GatewayOpcodes.Identify:
this.logger.debug({ data: message.d }, 'Recieved Identifiy opcode');
break;
case GatewayOpcodes.PresenceUpdate:
this.logger.debug({ data: message.d }, 'Recieved Presence Update opcode');
break;
default:
this.logger.debug(`Recieved unhandled opcode: ${op}`);
break;
}
} catch (error) {
throw new Error('Error handling gateway message', { cause: error });
}
}
playStateToActivity = async (data: SourceData): Promise<GatewayActivity> => {
const { activity, artUrl } = playStateToActivityData(data);
const {
artwork = false
} = this.config;
const {
artworkDefaultUrl = ARTWORK_PLACEHOLDER,
applicationId
} = this.config;
let art = artworkDefaultUrl;
if(artUrl !== undefined) {
if(urlContainsKnownMediaDomain(artUrl)) {
art = artUrl;
} else if (artwork !== false) {
if (Array.isArray(artwork)) {
const allowed = artwork.some(x => artUrl.toLocaleLowerCase().includes(x.toLocaleLowerCase()));
if (allowed) {
art = artUrl;
}
} else {
const u = new URL(artUrl);
// only allow secure protocol as this is likely to be a real domain that is public accessible
// IP domain usually uses http only
if (u.protocol === 'https://') {
art = artUrl;
}
}
}
}
// https://docs.discord.com/developers/events/gateway-events#activity-object-activity-assets
// https://docs.discord.com/developers/events/gateway-events#activity-object-activity-asset-image
if(art !== false) {
const usedUrl = await this.getArtworkUrl(art);
if(usedUrl !== undefined) {
activity.assets.large_image = usedUrl;
}
}
if(art !== MS_ART && applicationId !== undefined) {
const smallArt = await this.getArtworkUrl(MS_ART);
if(smallArt !== undefined) {
activity.assets.small_image = smallArt;
activity.assets.small_text = 'Via Multi-Scrobbler'
activity.assets.small_url = 'https://multi-scrobbler.app'
}
}
return activity;
}
sendActivity = async (data: SourceData | undefined) => {
const [sendOk, reasons] = this.checkOkToSend();
if (!sendOk) {
this.logger.warn(`Cannot send activity because client is ${reasons}`);
return;
}
if(data === undefined) {
this.clearActivity();
return;
}
const activity = await this.playStateToActivity(data);
const play = isPlayObject(data) ? data : data.play;
let clearTime = dayjs().add(260, 'seconds'); // funny number
if (activity.timestamps?.end !== undefined) {
clearTime = dayjs.unix(Math.floor(activity.timestamps.end as number / 1000));
} else if (play.data?.duration !== undefined) {
clearTime = dayjs().add(play.data.duration, 'seconds')
}
const updateData = this.generatePresenceUpdate();
updateData.activities.push(activity);
const currentActivity: GatewayUpdatePresence = {
op: GatewayOpcodes.PresenceUpdate,
d: updateData
}
this.client.send(JSON.stringify(currentActivity));
if (this.activityTimeout !== undefined) {
clearTimeout(this.activityTimeout);
}
this.activityTimeout = setTimeout(() => {
this.clearActivity();
}, Math.abs(clearTime.diff(dayjs(), 'ms')));
}
clearActivity = () => {
if (this.activityTimeout !== undefined) {
clearTimeout(this.activityTimeout);
this.activityTimeout = undefined;
}
const [sendOk, reasons] = this.checkOkToSend();
if (!sendOk) {
this.logger.warn(`Cannot clear activity because client is ${reasons}`);
return;
}
const clearedActivity: GatewayUpdatePresence = {
op: GatewayOpcodes.PresenceUpdate,
d: this.generatePresenceUpdate()
}
this.client.send(JSON.stringify(clearedActivity));
}
generatePresenceUpdate = (): GatewayPresenceUpdateData => {
return {
since: null,
activities: [],
status: this.lastActiveStatus,
// TODO determine this?
afk: this.lastActiveStatus === PresenceUpdateStatus.Idle
}
}
checkOkToSend = (): [boolean, string?] => {
if (this.ready && this.client.OPEN === this.client.readyState) {
return [true];
}
const reasons = [];
if (!this.ready) {
reasons.push('not ready');
}
if (this.client.OPEN !== this.client.readyState) {
reasons.push(`socket not open (${this.client.readyState})`);
}
return [false, reasons.join(' and ')];
}
getArtworkUrl = async (artUrl: string): Promise<string | undefined> => {
const cachedUrl = await this.cache.cacheMetadata.get<string>(artUrl);
if (cachedUrl !== undefined) {
return cachedUrl;
}
if (this.config.applicationId === undefined || this.artFail) {
return;
}
try {
const imgResp = await request.post(`https://discord.com/api/v10/applications/${this.config.applicationId}/external-assets`)
.set('Authorization', this.config.token)
.type('json')
.send({ "urls": [artUrl] });
this.artFailCount = 0;
const proxied = `mp:${imgResp.body[0].external_asset_path}`
await this.cache.cacheMetadata.set(artUrl, proxied);
return proxied;
} catch (e) {
this.artFailCount++;
this.logger.warn(new Error('Failed to upload art url', { cause: e }));
if (isSuperAgentResponseError(e)) {
if (e.status === 401 || e.status === 403) {
this.artFail = true;
}
} else if (this.artFailCount > 3) {
this.logger.verbose('More than 3 consecutive failures to upload art...turning off to stop spamming bad requests');
this.artFail = true;
}
return;
}
}
presenceIsAllowedByStatus = (status?: PresenceUpdateStatus | StatusType): [boolean, string?] => {
if (!this.config.statusOverrideAllow.includes(status as StatusType ?? this.lastActiveStatus as StatusType)) {
return [false, `most active session has a disallowed status: ${status ?? this.lastActiveStatus}`];
}
return [true];
}
presenceIsAllowedByActivity = (manualActivities?: GatewayActivity[]): [boolean, string?] => {
const activities = manualActivities ?? this.lastActivities;
if (activities.length !== 0) {
const disallowedActivityType = activities.find(x => !this.config.activitiesOverrideAllow.includes(activityIdToStr(x.type)));
if (disallowedActivityType !== undefined) {
return [false, `a session has an activity type MS is not allowed to override: ${activityIdToStr(disallowedActivityType.type)}`];
}
const disallowedActivityName = activities.find(x => !this.config.applicationsOverrideDisallow.some(y => x.name.toLocaleLowerCase().includes(y.toLocaleLowerCase())));
if (disallowedActivityType !== undefined) {
return [false, `a session has an activity name MS is not allowed to override: ${disallowedActivityName.name}`];
}
}
return [true];
}
presenceIsAllowed = (): [boolean, string?] => {
const [statusAllowed, statusReason] = this.presenceIsAllowedByStatus();
if(!statusAllowed) {
return [statusAllowed, statusReason];
}
const [activityAllowed, activityReason] = this.presenceIsAllowedByActivity();
if(!activityAllowed) {
return [activityAllowed, activityReason];
}
return [true];
}
}
const opcodeToFriendly = (op: number) => {
switch (op) {
case GatewayOpcodes.Hello:
return 'Hello';
case GatewayOpcodes.HeartbeatAck:
return 'HeartbeatAck'
case GatewayOpcodes.Heartbeat:
return 'Heartbeat';
case GatewayOpcodes.Dispatch:
return 'Dispatch';
case GatewayOpcodes.InvalidSession:
return 'InvalidSession';
case GatewayOpcodes.Reconnect:
return 'Reconnect';
case GatewayOpcodes.Resume:
return 'Resume';
case GatewayOpcodes.Identify:
return 'Identify';
case GatewayOpcodes.PresenceUpdate:
return 'PresenceUpdate'
default:
return op;
}
}
interface UserSession {
status: 'online' | 'invisible' | 'dnd' | 'idle'
client_info: {
version: number
os: string
client: string
}
processed_at_timestamp?: number
active?: boolean
session_id: string
// activities: {
// state: string
// created_at: number
// type: ActivityType
// name: string
// }[]
activities: GatewayActivity[]
}
export const playStateToActivityData = (data: SourceData, opts: { useArt?: boolean } = {}): { activity: GatewayActivity, artUrl?: string } => {
// unix timestamps in milliseconds
let startTime: number,
endTime: number;
let play: PlayObject;
if (isPlayObject(data)) {
play = data;
if (data.meta.trackProgressPosition !== undefined && play.data.duration !== undefined) {
startTime = dayjs().subtract(data.meta.trackProgressPosition, 's').unix() * 1000;
endTime = dayjs().add(data.data.duration - data.meta.trackProgressPosition, 's').unix() * 1000;
} else if (asPlayerStateData(data)) {
play = data.play;
if (data.position !== undefined && play.data.duration !== undefined) {
startTime = dayjs().subtract(data.position, 's').unix() * 1000;
endTime = dayjs().add(data.data.duration - data.position, 's').unix() * 1000;
}
}
}
let activityName = capitalize(play.meta?.musicService ?? play.meta?.mediaPlayerName ?? play.meta?.source ?? 'music')
// @ts-expect-error
const activity = removeUndefinedKeys<GatewayActivity>({
// https://docs.discord.com/developers/events/gateway-events#activity-object
type: 2, // Listening
// https://docs.discord.com/developers/events/gateway-events#activity-object
status_display_type: 1, // state
name: activityName,
details: play.data.track,
state: play.data.artists !== undefined && play.data.artists.length > 0 ? play.data.artists.join(' / ') : undefined,
// https://docs.discord.com/developers/events/gateway-events#activity-object-activity-assets
// https://docs.discord.com/developers/events/gateway-events#activity-object-activity-asset-image
assets: {
large_text: play.data.album
}
});
if (endTime !== undefined && startTime !== undefined) {
activity.timestamps = {
start: startTime,
end: endTime
}
}
//let buttons: GatewayActivityButton[] = [];
const {
meta: {
url: {
web,
origin
} = {},
},
data: {
meta: {
brainz: {
recording
} = {}
} = {}
} = {}
} = play;
const url = origin ?? web;
if(url !== undefined) {
const knownService = urlToMusicService(url);
if(knownService !== undefined) {
activity.details_url = url;
// when including buttons discord accepts the presence update but does not actually use it
// I think buttons may now be limited to official RPC or restricted to preset actions via things like secrets or registering commands
// https://docs.discord.com/developers/developer-tools/game-sdk#activitysecrets-struct
// buttons.push({
// label: `Listen on ${capitalize(knownService)}`,
// url: web
// });
}
}
if(recording !== undefined) {
const mb = `https://musicbrainz.org/recording/${recording}`;
if(activity.details_url === undefined) {
activity.details_url = mb;
} else {
activity.state_url = mb;
}
// buttons.push({
// label: 'Open on Musicbrainz',
// url: `https://musicbrainz.org/recording/${recording}`
// });
}
// if(buttons.length > 0) {
// activity.buttons = buttons;
// }
const artUrl = play.meta?.art?.album ?? play.meta?.art?.track ?? play.meta?.art?.artist;
return { activity, artUrl };
}
export const statusStringToType = (str: string): StatusType => {
switch(str.trim().toLocaleLowerCase()) {
case 'online':
return PresenceUpdateStatus.Online;
case 'idle':
return PresenceUpdateStatus.Idle;
case 'dnd':
return PresenceUpdateStatus.DoNotDisturb;
case 'invisible':
return PresenceUpdateStatus.Invisible;
default:
throw new Error(`Not a valid status type. Must be one of: online | idle | dnd | invisible`);
}
}
export const activityStringToType = (str: string): MSActivityType => {
switch(str.trim().toLocaleLowerCase()) {
case 'playing':
return 'playing';
case 'streaming':
return 'streaming';
case 'listening':
return 'listening';
case 'watching':
return 'watching';
case 'custom':
return 'custom';
case 'competing':
return 'competing';
default:
throw new Error(`Not a valid activity type. Must be one of: playing | streaming | listening | watching | custom | competing`);
}
}
export const activityIdToStr = (id: number): MSActivityType => {
switch(id) {
case 0:
return 'playing';
case 1:
return 'streaming';
case 2:
return 'listening';
case 3:
return 'watching';
case 4:
return 'custom';
case 5:
return 'competing';
default:
throw new Error(`Not a valid activity type. Must be one of: playing | streaming | listening | watching | custom | competing`);
}
}
export const configToStrong = (data: DiscordData): DiscordStrongData => {
const {
token,
applicationId,
artwork,
artworkDefaultUrl,
statusOverrideAllow = ['online','idle','dnd'],
activitiesOverrideAllow = ['custom'],
applicationsOverrideDisallow = []
} = data;
const strongConfig: DiscordStrongData = {
token,
applicationId,
applicationsOverrideDisallow: parseArrayFromMaybeString(applicationsOverrideDisallow)
}
if (typeof artwork === 'boolean' || Array.isArray(artwork)) {
strongConfig.artwork = artwork;
} else if (typeof artwork === 'string') {
if (['true', 'false'].includes(artwork.toLocaleLowerCase())) {
strongConfig.artwork = parseBool(artwork)
} else {
strongConfig.artwork = parseArrayFromMaybeString(artwork)
}
}
if(artworkDefaultUrl !== undefined && typeof artworkDefaultUrl === 'string' && artworkDefaultUrl.toLocaleLowerCase().trim() === 'false') {
strongConfig.artworkDefaultUrl = false;
} else if (typeof artworkDefaultUrl === 'boolean') {
strongConfig.artworkDefaultUrl = artworkDefaultUrl ? ARTWORK_PLACEHOLDER : false;
} else {
strongConfig.artworkDefaultUrl = artworkDefaultUrl;
}
const saRaw = parseArrayFromMaybeString(statusOverrideAllow);
strongConfig.statusOverrideAllow = saRaw.map(statusStringToType);
const aaRaw = parseBoolOrArrayFromMaybeString(activitiesOverrideAllow);
if(typeof aaRaw === 'boolean') {
strongConfig.activitiesOverrideAllow = aaRaw ? ActivityTypes : [];
} else {
strongConfig.activitiesOverrideAllow = aaRaw.map(activityStringToType);
}
return strongConfig;
}
@@ -6,9 +6,10 @@ import { nanoid } from "nanoid";
import { MarkOptional } from "ts-essentials";
import {
DeadLetterScrobble,
NowPlayingUpdateThreshold,
PlayObject,
PlayObjectLifecycleless,
QueuedScrobble, ScrobbleActionResult, ScrobblePayload, ScrobbleResponse, TA_DURING,
QueuedScrobble, ScrobbleActionResult, ScrobblePayload, ScrobbleResponse, SourcePlayerObj, TA_DURING,
TA_FUZZY,
TrackStringOptions
} from "../../core/Atomic.js";
@@ -18,6 +19,7 @@ import { hasUpstreamError, UpstreamError } from "../common/errors/UpstreamError.
import {
ARTIST_WEIGHT,
Authenticatable,
CALCULATED_PLAYER_STATUSES,
ClientType,
DEFAULT_RETRY_MULTIPLIER,
DUP_SCORE_THRESHOLD,
@@ -63,10 +65,13 @@ import { normalizeStr } from "../utils/StringUtils.js";
import prom, { Counter, Gauge } from 'prom-client';
import { ScrobbleSubmitError } from "../common/errors/MSErrors.js";
import {serializeError} from 'serialize-error';
import { redactString } from "@foxxmd/redact-string";
type PlatformMappedPlays = Map<string, {play: PlayObject, source: SourceIdentifier}>;
type PlatformMappedPlays = Map<string, {player: SourcePlayerObj, source: SourceIdentifier}>;
type NowPlayingQueue = Map<string, PlatformMappedPlays>;
const platformTruncate = truncateStringToLength(10);
export default abstract class AbstractScrobbleClient extends AbstractComponent implements Authenticatable {
name: string;
@@ -100,11 +105,11 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i
supportsNowPlaying: boolean = false;
nowPlayingEnabled: boolean;
nowPlayingFilter: (queue: NowPlayingQueue) => PlayObject | undefined;
nowPlayingMinThreshold: (play?: PlayObject) => number = (_) => 10;
nowPlayingMaxThreshold: (play?: PlayObject) => number = (_) => 30;
nowPlayingFilter: (queue: NowPlayingQueue) => SourcePlayerObj | undefined;
nowPlayingMinThreshold: NowPlayingUpdateThreshold = (_) => 10;
nowPlayingMaxThreshold: NowPlayingUpdateThreshold = (_) => 30;
nowPlayingLastUpdated?: Dayjs;
nowPlayingLastPlay?: PlayObject;
nowPlayingLastPlay?: SourcePlayerObj;
nowPlayingQueue: NowPlayingQueue = new Map();
nowPlayingTaskInterval: number = 5000;
npLogger: Logger;
@@ -292,7 +297,7 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i
}
}
this.nowPlayingFilter = (queue: NowPlayingQueue) => {
this.nowPlayingFilter = (queue: NowPlayingQueue): SourcePlayerObj => {
if (queue.size === 0) {
return undefined;
}
@@ -305,7 +310,7 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i
// if only one player then return it
const plays = Array.from(platformPlays);
if (plays.length === 1) {
return plays[0][1].play;
return plays[0][1].player;
}
// else we need to sort players to determine which to report
@@ -313,18 +318,17 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i
// this way we aren't flip-flopping between multiple players for reporting now playing
// (keeps reporting sticky based on first reported)
if (this.nowPlayingLastPlay !== undefined) {
const lastNowPlayingPlat = genGroupIdStrFromPlay(this.nowPlayingLastPlay);
for (const [platform, data] of plays) {
if (platform === lastNowPlayingPlat) {
return data.play;
if (platform === this.nowPlayingLastPlay.platformId) {
return data.player;
}
}
}
// otherwise sort platform alphabetically and take first
plays.sort((a, b) => a[0].localeCompare(b[0]));
return plays[0][1].play;
return plays[0][1].player;
}
}
}
@@ -1001,38 +1005,42 @@ ${closestMatch.breakdowns.join('\n')}`, {leaf: ['Dupe Check']});
this.updateDeadLetterCache();
}
queuePlayingNow = (data: PlayObject, source: SourceIdentifier) => {
queuePlayingNow = (data: SourcePlayerObj, source: SourceIdentifier) => {
const sourceId = `${source.name}-${source.type}`;
if(isDebugMode()) {
this.npLogger.debug(`Queueing ${buildTrackString(data, {include: ['artist', 'track', 'platform']})} from ${sourceId}`);
let playHint = '';
if(data.play !== undefined) {
playHint = ` with Play ${buildTrackString(data.play, {include: ['artist', 'track', 'platform']})}`
}
this.npLogger.debug(`Queueing Player ${platformTruncate(data.platformId)} ${data.status.calculated.toLocaleUpperCase()}${playHint} from ${sourceId}`);
}
const platformPlays = this.nowPlayingQueue.get(sourceId) ?? new Map();
platformPlays.set(genGroupIdStrFromPlay(data), {play: data, source});
platformPlays.set(data.platformId, {player: data, source});
this.nowPlayingQueue.set(sourceId, platformPlays);
}
processingPlayingNow = async (): Promise<void> => {
if(this.supportsNowPlaying && this.nowPlayingEnabled) {
const play = this.nowPlayingFilter(this.nowPlayingQueue);
if(play === undefined) {
const sourcePlayerData = this.nowPlayingFilter(this.nowPlayingQueue);
if(sourcePlayerData === undefined) {
return;
}
if(this.shouldUpdatePlayingNow(play)) {
if(this.shouldUpdatePlayingNow(sourcePlayerData) && (await this.shouldUpdatePlayingNowPlatformSpecific(sourcePlayerData))) {
try {
await this.doPlayingNow(play);
await this.doPlayingNow(sourcePlayerData);
this.npLogger.debug(`Now Playing updated.`);
this.emitEvent('nowPlayingUpdated', play);
this.emitEvent('nowPlayingUpdated', sourcePlayerData);
} catch (e) {
this.npLogger.warn(new Error('Error occurred while trying to update upstream Client, will ignore', {cause: e}));
}
this.nowPlayingLastPlay = play;
this.nowPlayingLastPlay = sourcePlayerData;
this.nowPlayingLastUpdated = dayjs();
}
this.nowPlayingQueue = new Map();
}
}
shouldUpdatePlayingNow = (data: PlayObject): boolean => {
shouldUpdatePlayingNow = (data: SourcePlayerObj): boolean => {
if(this.nowPlayingLastPlay === undefined || this.nowPlayingLastUpdated === undefined) {
if(isDebugMode()) {
this.npLogger.debug(`Now Playing has not yet been set! Should update`);
@@ -1042,30 +1050,40 @@ ${closestMatch.breakdowns.join('\n')}`, {leaf: ['Dupe Check']});
const lastUpdateDiff = Math.abs(dayjs().diff(this.nowPlayingLastUpdated, 's'));
const playExistingDiscrepancy = (this.nowPlayingLastPlay.play !== undefined && data.play === undefined) || (this.nowPlayingLastPlay === undefined && data.play !== undefined);
const bothPlaysExist = this.nowPlayingLastPlay.play !== undefined && data.play !== undefined;
const playerStatusChanged = this.nowPlayingLastPlay.status.calculated !== data.status.calculated;
// update if play *has* changed and time since last update is greater than min interval
// this prevents spamming scrobbler API with updates if user is skipping tracks and source updates frequently
if(!playObjDataMatch(data, this.nowPlayingLastPlay) && this.nowPlayingMinThreshold(data) < lastUpdateDiff) {
if(this.nowPlayingMinThreshold(data.play) < lastUpdateDiff && (playExistingDiscrepancy || playerStatusChanged || (bothPlaysExist && !playObjDataMatch(data.play, this.nowPlayingLastPlay.play)))) {
if(isDebugMode()) {
this.npLogger.debug(`New Play differs from previous Now Playing and time since update ${lastUpdateDiff}s, greater than threshold ${this.nowPlayingMinThreshold(data)}. Should update`);
this.npLogger.debug(`New Play differs from previous Now Playing and time since update ${lastUpdateDiff}s, greater than threshold ${this.nowPlayingMinThreshold(data.play)}. Should update`);
}
return true;
}
// update if play *has not* changed but last update is greater than max interval
// this keeps scrobbler Now Playing fresh ("active" indicator) in the event play is long
if(playObjDataMatch(data, this.nowPlayingLastPlay) && this.nowPlayingMaxThreshold(data) < lastUpdateDiff) {
if(this.nowPlayingMaxThreshold(data.play) < lastUpdateDiff && (bothPlaysExist && playObjDataMatch(data.play, this.nowPlayingLastPlay.play))) {
if(isDebugMode()) {
this.npLogger.debug(`Now Playing last updated ${lastUpdateDiff}s ago, greater than threshold ${this.nowPlayingMaxThreshold(data)}s. Should update`);
this.npLogger.debug(`Now Playing last updated ${lastUpdateDiff}s ago, greater than threshold ${this.nowPlayingMaxThreshold(data.play)}s. Should update`);
}
return true;
}
if(isDebugMode()) {
this.npLogger.debug(`Now Playing ${playObjDataMatch(data, this.nowPlayingLastPlay) ? 'matches' : 'does not match'} and was last updated ${lastUpdateDiff}s ago (threshold ${this.nowPlayingMaxThreshold(data)}s), not updating`);
this.npLogger.debug(`Now Playing ${bothPlaysExist && playObjDataMatch(data.play, this.nowPlayingLastPlay.play) ? 'matches' : 'does not match'} and was last updated ${lastUpdateDiff}s ago (threshold ${this.nowPlayingMaxThreshold(data.play)}s), not updating`);
}
return false;
}
protected doPlayingNow = (data: PlayObject): Promise<any> => Promise.resolve(undefined)
/** Implement this for specific requirements for updating playing now based on the scrobbler platform */
protected shouldUpdatePlayingNowPlatformSpecific(data: SourcePlayerObj): Promise<boolean> {
return shouldUpdatePlayingNowPlatformWhenPlayingOnly(data);
}
protected doPlayingNow = (data: SourcePlayerObj): Promise<any> => Promise.resolve(undefined)
public emitEvent = (eventName: string, payload: object) => {
@@ -1090,6 +1108,13 @@ ${closestMatch.breakdowns.join('\n')}`, {leaf: ['Dupe Check']});
}
}
export const nowPlayingUpdateByPlayDuration = (play: PlayObject) => {
return (play.data.duration ?? 30) + 1;
export const nowPlayingUpdateByPlayDuration: NowPlayingUpdateThreshold = (play?: PlayObject) => {
if(play === undefined) {
31;
}
return (play?.data?.duration ?? 30) + 1;
}
export const shouldUpdatePlayingNowPlatformWhenPlayingOnly = async (data: SourcePlayerObj): Promise<boolean> => {
return data.status.calculated === CALCULATED_PLAYER_STATUSES.playing;
}
+114
View File
@@ -0,0 +1,114 @@
import { Logger } from "@foxxmd/logging";
import EventEmitter from "events";
import { PlayObject, SourcePlayerObj } from "../../core/Atomic.js";
import { CALCULATED_PLAYER_STATUSES, FormatPlayObjectOptions, REPORTED_PLAYER_STATUSES, ReportedPlayerStatus } from "../common/infrastructure/Atomic.js";
import { Notifiers } from "../notifier/Notifiers.js";
import AbstractScrobbleClient, { nowPlayingUpdateByPlayDuration } from "./AbstractScrobbleClient.js";
import { DiscordClientConfig, DiscordStrongData, StatusType } from "../common/infrastructure/config/client/discord.js";
import { configToStrong, DiscordWSClient, playStateToActivityData } from "../common/vendor/discord/DiscordWSClient.js";
export default class DiscordScrobbler extends AbstractScrobbleClient {
api: DiscordWSClient;
requiresAuth = true;
requiresAuthInteraction = false;
declare config: DiscordClientConfig & {data: DiscordStrongData };
constructor(name: any, config: DiscordClientConfig, options = {}, notifier: Notifiers, emitter: EventEmitter, logger: Logger) {
const strong = configToStrong(config.data);
super('discord', name, {...config, data: strong}, notifier, emitter, logger);
this.api = new DiscordWSClient(name, { ...strong, ...config.options }, { logger: this.logger });
this.api.emitter.on('stopped', async (e) => {
if(e.authFailure) {
this.authFailure = true;
this.authed = false;
}
await this.tryStopScrobbling();
});
this.supportsNowPlaying = true;
this.nowPlayingMaxThreshold = nowPlayingUpdateByPlayDuration;
this.nowPlayingMinThreshold = (_) => 5;
}
formatPlayObj = (obj: any, options: FormatPlayObjectOptions = {}) => obj;
protected async doBuildInitData(): Promise<true | string | undefined> {
const {
data: {
token
} = {}
} = this.config;
if (token === undefined) {
throw new Error('Must provide a user token');
}
if(typeof this.config.data.artwork === 'boolean') {
this.logger.verbose(`Artwork: ${this.config.data.artwork ? 'Allow any with HTTPS' : 'Allow none'}`);
} else {
this.logger.verbose(`Artwork: Allow HTTPS with these domains: ${this.config.data.artwork.join(', ')}`);
}
this.logger.verbose(`Artwork Fallback Url: ${this.config.data.artworkDefaultUrl}`);
this.logger.verbose(`Allow override statuses: ${this.config.data.statusOverrideAllow.join(', ')}`);
this.logger.verbose(`Allow override activity types: ${this.config.data.activitiesOverrideAllow.join(', ')}`);
this.logger.verbose(`Disallow override activity names: ${this.config.data.applicationsOverrideDisallow.join(', ')}`);
await this.api.initClient();
return true;
}
doAuthentication = async () => {
try {
return await this.api.connect();
} catch (e) {
throw e;
}
}
getScrobblesForRefresh = async (limit: number) => {
return [];
}
alreadyScrobbled = async (playObj: PlayObject, log = false) => true
public playToClientPayload(playObj: PlayObject): any {
return playStateToActivityData(playObj).activity;
}
doScrobble = async (playObj: PlayObject) => {
return { play: playObj, payload: {} };
}
doPlayingNow = async (data: SourcePlayerObj) => {
try {
if([CALCULATED_PLAYER_STATUSES.stopped, CALCULATED_PLAYER_STATUSES.paused].includes(data.status.calculated as ReportedPlayerStatus)) {
await this.api.sendActivity(undefined);
} else {
await this.api.sendActivity(data.play);
}
} catch (e) {
throw e;
}
}
shouldUpdatePlayingNowPlatformSpecific = async (data: SourcePlayerObj) => {
if ([CALCULATED_PLAYER_STATUSES.stopped, CALCULATED_PLAYER_STATUSES.paused, CALCULATED_PLAYER_STATUSES.playing].includes(data.status.calculated as ReportedPlayerStatus)
|| data.status.stale) {
const [sendOk, reasons] = this.api.checkOkToSend();
if (!sendOk) {
this.logger.warn(`Cannot update playing now because api client is ${reasons}`);
return false;
}
const [allowed, reason] = this.api.presenceIsAllowed();
if(!allowed) {
this.logger.debug(reason);
}
return true;
}
return false;
}
}
+4 -4
View File
@@ -1,6 +1,6 @@
import { Logger } from "@foxxmd/logging";
import EventEmitter from "events";
import { PlayObject } from "../../core/Atomic.js";
import { PlayObject, SourcePlayerObj } from "../../core/Atomic.js";
import { buildTrackString, capitalize } from "../../core/StringUtils.js";
import { isNodeNetworkException } from "../common/errors/NodeErrors.js";
import { UpstreamError } from "../common/errors/UpstreamError.js";
@@ -8,7 +8,7 @@ import { FormatPlayObjectOptions } from "../common/infrastructure/Atomic.js";
import { playToListenPayload } from "../common/vendor/ListenbrainzApiClient.js";
import { Notifiers } from "../notifier/Notifiers.js";
import AbstractScrobbleClient from "./AbstractScrobbleClient.js";
import AbstractScrobbleClient, { shouldUpdatePlayingNowPlatformWhenPlayingOnly } from "./AbstractScrobbleClient.js";
import { isDebugMode } from "../utils.js";
import { KoitoClientConfig } from "../common/infrastructure/config/client/koito.js";
import { KoitoApiClient, listenObjectResponseToPlay } from "../common/vendor/koito/KoitoApiClient.js";
@@ -95,9 +95,9 @@ export default class KoitoScrobbler extends AbstractScrobbleClient {
}
}
doPlayingNow = async (data: PlayObject) => {
doPlayingNow = async (data: SourcePlayerObj) => {
try {
await this.api.submitListen(data, { listenType: 'playing_now'});
await this.api.submitListen(data.play, { listenType: 'playing_now'});
} catch (e) {
throw e;
}
+4 -4
View File
@@ -1,6 +1,6 @@
import { Logger } from "@foxxmd/logging";
import EventEmitter from "events";
import { PlayObject } from "../../core/Atomic.js";
import { PlayObject, SourcePlayerObj } from "../../core/Atomic.js";
import { buildTrackString, capitalize } from "../../core/StringUtils.js";
import { isNodeNetworkException } from "../common/errors/NodeErrors.js";
import { UpstreamError } from "../common/errors/UpstreamError.js";
@@ -8,7 +8,7 @@ import { FormatPlayObjectOptions, InternalConfigOptional } from "../common/infra
import { LastfmClientConfig } from "../common/infrastructure/config/client/lastfm.js";
import LastfmApiClient, { LastFMIgnoredScrobble, playToClientPayload, formatPlayObj, LASTFM_HOST, LASTFM_PATH } from "../common/vendor/LastfmApiClient.js";
import { Notifiers } from "../notifier/Notifiers.js";
import AbstractScrobbleClient, { nowPlayingUpdateByPlayDuration } from "./AbstractScrobbleClient.js";
import AbstractScrobbleClient, { nowPlayingUpdateByPlayDuration, shouldUpdatePlayingNowPlatformWhenPlayingOnly } from "./AbstractScrobbleClient.js";
import { findCauseByReference } from "../utils/ErrorUtils.js";
export default class LastfmScrobbler extends AbstractScrobbleClient {
@@ -107,10 +107,10 @@ export default class LastfmScrobbler extends AbstractScrobbleClient {
}
}
doPlayingNow = async (data: PlayObject) => {
doPlayingNow = async (data: SourcePlayerObj) => {
// last.fm shows Now Playing for the same time as the duration of the track being submitted
try {
return this.api.playingNow(data);
return this.api.playingNow(data.play);
} catch (e) {
throw e;
}
@@ -1,6 +1,6 @@
import { Logger } from "@foxxmd/logging";
import EventEmitter from "events";
import { PlayObject } from "../../core/Atomic.js";
import { PlayObject, SourcePlayerObj } from "../../core/Atomic.js";
import { buildTrackString, capitalize } from "../../core/StringUtils.js";
import { isNodeNetworkException } from "../common/errors/NodeErrors.js";
import { hasUpstreamError, UpstreamError } from "../common/errors/UpstreamError.js";
@@ -10,7 +10,7 @@ import { ListenbrainzApiClient, playToListenPayload, playToSubmitPayload } from
import { ListenPayload } from '../common/vendor/listenbrainz/interfaces.js';
import { Notifiers } from "../notifier/Notifiers.js";
import AbstractScrobbleClient, { nowPlayingUpdateByPlayDuration } from "./AbstractScrobbleClient.js";
import AbstractScrobbleClient, { nowPlayingUpdateByPlayDuration, shouldUpdatePlayingNowPlatformWhenPlayingOnly } from "./AbstractScrobbleClient.js";
import { isDebugMode } from "../utils.js";
export default class ListenbrainzScrobbler extends AbstractScrobbleClient {
@@ -98,10 +98,10 @@ export default class ListenbrainzScrobbler extends AbstractScrobbleClient {
}
}
doPlayingNow = async (data: PlayObject) => {
doPlayingNow = async (data: SourcePlayerObj) => {
// listenbrainz shows Now Playing for the same time as the duration of the track being submitted
try {
await this.api.submitListen(data, { listenType: 'playing_now'});
await this.api.submitListen(data.play, { listenType: 'playing_now'});
} catch (e) {
throw e;
}
+3 -3
View File
@@ -1,6 +1,6 @@
import { Logger } from "@foxxmd/logging";
import EventEmitter from "events";
import { PlayObject } from "../../core/Atomic.js";
import { PlayObject, SourcePlayerObj } from "../../core/Atomic.js";
import { buildTrackString, capitalize } from "../../core/StringUtils.js";
import { isNodeNetworkException } from "../common/errors/NodeErrors.js";
import { hasUpstreamError, UpstreamError } from "../common/errors/UpstreamError.js";
@@ -103,9 +103,9 @@ export default class RockskyScrobbler extends AbstractScrobbleClient {
}
}
doPlayingNow = async (data: PlayObject) => {
doPlayingNow = async (data: SourcePlayerObj) => {
try {
await this.api.submitListen(data, { listenType: 'playing_now'});
await this.api.submitListen(data.play, { listenType: 'playing_now'});
} catch (e) {
throw e;
}
+31 -4
View File
@@ -21,6 +21,7 @@ import { CommonClientOptions } from '../common/infrastructure/config/client/inde
import { ExternalMetadataTerm, PlayTransformHooks } from '../common/infrastructure/Transform.js';
import { LibrefmClientConfig } from '../common/infrastructure/config/client/librefm.js';
import clone from 'clone';
import { DiscordClientConfig } from '../common/infrastructure/config/client/discord.js';
type groupedNamedConfigs = {[key: string]: ParsedConfig[]};
@@ -49,9 +50,7 @@ export default class ScrobbleClients {
this.sourceEmitter.on('playerUpdate', async (payload: { data: SourcePlayerObj & { options: { scrobbleTo: string[] } }} & SourceIdentifier) => {
// agressively update Now Playing so scrobblers that display based on duration are mostly synced
// but aggressively *stop* updating if state becomes stale/orphaned
if(payload.data.status.reported === REPORTED_PLAYER_STATUSES.playing && (!payload.data.status.stale && !payload.data.status.orphaned)) {
this.playingNow(payload.data.play, {...payload.data.options, scrobbleFrom: { type: payload.type, name: payload.name}});
}
this.playingNow(payload.data, {...payload.data.options, scrobbleFrom: { type: payload.type, name: payload.name}});
});
this.sourceEmitter.on('discoveredToScrobble', async (payload: { data: (PlayObject | PlayObject[]), options: { forceRefresh?: boolean, checkTime?: Dayjs, scrobbleTo?: string[], scrobbleFrom?: string } }) => {
@@ -108,6 +107,8 @@ export default class ScrobbleClients {
return "TealClientConfig";
case 'rocksky':
return "RockSkyClientConfig";
case 'discord':
return 'DiscordClientConfig';
}
}
@@ -296,6 +297,28 @@ export default class ScrobbleClients {
})
}
break;
case 'discord': {
const discord = {
token: process.env.DISCORD_TOKEN,
artwork: process.env.DISCORD_ARTWORK,
applicationId: process.env.DISCORD_APPLICATION_ID,
artworkDefaultUrl: process.env.DISCORD_ARTWORK_DEFAULT_URL,
statusOverrideAllow: process.env.DISCORD_STATUS_OVERRIDE_ALLOW,
activitiesOverrideAllow: process.env.DISCORD_ACTIVITIES_OVERRIDE_ALLOW,
applicationsOverrideDisallow: process.env.DISCORD_APPNAME_OVERRIDE_DISALLOW
}
if (!Object.values(discord).every(x => x === undefined)) {
configs.push({
type: 'discord',
name: 'unnamed-discord',
source: 'ENV',
mode: 'single',
configureAs: 'client',
data: discord,
options: transformPresetEnv('DISCORD')
})
}
} break;
default:
break;
}
@@ -430,6 +453,10 @@ ${sources.join('\n')}`);
const RockskyScrobbler = (await import('./RockskyScrobbler.js')).default;
newClient = new RockskyScrobbler(name, {...clientConfig, data: {configDir: this.internalConfig.configDir, ...data} } as unknown as RockSkyClientConfig, {}, notifier, this.emitter, this.logger);
break;
case 'discord':
const DiscordScrobbler = (await import('./DiscordScrobbler.js')).default;
newClient = new DiscordScrobbler(name, {...clientConfig, data: {configDir: this.internalConfig.configDir, ...data} } as unknown as DiscordClientConfig, {}, notifier, this.emitter, this.logger);
break;
default:
break;
}
@@ -442,7 +469,7 @@ ${sources.join('\n')}`);
this.clients.push(newClient);
}
playingNow = async (data: (PlayObject | PlayObject[]), options: {scrobbleTo: string[], scrobbleFrom: SourceIdentifier}) => {
playingNow = async (data: SourcePlayerObj, options: {scrobbleTo: string[], scrobbleFrom: SourceIdentifier}) => {
const playObjs = Array.isArray(data) ? data : [data];
const {
scrobbleTo = [],
+4 -1
View File
@@ -454,7 +454,10 @@ export default class JellyfinApiSource extends MemoryPositionalSource {
meta.album = ProviderIds.MusicBrainzAlbum;
}
if(ProviderIds.MusicBrainzTrack !== undefined) {
meta.recording = ProviderIds.MusicBrainzTrack;
meta.track = ProviderIds.MusicBrainzTrack;
}
if(ProviderIds.MusicBrainzRecording !== undefined) {
meta.recording = ProviderIds.MusicBrainzRecording;
}
if(ProviderIds.MusicBrainzArtist !== undefined) {
meta.artist = [ProviderIds.MusicBrainzArtist];
@@ -146,6 +146,7 @@ export class WebScrobblerSource extends MemorySource {
},
deviceId: `${connectorLabel}-${controllerTabId}`,
musicService: connectorL,
source: 'WebScrobbler',
scrobbleAllowed: isScrobblingAllowed,
nowPlaying: options.nowPlaying ?? false
}
+32 -32
View File
@@ -11,7 +11,7 @@ import { genGroupIdStr, sleep } from "../../utils.js";
import mixedDuration from '../plays/mixedDuration.json' with { type: 'json' };
import withDuration from '../plays/withDuration.json' with { type: 'json' };
import { MockNetworkError, withRequestInterception } from "../utils/networking.js";
import { asPlays, generatePlay, generatePlayPlatformId, generatePlays, normalizePlays } from "../utils/PlayTestUtils.js";
import { asPlays, generatePlay, generatePlayPlatformId, generatePlays, generateSourcePlayerObj, normalizePlays } from "../utils/PlayTestUtils.js";
import MockDate from 'mockdate';
import { NowPlayingScrobbler, TestAuthScrobbler, TestScrobbler } from "./TestScrobbler.js";
@@ -776,12 +776,12 @@ describe('Now Playing', function() {
const pt = dayjs().subtract(15, 's');
npScrobbler.queuePlayingNow(generatePlay({playDate: pt}, {deviceId: genGroupIdStr(secondPlatform)}), {type: 'spotify', name: 'test'});
npScrobbler.queuePlayingNow(generatePlay({playDate: pt}, {deviceId: genGroupIdStr(firstPlatform)}), {type: 'spotify', name: 'test'});
npScrobbler.queuePlayingNow(generateSourcePlayerObj({play: generatePlay({playDate: pt}, {deviceId: genGroupIdStr(secondPlatform)})}) , {type: 'spotify', name: 'test'});
npScrobbler.queuePlayingNow(generateSourcePlayerObj({play: generatePlay({playDate: pt}, {deviceId: genGroupIdStr(firstPlatform)})}), {type: 'spotify', name: 'test'});
const toReport = npScrobbler.nowPlayingFilter(npScrobbler.nowPlayingQueue);
expect(toReport.meta.deviceId).eq(genGroupIdStr(firstPlatform));
expect(toReport.play.meta.deviceId).eq(genGroupIdStr(firstPlatform));
});
@@ -793,16 +793,16 @@ describe('Now Playing', function() {
const firstPlatform: PlayPlatformId = ['aaa', 'NO_USER'];
const secondPlatform: PlayPlatformId = ['bbbb', 'NO_USER'];
npScrobbler.nowPlayingLastPlay = generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())});
npScrobbler.nowPlayingLastPlay = generateSourcePlayerObj({play: generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())})});
const pt = dayjs().subtract(15, 's');
npScrobbler.queuePlayingNow(generatePlay({playDate: pt}, {deviceId: genGroupIdStr(secondPlatform)}), {type: 'spotify', name: 'test'});
npScrobbler.queuePlayingNow(generatePlay({playDate: pt}, {deviceId: genGroupIdStr(firstPlatform)}), {type: 'spotify', name: 'test'});
npScrobbler.queuePlayingNow(generateSourcePlayerObj({play:generatePlay({playDate: pt}, {deviceId: genGroupIdStr(secondPlatform)})}), {type: 'spotify', name: 'test'});
npScrobbler.queuePlayingNow(generateSourcePlayerObj({play:generatePlay({playDate: pt}, {deviceId: genGroupIdStr(firstPlatform)})}), {type: 'spotify', name: 'test'});
const toReport = npScrobbler.nowPlayingFilter(npScrobbler.nowPlayingQueue);
expect(toReport.meta.deviceId).eq(genGroupIdStr(firstPlatform));
expect(toReport.play.meta.deviceId).eq(genGroupIdStr(firstPlatform));
});
@@ -816,17 +816,17 @@ describe('Now Playing', function() {
const pt = dayjs().subtract(15, 's');
const stickyNp = generatePlay({playDate: pt}, {deviceId: genGroupIdStr(secondPlatform)});
const stickyNp = generateSourcePlayerObj({play: generatePlay({playDate: pt}, {deviceId: genGroupIdStr(secondPlatform)})});
npScrobbler.nowPlayingLastPlay = stickyNp
npScrobbler.queuePlayingNow(generatePlay({playDate: pt}, {deviceId: genGroupIdStr(firstPlatform)}), {type: 'spotify', name: 'test'});
npScrobbler.queuePlayingNow(generateSourcePlayerObj({play:generatePlay({playDate: pt}, {deviceId: genGroupIdStr(firstPlatform)})}), {type: 'spotify', name: 'test'});
npScrobbler.queuePlayingNow(stickyNp, {type: 'spotify', name: 'test'});
const toReport = npScrobbler.nowPlayingFilter(npScrobbler.nowPlayingQueue);
expect(toReport.meta.deviceId).eq(genGroupIdStr(secondPlatform));
expect(toReport.play.meta.deviceId).eq(genGroupIdStr(secondPlatform));
});
@@ -835,15 +835,15 @@ describe('Now Playing', function() {
const npScrobbler = new NowPlayingScrobbler();
await npScrobbler.initialize();
const a = generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())});
const b = generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())});
const a = generateSourcePlayerObj({play:generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())})});
const b = generateSourcePlayerObj({play:generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())})});
npScrobbler.queuePlayingNow(b, {type: 'jellyfin', name: 'btest'})
npScrobbler.queuePlayingNow(a, {type: 'subsonic', name: 'atest'})
const toReport = npScrobbler.nowPlayingFilter(npScrobbler.nowPlayingQueue);
expect(toReport.meta.deviceId).eq(a.meta.deviceId);
expect(toReport.play.meta.deviceId).eq(a.play.meta.deviceId);
});
@@ -852,15 +852,15 @@ describe('Now Playing', function() {
const npScrobbler = new NowPlayingScrobbler({name: 'test', options: {nowPlaying: ['btest', 'atest']}});
await npScrobbler.initialize();
const a = generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())});
const b = generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())});
const a = generateSourcePlayerObj({play:generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())})});
const b = generateSourcePlayerObj({play:generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())})});
npScrobbler.queuePlayingNow(a, {type: 'subsonic', name: 'atest'})
npScrobbler.queuePlayingNow(b, {type: 'jellyfin', name: 'btest'})
const toReport = npScrobbler.nowPlayingFilter(npScrobbler.nowPlayingQueue);
expect(toReport.meta.deviceId).eq(b.meta.deviceId);
expect(toReport.play.meta.deviceId).eq(b.play.meta.deviceId);
});
@@ -869,7 +869,7 @@ describe('Now Playing', function() {
const npScrobbler = new NowPlayingScrobbler({name: 'test', options: {nowPlaying: ['btest', 'atest']}});
await npScrobbler.initialize();
const c = generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())});
const c = generateSourcePlayerObj({play:generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())})});
npScrobbler.queuePlayingNow(c, {type: 'jellyfin', name: 'ctest'})
@@ -887,7 +887,7 @@ describe('Now Playing', function() {
const npScrobbler = new NowPlayingScrobbler();
await npScrobbler.initialize();
const res = npScrobbler.shouldUpdatePlayingNow(generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())}));
const res = npScrobbler.shouldUpdatePlayingNow(generateSourcePlayerObj({play:generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())})}));
expect(res).to.be.true;
});
@@ -896,8 +896,8 @@ describe('Now Playing', function() {
const npScrobbler = new NowPlayingScrobbler();
await npScrobbler.initialize();
const lastUpdate = generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())});
npScrobbler.nowPlayingLastUpdated = dayjs().subtract(npScrobbler.nowPlayingMaxThreshold(lastUpdate) + 1, 's');
const lastUpdate = generateSourcePlayerObj({play:generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())})});
npScrobbler.nowPlayingLastUpdated = dayjs().subtract(npScrobbler.nowPlayingMaxThreshold(lastUpdate.play) + 1, 's');
npScrobbler.nowPlayingLastPlay = lastUpdate;
const res = npScrobbler.shouldUpdatePlayingNow(lastUpdate);
@@ -909,8 +909,8 @@ describe('Now Playing', function() {
const npScrobbler = new NowPlayingScrobbler();
await npScrobbler.initialize();
const lastUpdate = generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())});
npScrobbler.nowPlayingLastUpdated = dayjs().subtract(npScrobbler.nowPlayingMaxThreshold(lastUpdate) - 1, 's');
const lastUpdate = generateSourcePlayerObj({play:generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())})});
npScrobbler.nowPlayingLastUpdated = dayjs().subtract(npScrobbler.nowPlayingMaxThreshold(lastUpdate.play) - 1, 's');
npScrobbler.nowPlayingLastPlay = lastUpdate;
const res = npScrobbler.shouldUpdatePlayingNow(lastUpdate);
@@ -922,11 +922,11 @@ describe('Now Playing', function() {
const npScrobbler = new NowPlayingScrobbler();
await npScrobbler.initialize();
const lastUpdate = generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())});
npScrobbler.nowPlayingLastUpdated = dayjs().subtract(npScrobbler.nowPlayingMinThreshold(lastUpdate) + 1, 's');
const lastUpdate = generateSourcePlayerObj({play:generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())})});
npScrobbler.nowPlayingLastUpdated = dayjs().subtract(npScrobbler.nowPlayingMinThreshold(lastUpdate.play) + 1, 's');
npScrobbler.nowPlayingLastPlay = lastUpdate;
const res = npScrobbler.shouldUpdatePlayingNow(generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())}));
const res = npScrobbler.shouldUpdatePlayingNow(generateSourcePlayerObj({play:generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())})}));
expect(res).to.be.true;
});
@@ -935,11 +935,11 @@ describe('Now Playing', function() {
const npScrobbler = new NowPlayingScrobbler();
await npScrobbler.initialize();
const lastUpdate = generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())});
npScrobbler.nowPlayingLastUpdated = dayjs().subtract(npScrobbler.nowPlayingMinThreshold(lastUpdate) - 1, 's');
const lastUpdate = generateSourcePlayerObj({play:generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())})});
npScrobbler.nowPlayingLastUpdated = dayjs().subtract(npScrobbler.nowPlayingMinThreshold(lastUpdate.play) - 1, 's');
npScrobbler.nowPlayingLastPlay = lastUpdate;
const res = npScrobbler.shouldUpdatePlayingNow(generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())}));
const res = npScrobbler.shouldUpdatePlayingNow(generateSourcePlayerObj({play:generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())})}));
expect(res).to.be.false;
});
@@ -958,7 +958,7 @@ describe('Now Playing', function() {
await npScrobbler.initialize();
npScrobbler.scheduler.startById('pn_task');
npScrobbler.queuePlayingNow(generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())}), {type: 'jellyfin', name: 'test'});
npScrobbler.queuePlayingNow(generateSourcePlayerObj({play:generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())})}), {type: 'jellyfin', name: 'test'});
const res = await Promise.race([pEvent(npScrobbler.emitter, 'nowPlayingUpdated'), sleep(12)]);
@@ -974,7 +974,7 @@ describe('Now Playing', function() {
const now = dayjs();
npScrobbler.queuePlayingNow(generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())}), {type: 'jellyfin', name: 'test'});
npScrobbler.queuePlayingNow(generateSourcePlayerObj({play:generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())})}), {type: 'jellyfin', name: 'test'});
const res = await Promise.race([pEvent(npScrobbler.emitter, 'nowPlayingUpdated'), sleep(12)]);
@@ -982,7 +982,7 @@ describe('Now Playing', function() {
MockDate.set(now.add(npScrobbler.nowPlayingMinThreshold() + 3, 's').toDate());
npScrobbler.queuePlayingNow(generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())}), {type: 'jellyfin', name: 'test'});
npScrobbler.queuePlayingNow(generateSourcePlayerObj({play:generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())})}), {type: 'jellyfin', name: 'test'});
const resUpdate = await Promise.race([pEvent(npScrobbler.emitter, 'nowPlayingUpdated'), sleep(12)]);
+44 -4
View File
@@ -5,9 +5,9 @@ import isBetween from "dayjs/plugin/isBetween.js";
import relativeTime from "dayjs/plugin/relativeTime.js";
import timezone from "dayjs/plugin/timezone.js";
import utc from "dayjs/plugin/utc.js";
import { FEAT, JOINERS, JOINERS_FINAL, JsonPlayObject, MissingMbidType, ObjectPlayData, PlayMeta, PlayObject } from "../../../core/Atomic.js";
import { sortByNewestPlayDate } from "../../utils.js";
import { NO_DEVICE, NO_USER, PlayerStateDataMaybePlay, PlayPlatformId, ReportedPlayerStatus } from '../../common/infrastructure/Atomic.js';
import { FEAT, JOINERS, JOINERS_FINAL, JsonPlayObject, MissingMbidType, ObjectPlayData, PlayMeta, PlayObject, SourcePlayerObj } from "../../../core/Atomic.js";
import { genGroupIdStr, getPlatformIdFromData, sortByNewestPlayDate } from "../../utils.js";
import { CALCULATED_PLAYER_STATUSES, NO_DEVICE, NO_USER, PlayerStateDataMaybePlay, PlayPlatformId, REPORTED_PLAYER_STATUSES, ReportedPlayerStatus, SINGLE_USER_PLATFORM_ID, SourceIdentifier } from '../../common/infrastructure/Atomic.js';
import { arrayListAnd } from '../../../core/StringUtils.js';
import { findDelimiters } from '../../utils/StringUtils.js';
import { ListRecord, ScrobbleRecord } from '../../common/infrastructure/config/client/tealfm.js';
@@ -428,4 +428,44 @@ export const generateTealPlayRecord = (opts: {
}
return [rec, { did, tid }];
}
}
export interface GenerateSourcePlayerObjOptions {
playOpts?: Parameters<typeof generatePlay>[0]
play?: PlayObject
playPlatform?: string,
sourcePlayOpts?: Partial<SourcePlayerObj>
}
export const generateSourcePlayerObj = (opts: GenerateSourcePlayerObjOptions): SourcePlayerObj => {
let play: PlayObject;
let platformId: string;
if(opts.play !== undefined) {
platformId = opts.play.meta.deviceId;
} else {
platformId = opts.playPlatform ?? genGroupIdStr(SINGLE_USER_PLATFORM_ID);
}
play = opts.play ?? generatePlay(opts.playOpts, {deviceId:platformId});
const {
status = {},
play: oPlay,
platformId: oplat,
...rest
} = opts.sourcePlayOpts ?? {};
return {
platformId,
play,
listenedDuration: 60,
playerLastUpdatedAt: dayjs().toISOString(),
status: {
reported: REPORTED_PLAYER_STATUSES.playing,
calculated: CALCULATED_PLAYER_STATUSES.playing,
stale: false,
orphaned: false,
...status
},
...rest
}
}
+12
View File
@@ -353,6 +353,18 @@ export function parseBool(value: any, prev: any = false): boolean {
throw new Error(`'${value.toString()}' is not a boolean value.`);
}
export function parseBoolStrict(value: string): boolean {
const strTrue = ['1', 'true', 'yes'].includes(value.toLocaleLowerCase().trim());
if (strTrue) {
return strTrue;
}
const strFalse = ['0', 'false', 'no'].includes(value.toLocaleLowerCase().trim());
if (strFalse) {
return false;
}
throw new Error(`'${value.toString()}' is not a strict boolean value.`);
}
export const genGroupIdStrFromPlay = (play: PlayObject) => {
const groupId = genGroupId(play);
return genGroupIdStr(groupId);
+10
View File
@@ -1,5 +1,6 @@
import { Files, File } from "formidable";
import VolatileFile from "formidable/VolatileFile.js";
import { KNOWN_MEDIA_PROVIDER_URLS } from "../../core/Atomic.js";
// typings from Formidable are all nuts.
// VolatileFile is missing buffer and also does not extend File even though it should
@@ -69,3 +70,12 @@ const isVolatileFile = (val: unknown): val is File => {
export const getFileIdentifier = (f: File): string => {
return f.originalFilename === null ? f.newFilename : f.originalFilename;
}
export const urlContainsDomains = (url: string | URL, domains: string[]): boolean => {
const u = typeof url === 'string' ? url : url.hostname;
return domains.some(x => u.includes(x));
}
export const urlContainsKnownMediaDomain = (url: string | URL): boolean => {
return urlContainsDomains(url, KNOWN_MEDIA_PROVIDER_URLS);
}
+16 -1
View File
@@ -2,7 +2,7 @@ import { strategies, stringSameness, StringSamenessResult } from "@foxxmd/string
import { hasher } from 'node-object-hash';
import { PlayObject } from "../../core/Atomic.js";
import { asPlayerStateData, DELIMITERS, DELIMITERS_NO_AMP, PlayerStateDataMaybePlay } from "../common/infrastructure/Atomic.js";
import { genGroupIdStr, getPlatformIdFromData, intersect, parseRegexSingleOrFail } from "../utils.js";
import { genGroupIdStr, getPlatformIdFromData, intersect, parseBool, parseBoolStrict, parseRegexSingleOrFail } from "../utils.js";
import { buildTrackString } from "../../core/StringUtils.js";
const {levenStrategy, diceStrategy} = strategies;
@@ -412,6 +412,21 @@ export const parseArrayFromMaybeString = (value: string | string[] = '', opts: A
return arr;
}
export const parseBoolOrArrayFromMaybeString = (value: string | string[] | boolean = '', opts: ArrParseOpts = {}): string[] | boolean => {
if (typeof value === 'boolean') {
return value;
}
if(Array.isArray(value)) {
return value;
}
try {
return parseBoolStrict(value);
} catch (e) {
// not a strict bool value
}
return parseArrayFromMaybeString(value, opts);
}
export const firstNonEmptyStr = (vals: unknown[]): string | undefined => {
for(const val of vals) {
if(val !== undefined && val !== null && typeof val !== 'object') {
+2 -2
View File
@@ -39,8 +39,8 @@ export const validateJson = async <T>(type: string, config: object, schemaIdenti
return config as unknown as T;
} else {
const schemaErrors = ['Json config was not valid. Please use schema to check validity.'];
if (Array.isArray(ajv.errors)) {
for (const err of ajv.errors) {
if (Array.isArray(validate.errors)) {
for (const err of validate.errors) {
const parts = [
`At: ${err.instancePath}`,
];
+23 -5
View File
@@ -5,6 +5,7 @@ import { AdditionalTrackInfoResponse } from "../backend/common/vendor/listenbrai
import { Delta } from 'jsondiffpatch';
import { MarkOptional } from "ts-essentials";
import { ErrorObject } from "serialize-error";
import { PlayPlatformIdStr } from "../backend/common/infrastructure/Atomic.js";
export interface SourceStatusData {
status: string;
@@ -54,7 +55,7 @@ export interface SourceStatusData {
export interface ClientStatusData {
status: string;
type: "maloja" | "lastfm" | "librefm" | "listenbrainz" | "koito" | "tealfm" | "rocksky";
type: "maloja" | "lastfm" | "librefm" | "listenbrainz" | "koito" | "tealfm" | "rocksky" | "discord";
display: string;
scrobbled: number;
deadLetterScrobbles: number
@@ -357,8 +358,8 @@ export interface LogOutputConfig {
}
export interface SourcePlayerObj {
platformId: string,
play: PlayObject,
platformId: PlayPlatformIdStr,
play?: PlayObject,
playFirstSeenAt?: string,
playLastUpdatedAt?: string,
playerLastUpdatedAt: string
@@ -374,7 +375,7 @@ export interface SourcePlayerObj {
}
export interface SourcePlayerJson extends Omit<SourcePlayerObj, 'play'> {
play: JsonPlayObject
play?: JsonPlayObject
}
export interface SourceScrobble<PlayType> {
@@ -386,6 +387,8 @@ export interface QueuedScrobble<PlayType> extends SourceScrobble<PlayType> {
id: string
}
export type NowPlayingUpdateThreshold = (play?: PlayObject) => number;
export interface DeadLetterScrobble<PlayType, RetryType = Dayjs> extends QueuedScrobble<PlayType> {
id: string
retries: number
@@ -528,4 +531,19 @@ export interface TransformResult {
type: string,
name: string,
play: PlayData
}
}
export const KNOWN_MEDIA_PROVIDER_URLS = [
'spotify.com',
'bandcamp.com',
'youtube.com',
'deezer.com',
'tidal.com',
'apple.com',
'archive.org',
'soundcloud.com',
'jamendo.com',
'play.google.com',
'listenbrainz.org',
'musicbrainz.org'
];