Skip to content

Commit 8b3f4c6

Browse files
committed
Merge branch 'develop'
2 parents 7a0a23b + 1b0212f commit 8b3f4c6

File tree

19 files changed

+887
-81
lines changed

19 files changed

+887
-81
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c
1717
* [Deezer](/docs/configuration.md#deezer)
1818
* [MPRIS (Linux Desktop)](/docs/configuration.md#mpris)
1919
* [Mopidy](/docs/configuration.md#mopidy)
20+
* [JRiver](/docs/configuration.md#jriver)
2021
* Supports scrobbling to many **Clients**
2122
* [Maloja](/docs/configuration.md#maloja)
2223
* [Last.fm](/docs/configuration.md#lastfm)

config/jriver.json.example

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
[
2+
{
3+
"name": "MyJriver",
4+
"data": {
5+
"url": "0.0.0.0",
6+
"username": "auser",
7+
"password": "apassword"
8+
}
9+
}
10+
]

docs/configuration.md

Lines changed: 74 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
* [Youtube Music](#youtube-music)
1616
* [MPRIS (Linux Desktop)](#mpris)
1717
* [Mopidy](#mopidy)
18+
* [JRiver](#jriver)
1819
* [Client Configurations](#client-configurations)
1920
* [Maloja](#maloja)
2021
* [Last.fm](#lastfm)
@@ -270,7 +271,7 @@ No support for ENV based for Last.fm as a client (only source)
270271

271272
See [`lastfm.json.example`](/config/lastfm.json.example), change `configureAs` to `source`. Or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23/%23%2Fdefinitions%2FLastfmSourceConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fsource.json)
272273

273-
# [Listenbrainz (Source)](https://listenbrainz.org)
274+
## [Listenbrainz (Source)](https://listenbrainz.org)
274275

275276
You will need to run your own Listenbrainz server or have an account [on the official instance](https://listenbrainz.org/login/)
276277

@@ -404,7 +405,8 @@ Part => Default Value
404405
* Port => `6680`
405406
* Path => `/mopidy/ws/`
406407

407-
EX
408+
<details>
409+
<summary>URL Transform Examples</summary>
408410

409411
```json
410412
{
@@ -430,6 +432,8 @@ MS transforms this to: `ws://192.168.0.101:3456/mopidy/ws/`
430432

431433
MS transforms this to: `ws://mopidy.mydomain.com:80/MOPWS`
432434

435+
</details>
436+
433437

434438
#### URI Blacklist/Whitelist
435439

@@ -460,6 +464,74 @@ EX:
460464
If a track would be scrobbled like `Album: Soundcloud, Track: My Cool Track, Artist: A Cool Artist`
461465
then multi-scrobbler will instead scrobble `Track: My Cool Track, Artist: A Cool Artist`
462466

467+
## [JRiver](https://jriver.com/)
468+
469+
In order for multi-scrobbler to communicate with JRiver you must have [Web Server Interface](https://wiki.jriver.com/index.php/Web_Service_Interface#Documentation_of_Functions) enabled. This can can be in the JRiver GUI:
470+
471+
* Tools -> Options -> Media Network
472+
* Check `Use Media Network to share this library...`
473+
* If you have `Authentication` checked you will need to provide the **Username** and **Password** in the ENV/File configuration below.
474+
475+
#### URL
476+
477+
If you do not provide a URL then a default is used which assumes JRiver is installed on the same server as multi-scrobbler: `http://localhost:52199/MCWS/v1/`
478+
479+
* Make sure the port number matches what is found in `Advanced` section in the [Media Network](#jriver) options.
480+
* If your installation is on the same machine but you cannot connect using `localhost` try `0.0.0.0` instead.
481+
482+
The URL used to connect ultimately must be formed like this: `[protocol]://[hostname]:[port]/[path]`
483+
If any part of this URL is missing multi-scrobbler will use a default value, for your convenience. This also means that if any part of your URL is **not** standard you must explicitly define it.
484+
485+
Part => Default Value
486+
487+
* Protocol => `http://`
488+
* Hostname => `localhost`
489+
* Port => `52199`
490+
* Path => `/MCWS/v1/`
491+
492+
<details>
493+
<summary>URL Transform Examples</summary>
494+
495+
```json
496+
{
497+
"url": "jriver.mydomain.com"
498+
}
499+
```
500+
501+
MS transforms this to: `http://jriver.mydomain.com:52199/MCWS/v1/`
502+
503+
```json
504+
{
505+
"url": "192.168.0.101:3456"
506+
}
507+
```
508+
509+
MS transforms this to: `http://192.168.0.101:3456/MCWS/v1/`
510+
511+
```json
512+
{
513+
"url": "mydomain.com:80/jriverReverse/MCWS/v1/"
514+
}
515+
```
516+
517+
MS transforms this to: `http://mydomain.com:80/jriverReverse/MCWS/v1/`
518+
519+
</details>
520+
521+
### ENV-Based
522+
523+
524+
| Environmental Variable | Required | Default | Description |
525+
|------------------------|----------|---------------------------------|------------------------------------------------|
526+
| JRIVER_URL | Yes | http://localhost:52199/MCWS/v1/ | The URL of the JRiver server |
527+
| JRIVER_USERNAME | No | | If authentication is enabled, the username set |
528+
| JRIVER_PASSWORD | No | | If authenticated is enabled, the password set |
529+
530+
531+
### File-Based
532+
533+
See [`jriver.json.example`](/config/jriver.json.example) or [explore the schema with an example and live editor/validator](https://json-schema.app/view/%23%2Fdefinitions%2FJRiverSourceConfig/%23%2Fdefinitions%2FJRiverData?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fdevelop%2Fsrc%2Fcommon%2Fschema%2Fsource.json)
534+
463535
# Client Configurations
464536

465537
## [Maloja](https://github.com/krateng/maloja)

package-lock.json

Lines changed: 17 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
"homepage": "https://github.com/FoxxMD/multi-scrobbler#readme",
3737
"dependencies": {
3838
"@awaitjs/express": "^0.6.3",
39+
"@kenyip/backoff-strategies": "^1.0.4",
3940
"ajv": "^7.2.4",
4041
"body-parser": "^1.19.0",
4142
"compare-versions": "^4.1.2",
@@ -69,6 +70,7 @@
6970
"winston-duplex": "^0.1.1",
7071
"winston-null": "^2.0.0",
7172
"winston-transport": "^4.4.0",
73+
"xml2js": "^0.4.23",
7274
"youtube-music-ts-api": "^1.4.1"
7375
},
7476
"devDependencies": {
@@ -82,6 +84,7 @@
8284
"@types/spotify-web-api-node": "^5.0.7",
8385
"@types/superagent": "^4.1.16",
8486
"@types/triple-beam": "^1.3.2",
87+
"@types/xml2js": "^0.4.11",
8588
"ts-essentials": "^9.1.2",
8689
"ts-node": "^10.7.0",
8790
"tsconfig-paths": "^3.13.0",

src/apis/JRiverApiClient.ts

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import AbstractApiClient from "./AbstractApiClient.js";
2+
import {JRiverData} from "../common/infrastructure/config/source/jriver.js";
3+
import request, {Request, Response} from 'superagent';
4+
import xml2js from 'xml2js';
5+
import {ErrorWithCause} from "pony-cause";
6+
7+
const parser = new xml2js.Parser({'async': true});
8+
9+
export const PLAYER_STATE: Record<string, PLAYER_STATE> = {
10+
STOPPED: '0',
11+
PAUSED: '1',
12+
PLAYING: '2'
13+
}
14+
15+
export type PLAYER_STATE = '0' | '1' | '2';
16+
17+
interface JRiverResponseItem {
18+
_: string
19+
$: {
20+
Name: string
21+
}
22+
}
23+
24+
interface JRiverResponse {
25+
Response: {
26+
'$': {
27+
Status: string
28+
},
29+
Item: JRiverResponseItem[]
30+
}
31+
}
32+
33+
export interface JRiverTransformedResponse<T> {
34+
status: string
35+
data?: T
36+
}
37+
38+
export interface Alive {
39+
RuntimeGUID: string
40+
LibraryVersion: string
41+
ProgramName: string
42+
ProgramVersion: string
43+
FriendlyName: string
44+
AccessKey: string
45+
ProductVersion: string
46+
Platform: string
47+
}
48+
// state 0 = nothing?
49+
// 2 = playing
50+
// 1 = paused
51+
52+
export interface Authenticate {
53+
Token: string
54+
ReadOnly: number
55+
PreLicensed: boolean
56+
}
57+
58+
export interface Info {
59+
ZoneID: string
60+
ZoneName: string
61+
State: PLAYER_STATE
62+
PositionMS: number
63+
DurationMS: number
64+
Artist: string
65+
Album: string
66+
Name: string
67+
Status: string
68+
FileKey: string
69+
}
70+
71+
export interface Zones {
72+
NumberZones: number
73+
CurrentZoneID: string
74+
CurrentZoneIndex: string
75+
}
76+
77+
const jriverResponseTransform = <T>(val: JRiverResponse): JRiverTransformedResponse<T> => {
78+
const status = val.Response.$.Status;
79+
const items = val.Response.Item === undefined ? undefined : val.Response.Item.map(x => {
80+
return [x.$.Name, x._];
81+
});
82+
return {
83+
status,
84+
data: items.reduce((acc, curr) => {
85+
acc[curr[0]] = curr[1];
86+
return acc;
87+
}, {}) as T
88+
};
89+
}
90+
91+
export class JRiverApiClient extends AbstractApiClient {
92+
93+
declare config: JRiverData
94+
95+
url: string;
96+
97+
token?: string;
98+
99+
constructor(name: any, config: JRiverData, options = {}) {
100+
super('JRiver', name, config, options);
101+
const {
102+
url = 'http://localhost:52199/MCWS/v1/'
103+
} = config;
104+
this.url = url;
105+
}
106+
107+
callApi = async <T>(req: Request, retries = 0): Promise<Response & {body: T}> => {
108+
const {
109+
maxRequestRetries = 2,
110+
retryMultiplier = 1.5
111+
} = this.config;
112+
113+
if (this.token !== undefined) {
114+
req.query({token: this.token});
115+
}
116+
117+
try {
118+
const resp = await req as Response;
119+
if (resp.text !== '') {
120+
const rawBody = await parser.parseStringPromise(resp.text);
121+
resp.body = <T>jriverResponseTransform(rawBody);
122+
}
123+
return resp;
124+
} catch (e) {
125+
throw e;
126+
}
127+
}
128+
129+
testConnection = async () => {
130+
try {
131+
const resp = await this.callApi<Alive>(request.get(`${this.url}Alive`));
132+
const {body: { data } = {}} = resp;
133+
this.logger.verbose(`Found ${data.ProgramName} ${data.ProgramVersion} (${data.FriendlyName})`);
134+
return true;
135+
} catch (e) {
136+
this.logger.error(new ErrorWithCause('Could not communicate with JRiver server. Verify your server URL is correct.', {cause: e}));
137+
return false;
138+
}
139+
}
140+
141+
testAuth = async () => {
142+
try {
143+
let req = request.get(`${this.url}Authenticate`);
144+
if (this.config.username !== undefined) {
145+
req.auth(this.config.username, this.config.password);
146+
}
147+
const resp = await this.callApi<Authenticate>(req);
148+
this.token = resp.body.data.Token;
149+
return true;
150+
} catch (e) {
151+
let msg = 'Authentication failed.';
152+
if(this.config.username === undefined || this.config.password === undefined) {
153+
msg = 'Authentication failed. No username/password was provided in config! Did you mean to do this?';
154+
}
155+
this.logger.error(new ErrorWithCause(msg, {cause: e}));
156+
return false;
157+
}
158+
}
159+
160+
getInfo = async (zoneId: string = '-1') => {
161+
return await this.callApi<Info>(request.get(`${this.url}Playback/Info`).query({Zone: zoneId}));
162+
}
163+
164+
getZones = async () => {
165+
return await this.callApi<Zones>(request.get(`${this.url}Playback/Zones`));
166+
}
167+
}

src/clients/AbstractScrobbleClient.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ export default abstract class AbstractScrobbleClient {
5252
constructor(type: any, name: any, config: CommonClientConfig, notifier: Notifiers, logger: Logger) {
5353
this.type = type;
5454
this.name = name;
55-
const identifier = `Client ${capitalize(this.type)} - ${name}`;
55+
const identifier = `${capitalize(this.type)} - ${name}`;
5656
this.logger = logger.child({labels: [identifier]}, mergeArr);
5757
this.notifier = notifier;
5858

src/common/infrastructure/Atomic.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ import {FixedSizeList} from 'fixed-size-list';
33
import {MESSAGE} from 'triple-beam';
44
import {Logger} from "winston";
55

6-
export type SourceType = 'spotify' | 'plex' | 'tautulli' | 'subsonic' | 'jellyfin' | 'lastfm' | 'deezer' | 'ytmusic' | 'mpris' | 'mopidy' | 'listenbrainz';
7-
export const sourceTypes: SourceType[] = ['spotify', 'plex', 'tautulli', 'subsonic', 'jellyfin', 'lastfm', 'deezer', 'ytmusic', 'mpris', 'mopidy', 'listenbrainz'];
6+
export type SourceType = 'spotify' | 'plex' | 'tautulli' | 'subsonic' | 'jellyfin' | 'lastfm' | 'deezer' | 'ytmusic' | 'mpris' | 'mopidy' | 'listenbrainz' | 'jriver';
7+
export const sourceTypes: SourceType[] = ['spotify', 'plex', 'tautulli', 'subsonic', 'jellyfin', 'lastfm', 'deezer', 'ytmusic', 'mpris', 'mopidy', 'listenbrainz', 'jriver'];
88

99
export const lowGranularitySources: SourceType[] = ['subsonic','ytmusic'];
1010

0 commit comments

Comments
 (0)