mirror of
https://github.com/FoxxMD/multi-scrobbler.git
synced 2026-09-02 21:03:09 +03:00
Merge pull request #472 from FoxxMD/discordPresence
feat: Discord rich presence
This commit is contained in:
@@ -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 |
@@ -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>
|
||||
@@ -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 |
Generated
+259
-3
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
@@ -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)]};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 = [],
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)]);
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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') {
|
||||
|
||||
@@ -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
@@ -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'
|
||||
];
|
||||
Reference in New Issue
Block a user