mirror of
https://github.com/FoxxMD/multi-scrobbler.git
synced 2026-09-03 05:10:00 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2cd7e2991 | ||
|
|
92d8eb8d6f | ||
|
|
d14e7d9b2e | ||
|
|
e4befae55a | ||
|
|
0e00b425b4 | ||
|
|
fcf6c8eb4c | ||
|
|
242bacc1be | ||
|
|
cbe28f97a0 | ||
|
|
336972f586 | ||
|
|
81edb31e7b | ||
|
|
dbde66ad77 | ||
|
|
efbf2da816 | ||
|
|
38d1db2f3f | ||
|
|
265ae82663 | ||
|
|
2bd2972af6 | ||
|
|
1bdf083e28 | ||
|
|
4c233fdded | ||
|
|
e0060fe3e5 | ||
|
|
2adb3bd052 | ||
|
|
0dca49ab43 | ||
|
|
6a71d4a495 | ||
|
|
230ddf4a48 | ||
|
|
e4e1797ac5 | ||
|
|
1dd3185715 | ||
|
|
4fee231a7b | ||
|
|
b5ead39792 | ||
|
|
190431b9d4 | ||
|
|
210fa35cb6 | ||
|
|
479af1cd5e | ||
|
|
c72831cd12 | ||
|
|
f68a9493ce | ||
|
|
78a93d0809 | ||
|
|
3680ac8af1 | ||
|
|
e74ba52c47 | ||
|
|
d3cd07c5c3 | ||
|
|
2b890d4704 | ||
|
|
47eead2a18 | ||
|
|
38ea3115f6 | ||
|
|
f11a2b5442 | ||
|
|
818f67d6da | ||
|
|
84f353220d | ||
|
|
47541a449b | ||
|
|
1a62edab37 | ||
|
|
5927450840 | ||
|
|
ec9bea70fd | ||
|
|
2f9a45b1e4 | ||
|
|
3c649df2e9 | ||
|
|
ab1766ca0a | ||
|
|
a42de78ad3 | ||
|
|
81bde3a771 | ||
|
|
982e6655ef | ||
|
|
44ce0cba3c | ||
|
|
c1e1c69b5e | ||
|
|
142defdd20 | ||
|
|
aad9f2b4de | ||
|
|
8e92407182 | ||
|
|
94210e29d1 | ||
|
|
d2c6194314 | ||
|
|
11ca7a724d | ||
|
|
3057908074 | ||
|
|
6a9e538845 | ||
|
|
130538cd9a | ||
|
|
1486613744 | ||
|
|
f2516206c0 | ||
|
|
f8902e05a6 | ||
|
|
c59b47c801 | ||
|
|
0e4845f2e2 | ||
|
|
e2775cd9de | ||
|
|
816bec88c8 | ||
|
|
d724bef4e4 | ||
|
|
cac851f120 | ||
|
|
c1df74ee27 | ||
|
|
0c7b51c622 | ||
|
|
56ecad24d4 | ||
|
|
6bb600648a | ||
|
|
fe2610c35a | ||
|
|
84bcbc9cea | ||
|
|
d741377e5a | ||
|
|
1ea106970d | ||
|
|
a879b7aded | ||
|
|
30ee000676 | ||
|
|
703cf80d5a | ||
|
|
34289f0176 | ||
|
|
8b8f32de02 | ||
|
|
095e5cc0d9 | ||
|
|
0c46c0a079 | ||
|
|
dd4b2ebb8c | ||
|
|
d30ee0c35a | ||
|
|
484fb16bba | ||
|
|
f320e6cef0 | ||
|
|
ea2421bc6d | ||
|
|
f43c31fb9a | ||
|
|
6825ad55e3 | ||
|
|
92841f8bb3 | ||
|
|
38e5e4c9a9 | ||
|
|
1bcfed05af | ||
|
|
e00ec4098a | ||
|
|
5d44953435 | ||
|
|
80ad5a3518 | ||
|
|
b5683ba4b8 | ||
|
|
884b3963d3 | ||
|
|
8de2296aaf | ||
|
|
c1869f469c | ||
|
|
86196b3122 | ||
|
|
fd03e42240 | ||
|
|
3e230b0d68 | ||
|
|
133d7f56e5 | ||
|
|
78d048ba62 | ||
|
|
323004a730 | ||
|
|
626bca9951 | ||
|
|
3ae0d4ff45 | ||
|
|
9d85df54e1 | ||
|
|
0ff1c95fd1 | ||
|
|
b93274fd53 | ||
|
|
0f0d1d64f9 | ||
|
|
d77e6c6f22 | ||
|
|
83ff5348c6 | ||
|
|
65012d40ed | ||
|
|
feb02bfa85 | ||
|
|
276e51b8ea | ||
|
|
c83d77c02a | ||
|
|
0cdb48a71a | ||
|
|
1556d7ac69 | ||
|
|
1e65abd962 | ||
|
|
9f11d799ac | ||
|
|
5c0f419e7e | ||
|
|
c33d42808a | ||
|
|
e8716a45b2 | ||
|
|
570dee30e3 | ||
|
|
97222ed233 | ||
|
|
e2a7fff6f9 | ||
|
|
efc8246a76 | ||
|
|
b4a95e0cf9 | ||
|
|
c66d5d4627 | ||
|
|
4de28e0390 | ||
|
|
f74721b366 | ||
|
|
4fb16e7098 | ||
|
|
a76c351206 | ||
|
|
7faf94cfab | ||
|
|
3a1e29f682 | ||
|
|
88cb287df8 | ||
|
|
eb26ee0cd4 | ||
|
|
844833f348 | ||
|
|
38b02cf771 | ||
|
|
1706d5cab9 | ||
|
|
640d2ff28b | ||
|
|
312b6ce987 | ||
|
|
489f70929e | ||
|
|
904ddfa5c6 | ||
|
|
3ed6840607 | ||
|
|
93125dd7c8 | ||
|
|
34356c6c56 | ||
|
|
05466fed2b | ||
|
|
535b818a22 | ||
|
|
e62e04dd14 | ||
|
|
61cd5fcf7b | ||
|
|
f308f71b5d | ||
|
|
c498bde1fd | ||
|
|
0c9b496137 | ||
|
|
9682d4f8f9 | ||
|
|
da76945f5e | ||
|
|
51074a58f4 | ||
|
|
3cc735b9c8 | ||
|
|
b74f31c26d | ||
|
|
4a96cbfb0e | ||
|
|
77a3c352b1 | ||
|
|
598ae01082 | ||
|
|
54e11899a0 | ||
|
|
dc4962ade1 | ||
|
|
ccf317c06f | ||
|
|
aff5717f19 | ||
|
|
fa39209272 | ||
|
|
d63082a889 | ||
|
|
a28d46ecbe | ||
|
|
3bc72ce881 | ||
|
|
314210117b | ||
|
|
57a8e48d3a | ||
|
|
2d3ab86238 | ||
|
|
b76edbf7de | ||
|
|
b2efc34b80 | ||
|
|
dee8d6bc61 | ||
|
|
3e5576481d | ||
|
|
ffd74aea6f | ||
|
|
145af33239 | ||
|
|
3851b2226a | ||
|
|
bb132101b5 | ||
|
|
1e6251edfb | ||
|
|
e251da9b8e | ||
|
|
335ee0cd60 | ||
|
|
dc9f6c22ad | ||
|
|
5568b54c99 | ||
|
|
e6e35e052a | ||
|
|
9c21db8172 | ||
|
|
77c9141d92 | ||
|
|
68e86f4f90 | ||
|
|
5e95f60461 | ||
|
|
908d7d4177 | ||
|
|
de1a9c35ec | ||
|
|
f7fa9cd330 | ||
|
|
ee8b371850 | ||
|
|
601228a369 | ||
|
|
5dcaefc460 | ||
|
|
026e94fc1b | ||
|
|
fd7c8467b4 | ||
|
|
06e651a844 | ||
|
|
1b0e7a3dfd | ||
|
|
abc5d1861a | ||
|
|
4b8129fd19 | ||
|
|
f610f385bd | ||
|
|
6994635fe4 | ||
|
|
61525e1351 | ||
|
|
a50070e133 | ||
|
|
db632424b1 | ||
|
|
6b92920f50 | ||
|
|
a30d362695 | ||
|
|
d105e62f18 | ||
|
|
d5ad796339 | ||
|
|
c0aabefc72 | ||
|
|
259d321f3d | ||
|
|
d2375d92e6 | ||
|
|
f8a63c5df1 | ||
|
|
5004d99473 | ||
|
|
5b26cc73e8 | ||
|
|
72a4c4542b | ||
|
|
fdaeb8f6b8 | ||
|
|
f2f550dae7 | ||
|
|
6780d72296 | ||
|
|
f1784f3c21 | ||
|
|
22d17ba621 | ||
|
|
4b4cc1c438 | ||
|
|
7aac664916 |
@@ -3,19 +3,35 @@
|
||||
{
|
||||
"name": "Node.js",
|
||||
// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
|
||||
"image": "mcr.microsoft.com/devcontainers/javascript-node:1-22-bookworm",
|
||||
"image": "mcr.microsoft.com/devcontainers/javascript-node:1-18-bookworm",
|
||||
|
||||
// Features to add to the dev container. More info: https://containers.dev/features.
|
||||
// "features": {},
|
||||
|
||||
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
||||
"forwardPorts": [9078]
|
||||
"forwardPorts": [9078,3000],
|
||||
|
||||
// Use 'postCreateCommand' to run commands after the container is created.
|
||||
//"postCreateCommand": "./.devcontainer/postCreateCommand.sh"
|
||||
"postCreateCommand": "npm install",
|
||||
|
||||
// Configure tool-specific properties.
|
||||
// "customizations": {},
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": [
|
||||
"hbenl.vscode-mocha-test-adapter",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"unifiedjs.vscode-mdx"
|
||||
]
|
||||
}
|
||||
},
|
||||
"portsAttributes": {
|
||||
"3000": {
|
||||
"label": "Docusaurus"
|
||||
},
|
||||
"9078": {
|
||||
"label": "App"
|
||||
}
|
||||
}
|
||||
|
||||
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
|
||||
// "remoteUser": "root"
|
||||
|
||||
+3
-1
@@ -25,4 +25,6 @@ build
|
||||
*.secrets
|
||||
.devcontainer
|
||||
.vscode
|
||||
**/act/*
|
||||
**/act/*
|
||||
tmp-*
|
||||
.env
|
||||
@@ -0,0 +1,67 @@
|
||||
name: Bug Report
|
||||
description: Report a reproducible problem
|
||||
title: "bug: "
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Please check existing knowledge before opening an issue
|
||||
options:
|
||||
- label: I have [checked the FAQ](https://foxxmd.github.io/multi-scrobbler/docs/FAQ/) and [documentation](https://foxxmd.github.io/multi-scrobbler/docs/configuration/)
|
||||
required: true
|
||||
- label: I have [searched through existing issues](https://github.com/FoxxMD/multi-scrobbler/issues?q=sort%3Aupdated-desc) and [discussions.](https://github.com/FoxxMD/multi-scrobbler/discussions)
|
||||
required: true
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
If this a how-do-I question, troublehooting, or generally **not** a bug/error with multi-scrobbler please consider [**starting a discussion**](https://github.com/FoxxMD/multi-scrobbler/discussions) instead of opening an issue.
|
||||
- type: textarea
|
||||
id: what-happened
|
||||
attributes:
|
||||
label: Describe the Bug
|
||||
description: Describe what you expected to happen and what happened instead. Please provide as much detail as possible.
|
||||
placeholder: The app should have scrobbled from Spotify but instead it crashed with an error about undefined properties, etc...
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: platform
|
||||
attributes:
|
||||
label: Platform
|
||||
description: What platform are you running multi-scrobbler on?
|
||||
options:
|
||||
- Docker
|
||||
- Local (NodeJS)
|
||||
- Flatpak
|
||||
default: 0
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Versions
|
||||
description: What version of multi-scrobbler and issue-related sources/clients are running?
|
||||
placeholder: |
|
||||
- multi-scrobbler: 0.8.3 on docker
|
||||
- maloja 3.1.4
|
||||
- jellyfin 10.8.9
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Logs
|
||||
description: |
|
||||
Please copy and paste any/all relevant from the DEBUG level log output.
|
||||
Even if your issue does not seem to be shown in the logs you should still add as much detail as possible, a _lack_ of issues in logs may be a good hint during debugging.
|
||||
Your logs will be automatically formatted into code, do not need use backticks.
|
||||
If possible reproduce the issue with [**Debug Mode** enabled](https://foxxmd.github.io/multi-scrobbler/docs/configuration#debug-mode).
|
||||
render: shell
|
||||
placeholder: |
|
||||
[2025-02-14 12:47:57.170 -0500] VERBOSE: [Init] Config Dir ENV: undefined -> Resolved: CWD/config
|
||||
[2025-02-14 12:47:57.236 -0500] INFO : [Init] Debug Mode: NO
|
||||
[2025-02-14 12:47:57.258 -0500] INFO : [Init] Version: master
|
||||
[2025-02-14 12:47:57.259 -0500] INFO : [Init] Generating schema definitions...
|
||||
[2025-02-14 12:47:59.148 -0500] DEBUG : [App] [Scrobblers] [Maloja - default] Found 100 recent scrobbles
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Add any other context about the problem here.
|
||||
@@ -0,0 +1,31 @@
|
||||
name: Feature Request
|
||||
description: Suggest a new feature or functionality
|
||||
title: "feat: "
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Please check existing knowledge before opening an issue
|
||||
options:
|
||||
- label: I have checked the [documentation](https://foxxmd.github.io/multi-scrobbler/docs/configuration/)
|
||||
required: true
|
||||
- label: I have [searched through existing issues](https://github.com/FoxxMD/multi-scrobbler/issues?q=sort%3Aupdated-desc) and [discussions.](https://github.com/FoxxMD/multi-scrobbler/discussions)
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Describe the new functionality
|
||||
description: Please describe the feature or functionality you want multi-scrobbler to have. Use as much detail as possible about the exact behavior you wish to see.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Configuration Example
|
||||
description: |
|
||||
If the functionality involves changes to configuration files please provide an example here
|
||||
render: json5
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Will you help implement it?
|
||||
options:
|
||||
- label: I am willing to contribute code towards completing this feature
|
||||
- label: I am willing to help test preview builds for this feature
|
||||
@@ -0,0 +1,75 @@
|
||||
name: Request a new Source or Client
|
||||
description: Request a new Source or Client to be implemented
|
||||
title: "feat: "
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Please check existing knowledge before opening an issue
|
||||
options:
|
||||
- label: I have checked the [documentation](https://foxxmd.github.io/multi-scrobbler/docs/configuration/)
|
||||
required: true
|
||||
- label: I have [searched through existing issues](https://github.com/FoxxMD/multi-scrobbler/issues?q=sort%3Aupdated-desc) and [discussions.](https://github.com/FoxxMD/multi-scrobbler/discussions)
|
||||
required: true
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: What Type?
|
||||
description: Is this a Source or a Client?
|
||||
options:
|
||||
- Source
|
||||
- Client
|
||||
default: 1
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: Name
|
||||
description: "What is the name of the new service/software?"
|
||||
placeholder: "My Cool Player"
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: Website
|
||||
description: "Website for the service/software. Preferably a github repo or other source-code homepage"
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: API Available?
|
||||
description: Does the service have an API or other protocol that multi-scrobbler can use to communicate? **Without this the likelyhood of implementation is very low.**
|
||||
options:
|
||||
- 'Yes'
|
||||
- 'No'
|
||||
default: 1
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: Free To Use?
|
||||
description: Is this service/software freely available or does it require purchasing/subscription?
|
||||
options:
|
||||
- 'Yes'
|
||||
- 'No'
|
||||
default: 1
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: Willing to finance development?
|
||||
description: If the software is **non-free** are you will to provide a paid account/subscription to multi-scrobbler developer's in order to facilitate development/testing?
|
||||
options:
|
||||
- 'Yes'
|
||||
- 'No'
|
||||
validations:
|
||||
required: false
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Will you help implement it?
|
||||
options:
|
||||
- label: I am willing to contribute code towards completing this feature
|
||||
- label: I am willing to help test preview builds for this feature
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Add any other context about the new Source/Client here
|
||||
@@ -1,40 +0,0 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
[Please check the FAQ](https://github.com/FoxxMD/multi-scrobbler/blob/master/docsite/docs/FAQ.md) before submitting a bug report.
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Logs**
|
||||
If possible reproduce the issue with [debug logging ON](https://github.com/FoxxMD/multi-scrobbler/blob/master/docsite/docs/FAQ.md#turn-on-debug-logging)
|
||||
|
||||
```
|
||||
Copy and paste as much log data as possible related to this issue here.
|
||||
```
|
||||
|
||||
**Versions (please complete the following information):**
|
||||
Provide version information for any related sources/clients.
|
||||
|
||||
- multi-scrobbler: [e.g. 0.8.3 on docker]
|
||||
- maloja [e.g. 3.1.4]
|
||||
- jellyfin [e.g. 10.8.9]
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
@@ -0,0 +1 @@
|
||||
blank_issues_enabled: false
|
||||
@@ -12,7 +12,13 @@ docker pull ghcr.io/jefuller/artifact-server:latest
|
||||
docker run -d --name artifact-server -p 8082:8080 --add-host host.docker.internal:host-gateway -e AUTH_KEY=foo ghcr.io/jefuller/artifact-server:latest
|
||||
```
|
||||
|
||||
Run the following **from this directory** to make use of `.actrc` and proper working directoy.
|
||||
Run the following **from this directory** to make use of `.actrc` and proper working directory.
|
||||
|
||||
### Test Branch Test Suite
|
||||
|
||||
```shell
|
||||
act -W '.github/act/testSuite.yml' -e '.github/act/actBranchEvent.json'
|
||||
```
|
||||
|
||||
### Test Branch Push
|
||||
|
||||
@@ -44,4 +50,4 @@ act -W '.github/act/multiRunnerTest.yml' -e '.github/act/actBranchEvent.json'
|
||||
|
||||
```shell
|
||||
act -W '.github/act/multiRunnerBakeTest.yml' -e '.github/act/actBranchEvent.json'
|
||||
```
|
||||
```
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"ref": "refs/heads/master",
|
||||
"sha": "005dae76ab51799d3d55112738e301cb1af0dafd"
|
||||
"sha": "14d5eb3313110f2cfd1aa8fe9c229c21901c1eeb"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
{
|
||||
"action": "synchronize",
|
||||
"number": 210,
|
||||
"pull_request": {
|
||||
"url": "https://api.github.com/repos/FoxxMD/multi-scrobbler/pulls/210",
|
||||
"id": 2123337348,
|
||||
"node_id": "PR_kwDOEpP-x85-j46E",
|
||||
"number": 210,
|
||||
"state": "open",
|
||||
"locked": false,
|
||||
"merge_commit_sha": "349bea1f4580b9e61aa22446eae73f8b1225bbb7",
|
||||
"labels": [
|
||||
{
|
||||
"id": 2494982061,
|
||||
"node_id": "MDU6TGFiZWwyNDk0OTgyMDYx",
|
||||
"url": "https://api.github.com/repos/FoxxMD/multi-scrobbler/labels/bug",
|
||||
"name": "bug",
|
||||
"color": "d73a4a",
|
||||
"default": true,
|
||||
"description": "Something isn't working"
|
||||
},
|
||||
{
|
||||
"id": 7131135993,
|
||||
"node_id": "LA_kwDOEpP-x88AAAABqQx_-Q",
|
||||
"url": "https://api.github.com/repos/FoxxMD/multi-scrobbler/labels/safe%20to%20test",
|
||||
"name": "safe to test",
|
||||
"color": "D4C5F9",
|
||||
"default": false,
|
||||
"description": "trusted to build image"
|
||||
}
|
||||
],
|
||||
"milestone": null,
|
||||
"draft": false,
|
||||
"head": {
|
||||
"label": "FoxxMD:GH-209/jellyfinCleanup",
|
||||
"ref": "GH-209/jellyfinCleanup",
|
||||
"sha": "e251da9b8eecfcb3aba8756393e5075d0092794b",
|
||||
"user": {
|
||||
"login": "FoxxMD",
|
||||
"id": 4663766,
|
||||
"node_id": "MDQ6VXNlcjQ2NjM3NjY=",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/4663766?v=4",
|
||||
"gravatar_id": "",
|
||||
"url": "https://api.github.com/users/FoxxMD",
|
||||
"html_url": "https://github.com/FoxxMD",
|
||||
"followers_url": "https://api.github.com/users/FoxxMD/followers",
|
||||
"following_url": "https://api.github.com/users/FoxxMD/following{/other_user}",
|
||||
"gists_url": "https://api.github.com/users/FoxxMD/gists{/gist_id}",
|
||||
"starred_url": "https://api.github.com/users/FoxxMD/starred{/owner}{/repo}",
|
||||
"subscriptions_url": "https://api.github.com/users/FoxxMD/subscriptions",
|
||||
"organizations_url": "https://api.github.com/users/FoxxMD/orgs",
|
||||
"repos_url": "https://api.github.com/users/FoxxMD/repos",
|
||||
"events_url": "https://api.github.com/users/FoxxMD/events{/privacy}",
|
||||
"received_events_url": "https://api.github.com/users/FoxxMD/received_events",
|
||||
"type": "User",
|
||||
"site_admin": false
|
||||
},
|
||||
"repo": {
|
||||
"id": 311688903,
|
||||
"node_id": "MDEwOlJlcG9zaXRvcnkzMTE2ODg5MDM=",
|
||||
"name": "multi-scrobbler",
|
||||
"full_name": "FoxxMD/multi-scrobbler",
|
||||
"private": false,
|
||||
"owner": {
|
||||
"login": "FoxxMD",
|
||||
"id": 4663766,
|
||||
"node_id": "MDQ6VXNlcjQ2NjM3NjY=",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/4663766?v=4",
|
||||
"gravatar_id": "",
|
||||
"url": "https://api.github.com/users/FoxxMD",
|
||||
"html_url": "https://github.com/FoxxMD",
|
||||
"followers_url": "https://api.github.com/users/FoxxMD/followers",
|
||||
"following_url": "https://api.github.com/users/FoxxMD/following{/other_user}",
|
||||
"gists_url": "https://api.github.com/users/FoxxMD/gists{/gist_id}",
|
||||
"starred_url": "https://api.github.com/users/FoxxMD/starred{/owner}{/repo}",
|
||||
"subscriptions_url": "https://api.github.com/users/FoxxMD/subscriptions",
|
||||
"organizations_url": "https://api.github.com/users/FoxxMD/orgs",
|
||||
"repos_url": "https://api.github.com/users/FoxxMD/repos",
|
||||
"events_url": "https://api.github.com/users/FoxxMD/events{/privacy}",
|
||||
"received_events_url": "https://api.github.com/users/FoxxMD/received_events",
|
||||
"type": "User",
|
||||
"site_admin": false
|
||||
},
|
||||
"html_url": "https://github.com/FoxxMD/multi-scrobbler",
|
||||
"description": "Scrobble plays from multiple sources to multiple clients",
|
||||
"fork": false,
|
||||
"created_at": "2020-11-10T14:45:38Z",
|
||||
"updated_at": "2024-10-14T09:21:37Z",
|
||||
"pushed_at": "2024-10-14T15:43:00Z",
|
||||
"allow_forking": true,
|
||||
"is_template": false,
|
||||
"web_commit_signoff_required": false,
|
||||
"visibility": "public",
|
||||
"forks": 16,
|
||||
"open_issues": 19,
|
||||
"watchers": 369,
|
||||
"default_branch": "master",
|
||||
"allow_squash_merge": true,
|
||||
"allow_merge_commit": true,
|
||||
"allow_rebase_merge": true,
|
||||
"allow_auto_merge": false,
|
||||
"delete_branch_on_merge": false,
|
||||
"allow_update_branch": false,
|
||||
"use_squash_pr_title_as_default": false,
|
||||
"squash_merge_commit_message": "COMMIT_MESSAGES",
|
||||
"squash_merge_commit_title": "COMMIT_OR_PR_TITLE",
|
||||
"merge_commit_message": "PR_TITLE",
|
||||
"merge_commit_title": "MERGE_MESSAGE"
|
||||
}
|
||||
},
|
||||
"base": {
|
||||
"label": "FoxxMD:master",
|
||||
"ref": "master",
|
||||
"sha": "9c21db8172b6beff9d321e831e9353bf17e92a37",
|
||||
"repo": {
|
||||
"id": 311688903,
|
||||
"node_id": "MDEwOlJlcG9zaXRvcnkzMTE2ODg5MDM=",
|
||||
"name": "multi-scrobbler",
|
||||
"full_name": "FoxxMD/multi-scrobbler",
|
||||
"private": false,
|
||||
"fork": false,
|
||||
"created_at": "2020-11-10T14:45:38Z",
|
||||
"updated_at": "2024-10-14T09:21:37Z",
|
||||
"pushed_at": "2024-10-14T15:43:00Z",
|
||||
"allow_forking": true,
|
||||
"is_template": false,
|
||||
"web_commit_signoff_required": false,
|
||||
"visibility": "public",
|
||||
"forks": 16,
|
||||
"open_issues": 19,
|
||||
"watchers": 369,
|
||||
"default_branch": "master",
|
||||
"allow_squash_merge": true,
|
||||
"allow_merge_commit": true,
|
||||
"allow_rebase_merge": true,
|
||||
"allow_auto_merge": false,
|
||||
"delete_branch_on_merge": false,
|
||||
"allow_update_branch": false,
|
||||
"use_squash_pr_title_as_default": false,
|
||||
"squash_merge_commit_message": "COMMIT_MESSAGES",
|
||||
"squash_merge_commit_title": "COMMIT_OR_PR_TITLE",
|
||||
"merge_commit_message": "PR_TITLE",
|
||||
"merge_commit_title": "MERGE_MESSAGE"
|
||||
}
|
||||
},
|
||||
"author_association": "OWNER",
|
||||
"auto_merge": null,
|
||||
"active_lock_reason": null,
|
||||
"merged": false,
|
||||
"mergeable": null,
|
||||
"rebaseable": null,
|
||||
"mergeable_state": "unknown",
|
||||
"merged_by": null,
|
||||
"comments": 0,
|
||||
"review_comments": 0,
|
||||
"maintainer_can_modify": false,
|
||||
"commits": 5,
|
||||
"additions": 84,
|
||||
"deletions": 28,
|
||||
"changed_files": 5
|
||||
},
|
||||
"before": "335ee0cd601f5a82e36643c812507b2b53e7daf0",
|
||||
"after": "e251da9b8eecfcb3aba8756393e5075d0092794b"
|
||||
}
|
||||
@@ -37,10 +37,10 @@ jobs:
|
||||
- name: Prepare
|
||||
run: |
|
||||
platform=${{ matrix.platform }}
|
||||
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
||||
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
||||
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Set short git commit SHA
|
||||
id: appvars
|
||||
@@ -64,17 +64,17 @@ jobs:
|
||||
run: |
|
||||
echo appversion=$APP_VERSION >>${GITHUB_OUTPUT}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
${{ env.DOCKERHUB_SLUG }}
|
||||
${{ env.GHCR_SLUG }}
|
||||
labels: |
|
||||
org.opencontainers.image.title=Multi-Scrobbler
|
||||
org.opencontainers.image.description=Scrobble from many sources to many clients
|
||||
org.opencontainers.image.vendor=FoxxMD
|
||||
# - name: Extract metadata (tags, labels) for Docker
|
||||
# id: meta
|
||||
# uses: docker/metadata-action@v5
|
||||
# with:
|
||||
# images: |
|
||||
# ${{ env.DOCKERHUB_SLUG }}
|
||||
# ${{ env.GHCR_SLUG }}
|
||||
# labels: |
|
||||
# org.opencontainers.image.title=Multi-Scrobbler
|
||||
# org.opencontainers.image.description=Scrobble from many sources to many clients
|
||||
# org.opencontainers.image.vendor=FoxxMD
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
@@ -138,6 +138,19 @@ jobs:
|
||||
path: /tmp/digests
|
||||
pattern: digests-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
@@ -155,7 +168,7 @@ jobs:
|
||||
type=edge
|
||||
|
||||
# maybe re-enable branch-named tags in the futures
|
||||
#type=ref,event=branch,enable=${{ !endsWith(github.ref, 'master') }}
|
||||
type=ref,event=branch,enable=${{ !endsWith(github.ref, 'master') }}
|
||||
|
||||
# tag non-prelease as latest -- has a higher priority than regular tag so it shows first in registries
|
||||
type=match,pattern=\d.\d.\d$,priority=901
|
||||
@@ -169,27 +182,17 @@ jobs:
|
||||
org.opencontainers.image.description=Scrobble from many sources to many clients
|
||||
org.opencontainers.image.vendor=FoxxMD
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Create manifest list and push
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
docker buildx imagetools create $(jq -cr '.tags | map(select(startswith("${{ env.DOCKERHUB_SLUG }}")) | "-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf '${{ env.DOCKERHUB_SLUG }}@sha256:%s ' *)
|
||||
docker buildx imagetools create $(jq -cr '.tags | map(select(startswith("${{ env.GHCR_SLUG }}")) | "-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf '${{ env.GHCR_SLUG }}@sha256:%s ' *)
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf '${{ env.GHCR_SLUG }}@sha256:%s ' *)
|
||||
|
||||
# - name: Inspect image
|
||||
# run: |
|
||||
# docker buildx imagetools inspect ${{ env.REGISTRY_IMAGE }}:${{ steps.meta.outputs.version }}
|
||||
- name: Inspect image
|
||||
run: |
|
||||
docker buildx imagetools inspect ${{ env.DOCKERHUB_SLUG }}:${{ steps.meta.outputs.version }}
|
||||
docker buildx imagetools inspect ${{ env.GHCR_SLUG }}:${{ steps.meta.outputs.version }}
|
||||
@@ -0,0 +1,10 @@
|
||||
name: PR Workflow
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'master'
|
||||
|
||||
jobs:
|
||||
test:
|
||||
uses: ./.github/workflows/testAndSanity.yml
|
||||
@@ -11,24 +11,6 @@ on:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Run Tests and Build (sanity)
|
||||
name: Tests / Build / Sanity Run
|
||||
if: github.actor == 'dependabot[bot]'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '18.x'
|
||||
cache: 'npm'
|
||||
- name: Install dev dependencies
|
||||
run: npm ci
|
||||
- name: Build Backend
|
||||
run: 'npm run build:backend'
|
||||
- name: Test Backend
|
||||
run: npm run test
|
||||
- name: Install Docs Deps
|
||||
run: npm run docs:install
|
||||
- name: Build
|
||||
run: npm run build
|
||||
uses: ./.github/workflows/testAndSanity.yml
|
||||
|
||||
@@ -18,7 +18,12 @@ jobs:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
- name: Install app dev dependencies
|
||||
run: npm ci
|
||||
- name: Generate schema files
|
||||
run: npm run schema
|
||||
|
||||
- name: Install docusaurus dependencies
|
||||
run: npm ci
|
||||
working-directory: ./docsite
|
||||
- name: Build website
|
||||
|
||||
+153
-52
@@ -1,5 +1,9 @@
|
||||
name: PR Workflow
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.head.sha }}
|
||||
cancel-in-progress: true
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types:
|
||||
@@ -12,29 +16,16 @@ on:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Run Tests
|
||||
runs-on: ubuntu-latest
|
||||
if: contains(github.event.pull_request.labels.*.name, 'safe to test')
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '18.x'
|
||||
cache: 'npm'
|
||||
- name: Install dev dependencies
|
||||
run: npm ci
|
||||
- name: Build Backend
|
||||
run: 'npm run build:backend'
|
||||
- name: Test Backend
|
||||
run: npm run test
|
||||
uses: ./.github/workflows/testAndSanity.yml
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
release-snapshot:
|
||||
name: Release snapshot
|
||||
runs-on: ubuntu-latest
|
||||
build-snapshot:
|
||||
name: Build OCI snapshot
|
||||
needs: test
|
||||
if: contains(github.event.pull_request.labels.*.name, 'safe to test')
|
||||
runs-on: ${{ matrix.os }}
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
@@ -42,62 +33,172 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- dockerfile: ./Dockerfile
|
||||
suffix: ''
|
||||
platforms: 'linux/amd64,linux/arm64'
|
||||
# - dockerfile: ./alpine.Dockerfile
|
||||
# suffix: '-alpine'
|
||||
# platforms: 'linux/amd64,linux/arm64'
|
||||
- os: ubuntu-latest
|
||||
arch: amd64
|
||||
platform: linux/amd64
|
||||
- os: ubuntu-24.04-arm
|
||||
arch: arm64
|
||||
platform: linux/arm64
|
||||
steps:
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Prepare
|
||||
run: |
|
||||
platform=${{ matrix.platform }}
|
||||
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
||||
|
||||
- name: Set up Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
# list all registries to push to and join all non-empty with comma
|
||||
# https://unix.stackexchange.com/a/693165/116849
|
||||
# https://stackoverflow.com/a/9429887/1469797
|
||||
strings=("${{vars.DOCKERHUB_SLUG}}" "${{vars.GHCR_SLUG}}")
|
||||
for i in ${!strings[@]}; do [[ -z ${strings[i]} ]] && unset strings[i]; done
|
||||
joined_string=$(IFS=, ; echo "${strings[*]}")
|
||||
echo "REGISTRIES_JOINED=$joined_string" >> $GITHUB_ENV
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
# do not use here!
|
||||
# instead we can specify repo#sha in build-push-action so we only deal with foreign SHA for actually building image
|
||||
# - uses: actions/checkout@v4
|
||||
# with:
|
||||
# ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- name: Set short git commit SHA
|
||||
run: |
|
||||
short=$(echo "${{ github.event.pull_request.head.sha }}" | head -c7)
|
||||
echo "COMMIT_SHORT_SHA=$short" >> $GITHUB_ENV
|
||||
|
||||
- name: Get App Version
|
||||
id: appversion
|
||||
env:
|
||||
APP_VERSION: ${{ format('pr{0}-{1}', github.event.number, env.COMMIT_SHORT_SHA ) }}
|
||||
run: |
|
||||
echo appversion=$APP_VERSION >>${GITHUB_OUTPUT}
|
||||
|
||||
- name: Login to Docker Hub
|
||||
if: ${{ vars.DOCKERHUB_SLUG != '' }}
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
if: ${{ vars.GHCR_SLUG != '' }}
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
# metadata extract for docker labels/image names is done in merge job
|
||||
|
||||
- name: Set up Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
# https://github.com/docker/build-push-action/issues/671#issuecomment-1619353328
|
||||
# for caching
|
||||
- name: Build and push by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
# specify foreign repo#sha from PR that is used to build image
|
||||
context: ${{github.event.pull_request.head.repo.clone_url}}#${{github.event.pull_request.head.sha}}
|
||||
build-args: |
|
||||
APP_BUILD_VERSION=${{steps.appversion.outputs.appversion}}
|
||||
platforms: ${{ matrix.platform }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
outputs: type=image,"name=${{ env.REGISTRIES_JOINED }}",push-by-digest=true,name-canonical=true,push=true
|
||||
#cache-from: type=gha,scope=build-${{ env.PLATFORM_PAIR }}
|
||||
#cache-to: type=gha,scope=build-${{ env.PLATFORM_PAIR }}
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
mkdir -p /tmp/digests
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digests-${{ env.PLATFORM_PAIR }}
|
||||
path: /tmp/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
release-snapshot:
|
||||
name: Merge OCI Images and Push
|
||||
if: ${{ vars.DOCKERHUB_SLUG != '' || vars.GHCR_SLUG != '' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
needs:
|
||||
- build-snapshot
|
||||
- test
|
||||
steps:
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digests-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Login to Docker Hub
|
||||
if: ${{ vars.DOCKERHUB_SLUG != '' }}
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
if: ${{ vars.GHCR_SLUG != '' }}
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Extract metadata (tags, labels)
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
foxxmd/multi-scrobbler
|
||||
ghcr.io/foxxmd/multi-scrobbler
|
||||
${{ vars.DOCKERHUB_SLUG }}
|
||||
${{ vars.GHCR_SLUG }}
|
||||
# generate Docker tags based on the following events/attributes
|
||||
# https://github.com/docker/metadata-action/issues/247#issuecomment-1511259674 for NOT is default branch, eventually
|
||||
tags: |
|
||||
type=ref,event=pr,suffix=${{ matrix.suffix }}
|
||||
type=ref,event=pr
|
||||
flavor: |
|
||||
latest=false
|
||||
labels: |
|
||||
org.opencontainers.image.title=Multi-Scrobbler
|
||||
org.opencontainers.image.description=Scrobble from many sources to many clients
|
||||
org.opencontainers.image.vendor=FoxxMD
|
||||
|
||||
- name: Create manifest list and push dockerhub
|
||||
if: ${{ vars.DOCKERHUB_SLUG != '' }}
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf '${{ vars.DOCKERHUB_SLUG }}@sha256:%s ' *)
|
||||
|
||||
- name: Create manifest list and push gchr
|
||||
if: ${{ vars.GHCR_SLUG != '' }}
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf '${{ vars.GHCR_SLUG }}@sha256:%s ' *)
|
||||
|
||||
- name: Inspect image dockerhub
|
||||
if: ${{ vars.DOCKERHUB_SLUG != '' }}
|
||||
run: |
|
||||
docker buildx imagetools inspect ${{ vars.DOCKERHUB_SLUG }}:${{ steps.meta.outputs.version }}
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
- name: Inspect image ghcr
|
||||
if: ${{ vars.GHCR_SLUG != '' }}
|
||||
run: |
|
||||
docker buildx imagetools inspect ${{ vars.GHCR_SLUG }}:${{ steps.meta.outputs.version }}
|
||||
|
||||
- name: Build and push
|
||||
id: docker_build
|
||||
uses: docker/build-push-action@v5
|
||||
env:
|
||||
APP_VERSION: ${{ format('pr{0}-{1}', github.event.number, github.event.pull_request.head.sha ) }}
|
||||
with:
|
||||
context: .
|
||||
build-args: |
|
||||
APP_BUILD_VERSION=${{env.APP_VERSION}}
|
||||
file: ${{ matrix.dockerfile }}
|
||||
push: ${{ !env.ACT}}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: ${{ matrix.platforms }}
|
||||
|
||||
combine-and-comment:
|
||||
name: Leave comment
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# Based on https://docs.docker.com/build/ci/github-actions/multi-platform/#distribute-build-across-multiple-runners
|
||||
|
||||
name: Publish Docker image to Dockerhub
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
@@ -18,41 +24,54 @@ on:
|
||||
# release:
|
||||
# types: [ published ]
|
||||
|
||||
# define in GH Repository -> Actions -> Variables (or act .variables) to enable pushing to registries
|
||||
# -- will only push to registries that are defined
|
||||
# EX
|
||||
# DOCKERHUB_SLUG=foxxmd/multi-scrobbler
|
||||
# GHCR_SLUG=ghcr.io/foxxmd/multi-scrobbler
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Run Tests
|
||||
if: github.event_name != 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '18.x'
|
||||
cache: 'npm'
|
||||
- name: Install dev dependencies
|
||||
run: npm ci
|
||||
- name: Build Backend
|
||||
run: 'npm run build:backend'
|
||||
- name: Test Backend
|
||||
run: npm run test
|
||||
uses: ./.github/workflows/testAndSanity.yml
|
||||
|
||||
push_to_registry:
|
||||
name: Build and push container images
|
||||
if: github.event_name != 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
build:
|
||||
name: Build OCI Images
|
||||
if: ${{ github.event_name != 'pull_request' && (vars.DOCKERHUB_SLUG != '' || vars.GHCR_SLUG != '') }}
|
||||
needs: test
|
||||
# https://docs.github.com/en/actions/security-guides/automatic-token-authentication#permissions-for-the-github_token
|
||||
runs-on: ${{ matrix.os }}
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
arch: amd64
|
||||
platform: linux/amd64
|
||||
- os: ubuntu-24.04-arm
|
||||
arch: arm64
|
||||
platform: linux/arm64
|
||||
steps:
|
||||
- name: Prepare
|
||||
run: |
|
||||
platform=${{ matrix.platform }}
|
||||
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
||||
|
||||
# list all registries to push to and join all non-empty with comma
|
||||
# https://unix.stackexchange.com/a/693165/116849
|
||||
# https://stackoverflow.com/a/9429887/1469797
|
||||
strings=("${{vars.DOCKERHUB_SLUG}}" "${{vars.GHCR_SLUG}}")
|
||||
for i in ${!strings[@]}; do [[ -z ${strings[i]} ]] && unset strings[i]; done
|
||||
joined_string=$(IFS=, ; echo "${strings[*]}")
|
||||
echo "REGISTRIES_JOINED=$joined_string" >> $GITHUB_ENV
|
||||
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set short git commit SHA
|
||||
id: vars
|
||||
id: appvars
|
||||
# https://dev.to/hectorleiva/github-actions-and-creating-a-short-sha-hash-8b7
|
||||
# short sha available under env.COMMIT_SHORT_SHA
|
||||
run: |
|
||||
@@ -61,33 +80,116 @@ jobs:
|
||||
echo "COMMIT_SHORT_SHA=$calculatedSha" >> $GITHUB_ENV
|
||||
echo "COMMIT_BRANCH=$branchName" >> $GITHUB_ENV
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
- name: Get App Version
|
||||
id: appversion
|
||||
env:
|
||||
# use release instead of tags once version is correctly parsed
|
||||
#APP_VERSION: ${{ github.event.release.tag_name }}
|
||||
|
||||
# https://github.com/actions/runner/issues/409#issuecomment-752775072
|
||||
# https://stackoverflow.com/a/69919067/1469797
|
||||
APP_VERSION: ${{ contains(github.ref, 'refs/tags/') && github.ref_name || format('{0}-{1}', env.COMMIT_BRANCH, env.COMMIT_SHORT_SHA ) }}
|
||||
run: |
|
||||
echo appversion=$APP_VERSION >>${GITHUB_OUTPUT}
|
||||
|
||||
- name: Login to Docker Hub
|
||||
if: ${{ github.event_name != 'pull_request' && vars.DOCKERHUB_SLUG != '' }}
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
if: ${{ github.event_name != 'pull_request' && vars.GHCR_SLUG != '' }}
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
# metadata extract for docker labels/image names is done in merge job
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
# https://github.com/docker/build-push-action/issues/671#issuecomment-1619353328
|
||||
# for caching
|
||||
- name: Build and push by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
build-args: |
|
||||
APP_BUILD_VERSION=${{steps.appversion.outputs.appversion}}
|
||||
platforms: ${{ matrix.platform }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
outputs: type=image,"name=${{ env.REGISTRIES_JOINED }}",push-by-digest=true,name-canonical=true,push=true
|
||||
#cache-from: type=gha,scope=build-${{ env.PLATFORM_PAIR }}
|
||||
#cache-to: type=gha,scope=build-${{ env.PLATFORM_PAIR }}
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
mkdir -p /tmp/digests
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digests-${{ env.PLATFORM_PAIR }}
|
||||
path: /tmp/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
merge:
|
||||
name: Merge OCI Images and Push
|
||||
if: ${{ github.event_name != 'pull_request' && (vars.DOCKERHUB_SLUG != '' || vars.GHCR_SLUG != '') }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
needs:
|
||||
- build
|
||||
- test
|
||||
steps:
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digests-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Login to Docker Hub
|
||||
if: ${{ github.event_name != 'pull_request' && vars.DOCKERHUB_SLUG != '' }}
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
if: ${{ github.event_name != 'pull_request' && vars.GHCR_SLUG != '' }}
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Extract metadata (tags, labels)
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
foxxmd/multi-scrobbler
|
||||
ghcr.io/foxxmd/multi-scrobbler
|
||||
${{ vars.DOCKERHUB_SLUG }}
|
||||
${{ vars.GHCR_SLUG }}
|
||||
# generate Docker tags based on the following events/attributes
|
||||
# https://github.com/docker/metadata-action/issues/247#issuecomment-1511259674 for NOT is default branch, eventually
|
||||
tags: |
|
||||
type=edge
|
||||
|
||||
# maybe re-enable branch-named tags in the futures
|
||||
#type=ref,event=branch,enable=${{ !endsWith(github.ref, 'master') }}
|
||||
# push with branch name as tag if not master/main
|
||||
type=ref,event=branch,enable=${{ !endsWith(github.ref, 'master') }}
|
||||
|
||||
# tag non-prelease as latest -- has a higher priority than regular tag so it shows first in registries
|
||||
type=match,pattern=\d.\d.\d$,priority=901
|
||||
@@ -96,28 +198,31 @@ jobs:
|
||||
type=semver,pattern={{version}}
|
||||
# flavor: |
|
||||
# latest=false
|
||||
labels: |
|
||||
org.opencontainers.image.title=Multi-Scrobbler
|
||||
org.opencontainers.image.description=Scrobble from many sources to many clients
|
||||
org.opencontainers.image.vendor=FoxxMD
|
||||
|
||||
- name: Create manifest list and push dockerhub
|
||||
if: ${{ vars.DOCKERHUB_SLUG != '' }}
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf '${{ vars.DOCKERHUB_SLUG }}@sha256:%s ' *)
|
||||
|
||||
- name: Create manifest list and push gchr
|
||||
if: ${{ vars.GHCR_SLUG != '' }}
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf '${{ vars.GHCR_SLUG }}@sha256:%s ' *)
|
||||
|
||||
- name: Inspect image dockerhub
|
||||
if: ${{ vars.DOCKERHUB_SLUG != '' }}
|
||||
run: |
|
||||
docker buildx imagetools inspect ${{ vars.DOCKERHUB_SLUG }}:${{ steps.meta.outputs.version }}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build and push Docker image
|
||||
env:
|
||||
# use release instead of tags once version is correctly parsed
|
||||
#APP_VERSION: ${{ github.event.release.tag_name }}
|
||||
|
||||
# https://github.com/actions/runner/issues/409#issuecomment-752775072
|
||||
# https://stackoverflow.com/a/69919067/1469797
|
||||
APP_VERSION: ${{ contains(github.ref, 'refs/tags/') && github.ref_name || format('{0}-{1}', env.COMMIT_BRANCH, env.COMMIT_SHORT_SHA ) }}
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
# https://github.com/docker/build-push-action/issues/1026#issue-2041857786
|
||||
build-args: |
|
||||
APP_BUILD_VERSION=${{env.APP_VERSION}}
|
||||
push: ${{ !env.ACT }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
- name: Inspect image ghcr
|
||||
if: ${{ vars.GHCR_SLUG != '' }}
|
||||
run: |
|
||||
docker buildx imagetools inspect ${{ vars.GHCR_SLUG }}:${{ steps.meta.outputs.version }}
|
||||
@@ -0,0 +1,65 @@
|
||||
name: Tests and Sanity Run
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
node-version:
|
||||
description: "Node version"
|
||||
required: false
|
||||
default: '20.x'
|
||||
type: string
|
||||
ref:
|
||||
description: "Ref to checkout"
|
||||
required: false
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Tests / Build / Sanity Run
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ inputs.node-version }}
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install concurrently
|
||||
run: npm install -g concurrently
|
||||
|
||||
- name: Install dev dependencies
|
||||
run: npm run install:parallel
|
||||
|
||||
- name: Test Backend
|
||||
run: npm run test
|
||||
|
||||
- name: Build
|
||||
run: npm run build:parallel
|
||||
|
||||
# remove modules that might include dev stuff
|
||||
# so that in the next step we are sure that prod-only runs work correctly
|
||||
- name: Install Prod Deps
|
||||
run: |
|
||||
rm -rf node_modules && \
|
||||
rm -rf docsite/node_modules && \
|
||||
NODE_ENV=production npm ci --omit=dev
|
||||
|
||||
# run app for 10 seconds as sanity check to see if it errors for any reason
|
||||
# easy testcase for missing packages and init errors
|
||||
- name: Sanity Run
|
||||
run: |
|
||||
set +e
|
||||
export NODE_ENV=production
|
||||
timeout --preserve-status 10s node node_modules/.bin/tsx src/backend/index.ts
|
||||
exitcode="$?"
|
||||
if [[ "$exitcode" -eq 143 ]] || [[ "$exitcode" -eq 137 ]]; then
|
||||
echo "App stayed up long enough and exited with expected status"
|
||||
exit 0
|
||||
else
|
||||
echo "App exited with unexpected code $exitcode"
|
||||
exit "$exitcode"
|
||||
fi
|
||||
+3
-1
@@ -118,6 +118,7 @@ dist
|
||||
.pnp.*
|
||||
|
||||
config/*.json
|
||||
config/yti-*
|
||||
*.txt
|
||||
.idea/
|
||||
|
||||
@@ -133,4 +134,5 @@ flatpak/generated-sources.json
|
||||
build
|
||||
!setupProxy.js
|
||||
**/generated-sources.*
|
||||
.github/act/out
|
||||
.github/act/out
|
||||
tmp-*
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"$schema": "https://lnav.org/schemas/format-v1.schema.json",
|
||||
"error_log": {
|
||||
"title": "Multi-Scrobbler Log",
|
||||
"description": "Logs from Multi Scrobbler",
|
||||
"regex": {
|
||||
"std": {
|
||||
"pattern": "^\\[(?<timestamp>\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}(?:\\.\\d{3})?)(?:.*?)\\] (?<level>[A-Z]*) : (?<body>.*)"
|
||||
}
|
||||
},
|
||||
"level-field": "level",
|
||||
"level": {
|
||||
"info": "^(?i)(?:info)$",
|
||||
"debug": "^(?i)(?:debug)$",
|
||||
"error": "^(?i)(error)$",
|
||||
"warning": "^(?i)(warn)$",
|
||||
"trace": "^(?i)verbose$",
|
||||
"critical": "^(?i)(?:crit|Cr)$",
|
||||
"fatal": "^(?i)(?:alert|fatal|panic|Al|Em)$"
|
||||
},
|
||||
"sample": [
|
||||
{
|
||||
"line": "[2024-12-26 01:58:48.261 -0500] DEBUG : [App] [Sources] [Jellyfin - unnamed] Last activity was at 01:58:48-05:00 | Next check in 10.00s | No new tracks discovered",
|
||||
"level": "debug"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -1,5 +1,7 @@
|
||||
{
|
||||
"reporter": "dot",
|
||||
"extension": "ts",
|
||||
"import": "tsx/esm"
|
||||
"import": "tsx/esm",
|
||||
"spec": "./src/backend/tests/**/*.test.ts",
|
||||
"exit": true
|
||||
}
|
||||
|
||||
Vendored
+20
-5
@@ -4,9 +4,8 @@
|
||||
{
|
||||
"name": "dev",
|
||||
"type": "node",
|
||||
"runtimeVersion": "18.19.1",
|
||||
"request": "launch",
|
||||
// Debug current file in VSCode
|
||||
// Debug app in VSCode
|
||||
"program": "${workspaceFolder}/src/backend/index.ts",
|
||||
"runtimeExecutable": "${workspaceFolder}/node_modules/.bin/tsx",
|
||||
"console": "integratedTerminal",
|
||||
@@ -20,7 +19,6 @@
|
||||
{
|
||||
"name": "tsx",
|
||||
"type": "node",
|
||||
"runtimeVersion": "18.19.1",
|
||||
"request": "launch",
|
||||
// Debug current file in VSCode
|
||||
"program": "${file}",
|
||||
@@ -33,6 +31,25 @@
|
||||
"${workspaceFolder}/node_modules/**",
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "schema",
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
// Debug app in VSCode
|
||||
"program": "${workspaceFolder}/src/backend/utils/SchemaStaticUtil.ts",
|
||||
"runtimeExecutable": "${workspaceFolder}/node_modules/.bin/tsx",
|
||||
"console": "integratedTerminal",
|
||||
"internalConsoleOptions": "neverOpen",
|
||||
"skipFiles": [
|
||||
"<node_internals>/**",
|
||||
// Ignore all dependencies (optional)
|
||||
//"${workspaceFolder}/node_modules/**",
|
||||
],
|
||||
"resolveSourceMapLocations": [
|
||||
"${workspaceFolder}/src/**/*",
|
||||
"${workspaceFolder}/node_modules/**/*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"args": [
|
||||
"-u",
|
||||
@@ -47,7 +64,6 @@
|
||||
"--recursive",
|
||||
"${workspaceFolder}/src/backend/tests/**/*.test.ts"
|
||||
],
|
||||
"runtimeVersion": "18.19.1",
|
||||
"runtimeExecutable": "${workspaceFolder}/node_modules/.bin/tsx",
|
||||
"internalConsoleOptions": "openOnSessionStart",
|
||||
"name": "Mocha Tests",
|
||||
@@ -72,7 +88,6 @@
|
||||
"--config", "${workspaceRoot}/.mocharc.json",
|
||||
"${file}"
|
||||
],
|
||||
"runtimeVersion": "18.19.1",
|
||||
"runtimeExecutable": "${workspaceFolder}/node_modules/.bin/tsx",
|
||||
"internalConsoleOptions": "openOnSessionStart",
|
||||
"name": "Mocha Test on File",
|
||||
|
||||
Vendored
+3
-3
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"debug.javascript.terminalOptions": {
|
||||
"runtimeVersion": "18.19.1"
|
||||
}
|
||||
"mochaExplorer.require": "tsx/esm",
|
||||
"mochaExplorer.timeout": 1200000,
|
||||
"mochaExplorer.exit": true
|
||||
}
|
||||
Vendored
+21
-1
@@ -12,6 +12,26 @@
|
||||
"isDefault": true
|
||||
},
|
||||
"label": "tsc: build - src/backend/tsconfig.json"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "shell",
|
||||
"label": "compose-up-dev",
|
||||
"command": "docker-compose -f docker-compose.dev.yml up",
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "new"
|
||||
},
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"type": "shell",
|
||||
"label": "compose-build-dev",
|
||||
"command": "docker-compose -f docker-compose.dev.yml build",
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "new"
|
||||
},
|
||||
"problemMatcher": []
|
||||
}
|
||||
]
|
||||
}
|
||||
+15
-13
@@ -1,7 +1,7 @@
|
||||
FROM ghcr.io/linuxserver/baseimage-debian:bookworm as base
|
||||
FROM ghcr.io/linuxserver/baseimage-debian:bookworm AS base
|
||||
|
||||
ENV TZ=Etc/GMT
|
||||
ENV NODE_VERSION 20.11.1
|
||||
ENV NODE_VERSION=20.18.1
|
||||
|
||||
# borrowing openssl header removal trick from offical docker-node
|
||||
# https://github.com/nodejs/docker-node/blob/main/18/bookworm-slim/Dockerfile#L8
|
||||
@@ -49,6 +49,8 @@ RUN \
|
||||
|
||||
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
|
||||
|
||||
RUN npm install -g concurrently
|
||||
|
||||
ARG data_dir=/config
|
||||
VOLUME $data_dir
|
||||
ENV CONFIG_DIR=$data_dir
|
||||
@@ -57,10 +59,11 @@ COPY docker/root /
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
FROM base as build
|
||||
FROM base AS build
|
||||
|
||||
COPY --chown=abc:abc package*.json tsconfig.json ./
|
||||
COPY --chown=abc:abc patches ./patches
|
||||
COPY --chown=abc:abc docsite/package*.json tsconfig.json ./docsite/
|
||||
|
||||
# for debugging, so the build fails faster when timing out (arm64)
|
||||
#RUN npm config set fetch-retries 1 && \
|
||||
@@ -74,21 +77,20 @@ COPY --chown=abc:abc patches ./patches
|
||||
|
||||
# This FAILED for node < 20 when building arm64 but not amd64 (and alpine-based Dockerfile has no issues building arm64)
|
||||
# see https://github.com/FoxxMD/multi-scrobbler/issues/126
|
||||
RUN npm ci \
|
||||
--verbose \
|
||||
# --no-audit \
|
||||
&& chown -R root:root node_modules
|
||||
RUN npm run install:parallel \
|
||||
&& chown -R root:root node_modules \
|
||||
&& chown -R root:root docsite/node_modules
|
||||
|
||||
COPY --chown=abc:abc . /app
|
||||
|
||||
# need to set before build so server/client build is optimized and has constants (if needed)
|
||||
ENV NODE_ENV=production
|
||||
|
||||
RUN npm run docs:install && npm run build && rm -rf node_modules && rm -rf docsite/node_modules
|
||||
RUN npm run build:parallel && rm -rf node_modules && rm -rf docsite/node_modules
|
||||
|
||||
FROM base as app
|
||||
FROM base AS app
|
||||
|
||||
COPY --chown=abc:abc package*.json ./
|
||||
COPY --chown=abc:abc *.json *.js *.ts index.html ./
|
||||
COPY --chown=abc:abc patches ./patches
|
||||
COPY --from=build --chown=abc:abc /app/dist /app/dist
|
||||
COPY --from=build --chown=abc:abc /app/src /app/src
|
||||
@@ -103,11 +105,11 @@ ENV IS_DOCKER=true
|
||||
ARG APP_BUILD_VERSION
|
||||
ENV APP_VERSION=$APP_BUILD_VERSION
|
||||
|
||||
RUN npm ci --omit=dev \
|
||||
RUN npm ci --omit=dev --no-audit \
|
||||
&& npm cache clean --force \
|
||||
&& chown -R abc:abc node_modules \
|
||||
&& rm -rf node_modules/@types \
|
||||
/root/.cache
|
||||
&& rm -rf /root/.cache
|
||||
|
||||
|
||||
ARG webPort=9078
|
||||
ENV PORT=$webPort
|
||||
|
||||
@@ -13,13 +13,15 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c
|
||||
|
||||
* Supports scrobbling from many **Sources**
|
||||
* [Spotify](https://foxxmd.github.io/multi-scrobbler/docs/configuration#spotify)
|
||||
* [Plex](https://foxxmd.github.io/multi-scrobbler/docs/configuration#plex) or [Tautulli](https://foxxmd.github.io/multi-scrobbler/docs/configuration#tautulli)
|
||||
* [Plex](https://foxxmd.github.io/multi-scrobbler/docs/configuration#plex) or [~~Tautulli~~](https://foxxmd.github.io/multi-scrobbler/docs/configuration#tautulli)
|
||||
* [Subsonic-compatible APIs](https://foxxmd.github.io/multi-scrobbler/docs/configuration#subsonic) (like [Airsonic](https://airsonic.github.io/) and [Navidrome](https://www.navidrome.org/))
|
||||
* [Jellyfin](https://foxxmd.github.io/multi-scrobbler/docs/configuration#jellyfin)
|
||||
* [WebScrobbler](https://foxxmd.github.io/multi-scrobbler/docs/configuration#webscrobbler)
|
||||
* [Youtube Music](https://foxxmd.github.io/multi-scrobbler/docs/configuration#youtube-music)
|
||||
* [Last.fm](https://foxxmd.github.io/multi-scrobbler/docs/configuration#lastfm-source)
|
||||
* [Last.fm (Endpoint)](https://foxxmd.github.io/multi-scrobbler/docs/configuration#lastfm-endpoint)
|
||||
* [ListenBrainz](https://foxxmd.github.io/multi-scrobbler/docs/configuration#listenbrainz-source)
|
||||
* [ListenBrainz (Endpoint)](https://foxxmd.github.io/multi-scrobbler/docs/configuration#listenbrainz-endpoint)
|
||||
* [~~Deezer~~](https://foxxmd.github.io/multi-scrobbler/docs/configuration#deezer)
|
||||
* [MPRIS (Linux Desktop)](https://foxxmd.github.io/multi-scrobbler/docs/configuration#mpris)
|
||||
* [Mopidy](https://foxxmd.github.io/multi-scrobbler/docs/configuration#mopidy)
|
||||
@@ -29,6 +31,9 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c
|
||||
* [Musikcube](https://foxxmd.github.io/multi-scrobbler/docs/configuration#muikcube)
|
||||
* [MPD (Music Player Daemon)](https://foxxmd.github.io/multi-scrobbler/docs/configuration#mpd-music-player-daemon)
|
||||
* [VLC](https://foxxmd.github.io/multi-scrobbler/docs/configuration#vlc)
|
||||
* [Azuracast](https://foxxmd.github.io/multi-scrobbler/docs/configuration#azuracast)
|
||||
* [Yamaha MusicCast](https://foxxmd.github.io/multi-scrobbler/docs/configuration#yamaha-musiccast)
|
||||
* [Icecast](https://foxxmd.github.io/multi-scrobbler/docs/configuration#icecast)
|
||||
* Supports scrobbling to many **Clients**
|
||||
* [Maloja](https://foxxmd.github.io/multi-scrobbler/docs/configuration#maloja)
|
||||
* [Last.fm](https://foxxmd.github.io/multi-scrobbler/docs/configuration#lastfm)
|
||||
@@ -39,8 +44,8 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c
|
||||
* Graceful network and client failure handling (queued scrobbles that auto-retry)
|
||||
* Smart handling of credentials (persistent, authorization through app)
|
||||
* Easy configuration through ENVs or JSON
|
||||
* Modify data before scrobbling with [regular expression or search patterns](https://foxxmd.github.io/multi-scrobbler/docs/transforms)
|
||||
* Install using [Docker images for x86/ARM](https://foxxmd.github.io/multi-scrobbler/docs/installation#docker), [flatpak](https://foxxmd.github.io/multi-scrobbler/docs/installationr#flatpak), or [locally with NodeJS](https://foxxmd.github.io/multi-scrobbler/docs/installation#nodejs)
|
||||
* Modify data before scrobbling with [regular expression or search patterns](https://foxxmd.github.io/multi-scrobbler/docs/configuration/transforms)
|
||||
* Install using [Docker images for x86/ARM](https://foxxmd.github.io/multi-scrobbler/docs/installation#docker), [flatpak](https://foxxmd.github.io/multi-scrobbler/docs/installation#flatpak), or [locally with NodeJS](https://foxxmd.github.io/multi-scrobbler/docs/installation#nodejs)
|
||||
|
||||
[**Quick Start Guide**](https://foxxmd.github.io/multi-scrobbler/docs/quickstart)
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
[
|
||||
{
|
||||
"type": "azuracast",
|
||||
"enable": true,
|
||||
"name": "azura",
|
||||
"data": {
|
||||
"url": "ws://192.168.0.101",
|
||||
"station": "my-station-name",
|
||||
"monitorWhenLive": true,
|
||||
"monitorWhenListeners": 1
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,9 @@
|
||||
[
|
||||
{
|
||||
"name": "myLfm",
|
||||
"enable": true,
|
||||
"data": {
|
||||
"slug": "mySlug"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,9 @@
|
||||
[
|
||||
{
|
||||
"name": "myLz",
|
||||
"enable": true,
|
||||
"data": {
|
||||
"token": "myToken"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,10 @@
|
||||
[
|
||||
{
|
||||
"name": "myIcy",
|
||||
"enable": true,
|
||||
"data": {
|
||||
"url": "http://myIcecast.com/radio"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -7,12 +7,19 @@
|
||||
"url": "http://localhost:8096",
|
||||
"user": "FoxxMD",
|
||||
"apiKey": "c9fae8756fbf481ebd9c5bb56bd6540c",
|
||||
|
||||
// everything below is optional
|
||||
"usersAllow": ["FoxxMD","SomeOtherUser"],
|
||||
"usersBlock": ["AnotherUser"],
|
||||
"devicesAllow": ["firefox"],
|
||||
"devicesBlock": ["google-home"]
|
||||
"devicesBlock": ["google-home"],
|
||||
"librariesAllow": ["GoodMusic"],
|
||||
"librariesBlock": ["BadMusic"],
|
||||
"additionalAllowedLibraryTypes": ["musicvideos"],
|
||||
"allowUnknown": false,
|
||||
},
|
||||
"options": {
|
||||
"logPayload": true,
|
||||
"logFilterFailure": "debug"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[
|
||||
{
|
||||
"name": "myLastFm",
|
||||
"name": "myLastFmClient",
|
||||
"enable": true,
|
||||
"configureAs": "client",
|
||||
"data": {
|
||||
@@ -8,5 +8,15 @@
|
||||
"secret": "ec42e09d5ae0ee0f0816ca151008412a",
|
||||
"redirectUri": "http://localhost:9078/lastfm/callback"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "myLastFmSource",
|
||||
"enable": true,
|
||||
"configureAs": "source",
|
||||
"data": {
|
||||
"apiKey": "a89cba1569901a0671d5a9875fed4be1",
|
||||
"secret": "ec42e09d5ae0ee0f0816ca151008412a",
|
||||
"redirectUri": "http://localhost:9078/lastfm/callback"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
[
|
||||
{
|
||||
"name": "brainz",
|
||||
"name": "brainzClient",
|
||||
"enable": true,
|
||||
"configureAs": "client",
|
||||
"data": {
|
||||
"token": "029b081ba-9156-4pe7-88e5-3be671f5ea2b",
|
||||
"username": "FoxxMD"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "brainzSource",
|
||||
"enable": true,
|
||||
"configureAs": "source",
|
||||
"data": {
|
||||
"token": "029b081ba-9156-4pe7-88e5-3be671f5ea2b",
|
||||
"username": "FoxxMD"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
[
|
||||
{
|
||||
"name": "myYamaha",
|
||||
"enable": true,
|
||||
"data": {
|
||||
"url": "192.168.0.101"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -1,15 +1,21 @@
|
||||
[
|
||||
{
|
||||
"name": "MyPlex",
|
||||
"name": "MyPlexApi",
|
||||
"enable": true,
|
||||
"clients": [],
|
||||
"data": {
|
||||
"user": ["username@gmail.com","anotherUser@gmail.com"],
|
||||
"libraries": ["music","my podcasts"],
|
||||
"servers": ["myServer","anotherServer"],
|
||||
"options": {
|
||||
"logFilterFailure": "warn"
|
||||
"token": "1234",
|
||||
"url": "http://192.168.0.120:32400",
|
||||
"usersAllow": ["FoxxMD","SomeOtherUser"],
|
||||
"usersBlock": ["AnotherUser"],
|
||||
"devicesAllow": ["firefox"],
|
||||
"devicesBlock": ["google-home"],
|
||||
"librariesAllow": ["GoodMusic"],
|
||||
"librariesBlock": ["BadMusic"]
|
||||
},
|
||||
"options": {
|
||||
"logPayload": true,
|
||||
"logFilterFailure": "debug"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
[
|
||||
// DEPRECATED, use API Source instead
|
||||
// rename files to plex.json to use
|
||||
{
|
||||
"name": "MyPlex",
|
||||
"enable": true,
|
||||
"clients": [],
|
||||
"data": {
|
||||
"user": ["username@gmail.com","anotherUser@gmail.com"],
|
||||
"libraries": ["music","my podcasts"],
|
||||
"servers": ["myServer","anotherServer"],
|
||||
"options": {
|
||||
"logFilterFailure": "warn"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -2,7 +2,7 @@
|
||||
{
|
||||
"name": "MyWebScrobbler",
|
||||
"data": {
|
||||
"slug": null,
|
||||
"slug": "MyOptionalSlug",
|
||||
"whitelist": [],
|
||||
"blacklist": []
|
||||
}
|
||||
|
||||
@@ -4,8 +4,16 @@
|
||||
"enable": true,
|
||||
"clients": [],
|
||||
"data": {
|
||||
"cookie": "VISITOR_INFO1_LIVE=jMDXz2_L8rY; __Secure-3PAPISID=3AxsXpSXGqOInSDn1jEKn; DEVICE_INFO=ChxOekU0TmTBpjek5EWZ0G; YSC=7gZdl3Zdl3; SID=TwhNsaZRXYTAtXxzGyu6rZdpg2HvGROeW8J4Ym_FhkhoZMUYEQ.; __Secure-1PSID=TwhNOsaZRXYTyRBe4rxAtXRIKsIEtk_Qot2VLBNfHQrQ.; __Secure-3PSID=ZRXYTAtXRIKsIEtk_Qot2yRBerZdpg2HvvZRXYTAtXRIKsIEtk_Qot2yRBerkuZICFQ.; HSID=A1UMmELW79; SSID=AKhomOs; APISID=IlHHmuzkPdQzZZDhHn3; SAPISID=3AxsXpy0u75Qb/n1jEKn; __Secure-1PAPISID=3AxsXpQb/AkSDn1jEKn; LOGIN_INFO=AFmP6vFpyVCZZAIgDwbkhWMBBhluaIWAPP:QUQ314UW5NWMjNmd2ZUJnYnJsakdIMjZoaE5zVVMjNmd2ZZUiHRlb3ZlV3ZIcUVyRVIMjNmdjNmd2ZZUivYlNqX2ZNZUiHdUNFNFdaYmJIW1NkJRX3hqdlU2YnFESkFuSS1uTldnZVRmLXNjWFc5OUJuR3dTd3JsZGZYa2EtZFQ2a0k2Ry1KQQ==; PREF=volume=26; SIDCC=AFvI_94PxXwls-ndqpGfPgFX3FWj80y_94PxXwls-ndqfSh15sP; __Secure-1PSIDCC=AFvIBnUbRr96I96UCIp2U4T8HRVk2B0HfKzhzxwsiP; __Secure-3PSIDCC=AFvIB3bINuUN0ETDR9gO91wpwWIVmpGki3BxT3bINuUN0ETDR9gO91wCH",
|
||||
"authUser": "0"
|
||||
"cookie": "__Secure-1PSIDTS=sidts-CjEB3EgAEvCd-......",
|
||||
// either cookie or id/secret needs to be provided
|
||||
"clientId": "891098404....apps.googleusercontent.com",
|
||||
"clientSecret": "GOCS..."
|
||||
|
||||
// optional
|
||||
//"redirectUri": "http://my.custom.tld/api/ytmusic/callback?name=MyYTMusic"
|
||||
},
|
||||
"options": {
|
||||
"logDiff": true
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
version: '3.7'
|
||||
|
||||
services:
|
||||
multi-scrobbler:
|
||||
build:
|
||||
context: .
|
||||
env_file:
|
||||
- path: ./.env
|
||||
required: false
|
||||
#environment:
|
||||
#- TZ=Etc/GMT # Specify timezone from TZ Database name found here https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
|
||||
#- SPOTIFY_CLIENT_ID=
|
||||
#- SPOTIFY_CLIENT_SECRET=
|
||||
#- SPOTIFY_REDIRECT_URI=http://multi-scrobbler-host-IP:9078/callback ## Need to be whitelisted in Spotify dashboard and is used for creating the connection first time
|
||||
#- MALOJA_URL=http://maloja:42010
|
||||
#- MALOJA_API_KEY=
|
||||
#- PUID=1000 # required if running docker on linux host, see main README Docker setup instructions
|
||||
#- PGID=1000 # required if running docker on linux host, see main README Docker setup instructions
|
||||
|
||||
# set if using a source/client with redirect URI that you have not explicitly set and MS is NOT running on the same machine that you will view the dashboard from
|
||||
# EX: You will view MS dashboard at 'http://192.168.0.101:9078' -> set BASE_URL=http://192.168.0.101:9078
|
||||
#- BASE_URL=http://MyHostIP:9078
|
||||
volumes:
|
||||
- "./config:/config"
|
||||
#networks:
|
||||
# - (optional to add container to the same bridge network that maloja is inside to be able to use docker internal networking & dns to resolve and connect to maloja URL via http://maloja:port)
|
||||
ports:
|
||||
- 9078:9078 # first port is the HOST port multi-scrobbler will serve UI and API on
|
||||
restart: unless-stopped
|
||||
+1
-3
@@ -1,5 +1,3 @@
|
||||
version: '3.7'
|
||||
|
||||
services:
|
||||
multi-scrobbler:
|
||||
image: foxxmd/multi-scrobbler
|
||||
@@ -18,7 +16,7 @@ services:
|
||||
# EX: You will view MS dashboard at 'http://192.168.0.101:9078' -> set BASE_URL=http://192.168.0.101:9078
|
||||
#- BASE_URL=http://MyHostIP:9078
|
||||
volumes:
|
||||
- /path_on_host/multi-scrobbler-config:/config
|
||||
- "./config:/config"
|
||||
#networks:
|
||||
# - (optional to add container to the same bridge network that maloja is inside to be able to use docker internal networking & dns to resolve and connect to maloja URL via http://maloja:port)
|
||||
ports:
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
module.exports = {
|
||||
presets: [require.resolve('@docusaurus/core/lib/babel/preset')],
|
||||
};
|
||||
+103
-33
@@ -5,7 +5,7 @@ toc_max_heading_level: 5
|
||||
|
||||
## Connection Issues
|
||||
|
||||
### Plex/Tautulli/Jellyfin/Webscrobbler don't connect
|
||||
### Plex (Webhook)/Tautulli/Jellyfin(Webhook)/Webscrobbler don't connect
|
||||
|
||||
These sources are **ingress-based** which means that multi-scrobbler waits for the Plex/Tautulli/Jellyfin server (or Webscrobbler extension) to contact multi-scrobbler, as opposed to multi-scrobbler contacting them.
|
||||
|
||||
@@ -30,12 +30,7 @@ Check or try all these steps before submitting an issue:
|
||||
|
||||
##### Turn on Debug Logging
|
||||
|
||||
First, turn on **debug** logging for multi-scrobbler by setting the environmental variable `LOG_LEVEL=debug`:
|
||||
|
||||
* using node `LOG_LEVEL=debug ... node src/index.js`
|
||||
* using docker `docker run -e LOG_LEVEL=debug ... foxxmd/multi-scrobbler`
|
||||
|
||||
Check the output for any additional information.
|
||||
First, turn on **debug** logging for multi-scrobbler by setting the environmental variable `DEBUG_MODE=true`. Then, check the output for any additional information.
|
||||
|
||||
##### Check Host name and URL
|
||||
|
||||
@@ -67,7 +62,7 @@ Administration -> Dashboard -> Advanced -> Logs
|
||||
|
||||
See [Debugging the extension](https://github.com/web-scrobbler/web-scrobbler/wiki/Debug-the-extension) to get logs which should have information about failed requests.
|
||||
|
||||
### Jellyfin has warnings about undefined or missing data
|
||||
### Jellyfin (Webhook) has warnings about undefined or missing data
|
||||
|
||||
Make sure you have
|
||||
* [Configured the webhook plugin correctly](configuration/configuration.mdx#jellyfin)
|
||||
@@ -75,26 +70,11 @@ Make sure you have
|
||||
|
||||
multi-scrobbler is known to work on Jellyfin `10.8.9` with Webhook version `11.0.0.0`.
|
||||
|
||||
You can verify the payload sent from the webhook by modifying your jellyfin configuration to include `logPayload: true` which will output the raw payload to DEBUG level logging:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"name": "MyJellyfin",
|
||||
"clients": [],
|
||||
"data": {
|
||||
"users": ["FoxxMD"]
|
||||
},
|
||||
"options": {
|
||||
"logPayload": true
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
You can verify the payload sent from the webhook by enabling Debug Mode with the env `DEBUG_MODE=true` which will output the raw payload to DEBUG level logging.
|
||||
|
||||
If your issue persists and you open an Issue for it please include the raw payload logs in your report.
|
||||
|
||||
### Jellyfin has warnings about missing headers
|
||||
### Jellyfin (Webhook) has warnings about missing headers
|
||||
|
||||
If you experience issues trying to scrobble with Jellyfin and find this in your MS logs
|
||||
|
||||
@@ -130,15 +110,25 @@ Deezer has discontinued support for their API and the Deezer Source is now [**de
|
||||
|
||||
### Youtube Music fails after some time
|
||||
|
||||
The Youtube Music library relies on scraping the YTM site (pretending to be a browser) by using cookies/auth from your actual browser. It does its best to keep these up to date but since this is not an official way to access the service YTM may invalidate your access _to the authenticated session_ at any time. How this is triggered is unknown and not something multi-scrobbler can control.
|
||||
|
||||
If you see errors in multi-scrobbler for YTM that contain **401** or **403** like
|
||||
|
||||
```
|
||||
Error: Could not send the specified request to browse. Status code: 401
|
||||
```
|
||||
|
||||
then YTM has invalidated your access. [Follow the YTM instructions to retrieve a new set of cookies for multi-scrobbler](configuration/configuration.mdx#youtube-music) and then restart MS to potentially resolve the problem. See [this issue](https://github.com/FoxxMD/multi-scrobbler/issues/158) for further discussion of the problem.
|
||||
then YTM has invalidated your authentication.
|
||||
|
||||
First, ensure you are NOT using [YoutubeTV authentication.](configuration/configuration.mdx?ytmAuth=ytt#youtube-music) If you completed authentication by entering a "User Code" you are using YoutubeTV which has stopped working. You should reauthenticate using **Cookies** or **Custom OAuth.**
|
||||
|
||||
#### When using OAuth Client Authentication
|
||||
|
||||
Refresh your authentication by using the **(Re)authenticate** link from MS's web dashboard.
|
||||
|
||||
#### When using Cookies Authentication
|
||||
|
||||
The library MS uses relies on scraping the YTM site by using cookies from your actual browser to pretend it is a browser. It does its best to keep these up to date but since this is not an official way to access the service YTM may invalidate your access _to the authenticated session_ at any time. How this is triggered is unknown and not something multi-scrobbler can control. You can help limit the chance of your session being invalidated by [getting the cookie from an Incognito/Private Session](https://github.com/LuanRT/YouTube.js/issues/803#issuecomment-2504032666) and then immediately closing the browser afterwards.
|
||||
|
||||
To re-authenticate MS [follow the YTM instructions to retrieve a new set of cookies for multi-scrobbler](configuration/configuration.mdx?ytmAuth=cookie#youtube-music) and then restart MS to potentially resolve the problem.
|
||||
|
||||
## Configuration Issues
|
||||
|
||||
@@ -162,10 +152,12 @@ This is a limitation of the [Last.fm API](https://www.last.fm/api/show/track.scr
|
||||
|
||||
Multi-scrobbler works the same was the official Spotify-Last.fm integration works -- it only scrobbles the **first** artist on a multi-artist track.
|
||||
|
||||
### Jellyfin does not scrobble tracks with multiple artists correctly
|
||||
### Jellyfin (Webhook) does not scrobble tracks with multiple artists correctly
|
||||
|
||||
This is a limitation caused by the [Jellyfin webhook plugin](https://github.com/FoxxMD/multi-scrobbler/issues/70#issuecomment-1443804712) only sending the first artist to multi-scrobbler. This issues needs to be [fixed upstream on the Jellyfin webhook repository.](https://github.com/jellyfin/jellyfin-plugin-webhook/issues/166)
|
||||
|
||||
You can avoid this entirely by using the newer [Jellyfin API Source](configuration/configuration.mdx#jellyfin) which correctly handles multiple artists.
|
||||
|
||||
### Google Cast track information is missing/incorrect or MS player has weird times
|
||||
|
||||
The Google Cast integration relies on a few common fields in the data it receives from your casting device. Every platform that can cast (Spotify, Pandora, etc...) *should* use these fields the same but there are slight differences between their implementations that may confuse multi-scrobbler. Specific platforms may also return more information in non-common fields that are undocumented.
|
||||
@@ -194,10 +186,88 @@ Refer to [Force Media Tracking](configuration/configuration.mdx#forcing-media-tr
|
||||
|
||||
Before reporting an issue turn on metadata logging in the MS VLC configuration, [see the VLC documentation.](configuration/configuration.mdx#vlc-information-reporting)
|
||||
|
||||
### Youtube Music misses scrobbles
|
||||
### Youtube Music misses or duplicates scrobbles
|
||||
|
||||
In order for multi-scrobbler to accurately determine if a song has been scrobbled it needs **a source of truth.** For YTM this is a "history" list scraped from the YTM website. Unfortunately, the data in this list can be (often) inconsistent which makes it hard for multi-scrobbler to "trust" that it is correct and determine when/if new scrobbles occur. This inconsistency is not something multi-scrobbler can control -- it is a side-effect of having to use an unofficial method to access YTM (scraping).
|
||||
<details>
|
||||
|
||||
In order to compensate for this multi-scrobbler resets when it considers this list the "source of truth" based on if the list changes in an inconsistent way between consecutive checks. New scrobbles can only be detected when this list is "OK" as a source of truth for N+1 checks. Therefore, any new tracks that appear when the list is inconsistent will be ignored.
|
||||
<summary>How MS detects YTM history</summary>
|
||||
|
||||
See [this issue](https://github.com/FoxxMD/multi-scrobbler/issues/156#issuecomment-2312533486) for further discussion and a more detailed explanation of why this is happening and how multi-scrobbler compensates for it.
|
||||
In order for multi-scrobbler to accurately determine if a song has been scrobbled it needs **a source of truth.** For YTM this is a "history" list scraped from the YTM website. Unfortunately, the data in this list can be (often) inconsistent which makes it hard for multi-scrobbler to "trust" that it is correct and determine when/if new scrobbles occur. This inconsistency is not something multi-scrobbler can control -- it is a side-effect of having to use an unofficial method to access YTM (scraping).
|
||||
|
||||
To compensate for this multi-scrobbler resets when it considers this list the "source of truth" based on if the list changes in an inconsistent way between consecutive checks. New scrobbles can only be detected when this list is "OK" as a source of truth for N+1 checks. Therefore, any new tracks that appear when the list is inconsistent will be ignored.
|
||||
|
||||
Duplicate scrobbles can also occur if the change between two checks is technically consistent. For instance, if you listen to a track twice in some period, separated by other music, YTM will sometimes "remove" the track from the earlier time (further down in your history) and "re-add" it at the top of the history.
|
||||
|
||||
</details>
|
||||
|
||||
#### Reporting YTM scrobble issues
|
||||
|
||||
If you experience scrobble detection issues you can help improve MS's YTM heureistic by providing thorough feedback as [an issue.](https://github.com/FoxxMD/multi-scrobbler/issues/new?assignees=&labels=bug&projects=&template=01-bug-report.yml&title=bug%3A+) **Please do the following to provide the most useful report:**
|
||||
|
||||
##### Turn on Change Detection
|
||||
|
||||
In your YTM configuration (`ytmusic.json`) add `logDiff` under `options` like this:
|
||||
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "ytmusic",
|
||||
"name": "MyYTM",
|
||||
"data": { ... },
|
||||
"options": {
|
||||
"logDiff": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
or set either ENVs:
|
||||
|
||||
* `YTM_LOG_DIFF=true`
|
||||
* [`DEBUG_MODE=true`](configuration/configuration.mdx?#debug-mode)
|
||||
|
||||
This will cause MS to log YTM history changes similar to this:
|
||||
|
||||
```
|
||||
[Ytmusic - MyYTM] Changes from last seen list:
|
||||
1. (tuhe1CpHRxY) KNOWER - I’m The President --- undefined => Moved - Originally at 6
|
||||
2. (Mtg8V6Xa2nc) Vulfpeck - Romanian Drinking Song --- Schvitz => Moved - Originally at 1
|
||||
3. (rxbCaiyYSXM) Nightmares On Wax - You Wish --- In A Space Outta Sound => Moved - Originally at 2
|
||||
4. (tMt_YXr90AM) Gorillaz - O Green World --- undefined => Moved - Originally at 3
|
||||
...
|
||||
```
|
||||
|
||||
Which are essential to troubleshooting this behavior.
|
||||
|
||||
|
||||
##### Turn on File Logging
|
||||
|
||||
Optionally, if your logs are busy due to many other sources, you can log **only** YTM logs to a file. Turn this on with the `logToFile` config option:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "ytmusic",
|
||||
"name": "MyYTM",
|
||||
"data": { ... },
|
||||
"options": {
|
||||
"logDiff": true,
|
||||
"logToFile": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This will cause MS to write YTM logs to a file in your `logs` folder named `ytmusic-MyYTM.log` (based on your source name).
|
||||
|
||||
##### Provide Detail and Context
|
||||
|
||||
Provide a detailed account of how you were using YTM when the issue occurred, including things like:
|
||||
|
||||
* the platform listening on (desktop, mobile, 3rd party client, etc...)
|
||||
* any changes in platform
|
||||
* > I switched from desktop to listening on my phone...
|
||||
* how you were listening to music
|
||||
* > I was playing an album start to finish
|
||||
* > I listened to two songs in a row, then browsed for a new song in library by artist, then went back to a song in the queue...
|
||||
|
||||
Explain the expected behavior (it should have scrobbled songs x, y, then z) and what actually happened (it scrobbled songs x, then y, then x again, then z)
|
||||
|
||||
Provide ALL logs from the time when the issue occurred including logs from BEFORE (ideally 2-3 minutes of logs) and AFTER the issue.
|
||||
@@ -10,19 +10,25 @@ import SchemaLink from "../../src/components/SchemaLink";
|
||||
import AIOExample from "../../src/components/AIOExample";
|
||||
|
||||
import AIOConfig from '!!raw-loader!../../../config/config.json.example';
|
||||
import AzuracastConfig from '!!raw-loader!../../../config/azuracast.json.example';
|
||||
import ChromecastConfig from '!!raw-loader!../../../config/chromecast.json.example';
|
||||
import DeezerConfig from '!!raw-loader!../../../config/chromecast.json.example';
|
||||
import IcecastConfig from '!!raw-loader!../../../config/icecast.json.example';
|
||||
import JellyfinConfig from '!!raw-loader!../../../config/jellyfin.json.example';
|
||||
import JriverfinConfig from '!!raw-loader!../../../config/jriver.json.example';
|
||||
import KodiConfig from '!!raw-loader!../../../config/kodi.json.example';
|
||||
import LastfmConfig from '!!raw-loader!../../../config/lastfm.json.example';
|
||||
import LastfmEndpointConfig from '!!raw-loader!../../../config/endpointlfm.json.example';
|
||||
import ListenbrainzConfig from '!!raw-loader!../../../config/listenbrainz.json.example';
|
||||
import ListenbrainzEndpointConfig from '!!raw-loader!../../../config/endpointlz.json.example';
|
||||
import MalojaConfig from '!!raw-loader!../../../config/maloja.json.example';
|
||||
import MopidyConfig from '!!raw-loader!../../../config/mopidy.json.example';
|
||||
import MprisConfig from '!!raw-loader!../../../config/mpris.json.example';
|
||||
import MusikcubeConfig from '!!raw-loader!../../../config/musikcube.json.example';
|
||||
import MusiccastConfig from '!!raw-loader!../../../config/musiccast.json.example';
|
||||
import MPDConfig from '!!raw-loader!../../../config/mpd.json.example';
|
||||
import PlexConfig from '!!raw-loader!../../../config/plex.json.example';
|
||||
import PlexWebhookConfig from '!!raw-loader!../../../config/plex.webhook.json.example';
|
||||
import SpotifyConfig from '!!raw-loader!../../../config/spotify.json.example';
|
||||
import SubsonicConfig from '!!raw-loader!../../../config/subsonic.json.example';
|
||||
import TautulliConfig from '!!raw-loader!../../../config/tautulli.json.example';
|
||||
@@ -151,7 +157,7 @@ These options affect multi-scrobbler's behavior and are not specific to any sour
|
||||
Defines the URL that is used to generate default redirect URLs for authentication on [spotify](#spotify) and [lastfm](#lastfm) -- as well as some logging hints.
|
||||
|
||||
* Default => `http://localhost:9078`
|
||||
* Set with [ENV](./configuration?configType=env#configuration-types) `BASE_URL` or `baseUrl` [all-in-one configuration](./configuration?configType=aio#configuration-types)
|
||||
* Set with [ENV](./?configType=env#configuration-types) `BASE_URL` or `baseUrl` [all-in-one configuration](./?configType=aio#configuration-types)
|
||||
* If protocol is `http` or no protocol is specified MS will try to use port `9078` -- to override this explicitly set the port or use `https`
|
||||
|
||||
Useful when running with [docker](../installation/installation.mdx#docker) so that you do not need to specify redirect URLs for each configuration.
|
||||
@@ -177,20 +183,102 @@ Useful when running with [docker](../installation/installation.mdx#docker) so th
|
||||
|
||||
</details>
|
||||
|
||||
#### Debug Mode
|
||||
|
||||
Turning on Debug Mode will
|
||||
|
||||
* override and enable all optional "debugging" options found in configuration
|
||||
* set log output to DEBUG
|
||||
|
||||
Use this as a shortcut for enabling output that can be used for troubleshooting and issue reporting. Be aware that logs will likely be VERY noisy while Debug Mode is on. You should only have this mode on while gathering logs for troubleshooting and then turn it off afterwards.
|
||||
|
||||
To set debug mode either add it to [AIO `config.json`](./?configType=aio#configuration-types)
|
||||
|
||||
```json5 title="config.json"
|
||||
{
|
||||
"debugMode": true,
|
||||
"sources": [...],
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
or set the ENV `DEBUG_MODE=true`
|
||||
|
||||
#### Disable Web
|
||||
|
||||
If you do not need the dashboard and/or ingress sources, or have security concerns about ingress and cannot control your hosting environment, the web server and API can be disabled.
|
||||
|
||||
:::warning
|
||||
|
||||
Any **ingress-based sources will be unusable** (Plex, Tautulli, etc...) if this is disabled.
|
||||
Any **ingress-based sources will be unusable** (Tautulli, Webscrobbler, etc...) if this is disabled.
|
||||
|
||||
:::
|
||||
|
||||
Disable using either:
|
||||
|
||||
* ENV `DISABLE_WEB=true`
|
||||
* In [All-in-One File](./configuration?configType=aio#configuration-types) use the top-level property `"disableWeb": true`
|
||||
* In [All-in-One File](./?configType=aio#configuration-types) use the top-level property `"disableWeb": true`
|
||||
|
||||
## Common Source Functionality
|
||||
|
||||
Some options and functionality is shared between all or some Sources. Detailed below is information on what these features are and how to use/configured them.
|
||||
|
||||
#### Scrobble Thresholds
|
||||
|
||||
For Sources where multi-scrobbler monitors active listening, it uses one of two metrics to determine if a Song is scrobbable based on your listening:
|
||||
|
||||
* **percent** of track listened to (50% or more)
|
||||
* or amount of time (**duration**) track was listened to (4 minutes or more)
|
||||
|
||||
These default values are based on [last.fm's scrobble guidance.](https://www.last.fm/api/scrobbling#when-is-a-scrobble-a-scrobble)
|
||||
|
||||
Each Source can have these values customized using values in the Source's `options` in [file or aio config](./?configType=file#configuration-types):
|
||||
|
||||
```json5 title="azuracast.json"
|
||||
{
|
||||
"data": {
|
||||
// ...
|
||||
},
|
||||
"options": {
|
||||
"scrobbleThresholds": {
|
||||
"duration": 40, // scrobbable if listened to for 40 seconds or more
|
||||
"percent": 20 // scrobbable if listened to for 20% or more of the track's length
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
These can also be configured **globally** for all Sources using ENVs:
|
||||
|
||||
```
|
||||
SOURCE_SCROBBLE_DURATION=40
|
||||
SOURCE_SCROBBLE_PERCENT=20
|
||||
```
|
||||
|
||||
#### Should Scrobble Behavior
|
||||
|
||||
:::note
|
||||
|
||||
This feature is currently available only for some Sources. Check each Source's documentation to see if it supported.
|
||||
|
||||
:::
|
||||
|
||||
For _most_ Sources multi-scrobbler can determine if a user is listening and scrobble automatically. For Sources which multi-scrobbler cannot do this or require additional configuration, a user can indicate to MS that it should or should not scrobble. This action can be taken on the Web Dashboard by clicking the link next to **Should Scrobble** found in each Source's status card.
|
||||
|
||||
Additionally, for these Sources, the default behavior for Scrobbling can be explicitly set by adding the following value to a Source's `options` in [file or aio config](./?configType=file#configuration-types):
|
||||
|
||||
```json5 title="azuracast.json"
|
||||
{
|
||||
"data": {
|
||||
// ...
|
||||
},
|
||||
"options": {
|
||||
// set to true to always scrobble automatically
|
||||
// set to false to never scrobble automatically
|
||||
"systemScrobble": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Source Configurations
|
||||
|
||||
@@ -240,10 +328,133 @@ If your Spotify player has [Automix](https://community.spotify.com/t5/FAQs/What-
|
||||
|
||||
### [Plex](https://plex.tv)
|
||||
|
||||
Check the [instructions](plex.md) on how to setup a [webhooks](https://support.plex.tv/articles/115002267687-webhooks) to scrobble your plays.
|
||||
<Tabs groupId="plexType" queryString>
|
||||
<TabItem value="api" label="API">
|
||||
|
||||
Find your [**Plex Token**](https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/) and make note of the **URL** and **Port** used to connect to your Plex instance.
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Allowed Users and Defaults</summary>
|
||||
|
||||
**Multi-scrobbler will automatically scrobble for these users by default:**
|
||||
|
||||
* The User authenticated with the Plex Token
|
||||
* and the **Local User**
|
||||
|
||||
The Local User (`PLEX_LOCAL_USER`) is how Plex identifies anyone directly accessing the Plex UI from a local IP (who does not need to login).
|
||||
|
||||
To allow MS to scrobble for other users use `usersAllow` or `PLEX_USERS_ALLOW` (env) from the below configuration docs. However, because you are overriding the default settings you must also explicitly list the authenticated user and the Local User if you want them to also be able to scrobble.
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Examples</summary>
|
||||
|
||||
###### Defaults
|
||||
|
||||
If `usersallow` and `PLEX_USERS_ALLOW` are not defined then the Plex Token authenticated User and Local User will be scrobbled for.
|
||||
|
||||
|
||||
###### Only A Specific User
|
||||
|
||||
* `"usersallow": ["SomeUser"]` or
|
||||
* `PLEX_USERS_ALLOW: SomeUser`
|
||||
|
||||
Only the Plex user `SomeUser` will be scrobbled for. The Plex Token authenticated user and the Local User will not be scrobbled for.
|
||||
|
||||
###### A Specific User + Defaults
|
||||
|
||||
(Assuming the plex authenticated user is `FoxxMD`)
|
||||
|
||||
* `"usersallow": ["FoxxMD", "PLEX_LOCAL_USER", "SomeUser"]` or
|
||||
* `PLEX_USERS_ALLOW: FoxxMD,PLEX_LOCAL_USER,SomeUser`
|
||||
|
||||
The Plex user SomeUser, the Plex Token authenticated user (FoxxMD) and the Local User will be scrobbled for.
|
||||
|
||||
</details>
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Allowed Libraries and Defaults</summary>
|
||||
|
||||
By default multi-scrobbler will only scrobble media found in Plex libraries that are labelled as **Music.**
|
||||
* `librariesAllow` or `PLEX_LIBRARIES_ALLOW` will override this
|
||||
|
||||
</details>
|
||||
|
||||
#### Configuration
|
||||
|
||||
<Tabs groupId="configType" queryString>
|
||||
<TabItem value="env" label="ENV">
|
||||
| Environmental Variable | Required? | Default | Description |
|
||||
| ---------------------- | --------- | ------- | ---------------------------------------------------------------------- |
|
||||
| `PLEX_URL` | **Yes** | | The URL of the Plex server IE `http://localhost:32400` |
|
||||
| `PLEX_TOKEN` | **Yes** | | The **Plex Token** to use with the API |
|
||||
| `PLEX_USERS_ALLOW` | No | | Comma-separated list of usernames (from Plex) to scrobble for |
|
||||
| `PLEX_USERS_BLOCK` | No | | Comma-separated list of usernames (from Plex) to disallow scrobble for |
|
||||
| `PLEX_DEVICES_ALLOW` | No | | Comma-separated list of devices to scrobble from |
|
||||
| `PLEX_DEVICES_BLOCK` | No | | Comma-separated list of devices to disallow scrobbles from |
|
||||
| `PLEX_LIBRARIES_ALLOW` | No | | Comma-separated list of libraries to allow scrobbles from |
|
||||
| `PLEX_LIBRARIES_BLOCK` | No | | Comma-separated list of libraries to disallow scrobbles from |
|
||||
</TabItem>
|
||||
<TabItem value="file" label="File">
|
||||
<details>
|
||||
|
||||
<summary>Example</summary>
|
||||
|
||||
<CodeBlock title="CONFIG_DIR/plex.json" language="json5">{PlexConfig}</CodeBlock>
|
||||
|
||||
</details>
|
||||
|
||||
or <SchemaLink lower objectName="PlexApiSourceConfig"/>
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="aio" label="AIO">
|
||||
<details>
|
||||
|
||||
<summary>Example</summary>
|
||||
|
||||
<AIOExample data={PlexConfig} name="plex"/>
|
||||
|
||||
</details>
|
||||
|
||||
or <SchemaLink lower objectName="PlexApiSourceConfig"/>
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="webhook" label="Webhook (Deprecated)">
|
||||
|
||||
:::warning[Deprecated]
|
||||
|
||||
Multi-scrobbler < 0.8.7 used [webhooks](https://support.plex.tv/articles/115002267687-webhooks) to support Plex scrobbling. This approach has been deprecated in favor of using Plex's API directly which has many benefits including **not requiring Plex Pass.**
|
||||
|
||||
The Plex Webhook Source will be removed in an upcoming release. You should migrate to the API source as soon as possible.
|
||||
|
||||
:::
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Migrating to API</summary>
|
||||
|
||||
* Follow the instructions in the API tab
|
||||
* The `user` (`PLEX_USER`) setting has been renamed `usersAllow` (`PLEX_USERS_ALLOW`)
|
||||
* If you were using this filter to ensure only scrobbles from yourself were registered then you no longer need this setting -- by default MS will only scrobble for the user the Plex Token is from.
|
||||
* The `servers` setting is no longer available as MS only scrobbles from the server the Plex token is from.
|
||||
* If you need to scrobble for multiple servers set up each server as a separate Plex API source with a separate token.
|
||||
* The `libraries` setting has been renamed to `librariesAllow`
|
||||
|
||||
</details>
|
||||
|
||||
* In the Plex dashboard Navigate to your **Account/Settings** and find the **Webhooks** page
|
||||
* Click **Add Webhook**
|
||||
* URL -- `http://localhost:9078/plex` (substitute your domain if different than the default)
|
||||
* **Save Changes**
|
||||
|
||||
##### Configuration
|
||||
|
||||
<Tabs groupId="configType" queryString>
|
||||
<TabItem value="env" label="ENV">
|
||||
| Environmental Variable | Required | Default | Description |
|
||||
@@ -256,7 +467,7 @@ Check the [instructions](plex.md) on how to setup a [webhooks](https://support.p
|
||||
|
||||
<summary>Example</summary>
|
||||
|
||||
<CodeBlock title="CONFIG_DIR/plex.json" language="json5">{PlexConfig}</CodeBlock>
|
||||
<CodeBlock title="CONFIG_DIR/plex.json" language="json5">{PlexWebhookConfig}</CodeBlock>
|
||||
|
||||
</details>
|
||||
|
||||
@@ -267,7 +478,7 @@ Check the [instructions](plex.md) on how to setup a [webhooks](https://support.p
|
||||
|
||||
<summary>Example</summary>
|
||||
|
||||
<AIOExample data={PlexConfig} name="plex"/>
|
||||
<AIOExample data={PlexWebhookConfig} name="plex"/>
|
||||
|
||||
</details>
|
||||
|
||||
@@ -275,8 +486,19 @@ Check the [instructions](plex.md) on how to setup a [webhooks](https://support.p
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
### [Tautulli](https://tautulli.com)
|
||||
|
||||
:::warning[Deprecated]
|
||||
|
||||
Multi-scrobbler >= 0.8.8 supports a Plex Source that [directly uses the API](./?plexType=api#plex) and removes the need to use Tautulli since it does not require Plex Pass.
|
||||
|
||||
The Tautuilli Source will be removed in an upcoming release. You should migrate to the Plex API source as soon as possible.
|
||||
|
||||
:::
|
||||
|
||||
Check the [instructions](plex.md) on how to setup a notification agent.
|
||||
|
||||
#### Configuration
|
||||
@@ -359,16 +581,19 @@ Can use this source for any application that implements the [Subsonic API](http:
|
||||
|
||||
### [Jellyfin](https://jellyfin.org/)
|
||||
|
||||
:::warning[Webhook Method Deprecated]
|
||||
|
||||
In multi-scrobbler **below v0.8.4** communication with Jellyfin was done using Jellyfin's **Webhook** plugin. This has been deprecated in favor of directly using Jeyllfin's API for a better experience in multi-scrobbler.
|
||||
|
||||
Webhook functionality will be removed in an upcoming release of MS. If you are still using the Webhook method you should migrate to the API method as soon as possible.
|
||||
|
||||
:::
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Migrating from Webhook (multi-scrobbler below `v0.9.0`) to API</summary>
|
||||
<summary>Migrating from Webhook (multi-scrobbler below `v0.8.4`) to API</summary>
|
||||
|
||||
In multi-scrobbler **below v0.9.0** communication with Jellyfin was done using Jellyfin's **Webhook** plugin.
|
||||
This has been deprecated in favor of directly using Jeyllfin's API for a better experience in multi-scrobbler.
|
||||
|
||||
### To Migrate:
|
||||
|
||||
* Remove the **Webhook** plugin (if not using it for anything else)
|
||||
* Remove the Jellyfin **Webhook** plugin (if not using it for anything else)
|
||||
* Follow the new instructions below (outside this section) to setup API usage
|
||||
* The `servers` (`JELLYFIN_SERVERS`) setting is no longer available as MS only scrobbles from the server using the API anyways.
|
||||
* If you need to scrobble for multiple servers set up each server as a separate Jellyfin source
|
||||
@@ -387,24 +612,36 @@ Must be using Jellyfin 10.7 or greater
|
||||
|
||||
It is **recommended** to use API Key + username but if you are not an admin for your Jellyfin instance you can also authenticate with your Jellyfin username and **password.**
|
||||
|
||||
#### Configuration
|
||||
:::tip[Important Defaults]
|
||||
|
||||
:::tip
|
||||
By default...
|
||||
|
||||
By default, multi-scrobbler will only scrobble for the user authenticated with the API. Allowed Users are only necessary if you want to scrobble for additional users.
|
||||
* multi-scrobbler will **only** scrobble for the user authenticated with the API.
|
||||
* Allowed Users (`usersAllow` or `JELLYFIN_USERS_ALLOW`) are only necessary if you want to scrobble for additional users.
|
||||
* multi-scrobbler will **only** scrobble media found in Jellyfin libraries that were labelled as **Music.**
|
||||
* `librariesAllow` or `JELLYFIN_LIBRARIES_ALLOW` will override this
|
||||
* OR use `additionalAllowedLibraryTypes` to allow more types (like `mixed` or `book` for audiobooks)
|
||||
* multi-scrobbler will **only** scrobble media Jellyfin detects as **Audio.**
|
||||
* To force multi-scrobbler to scrobble when media is detected as **Unknown** use `"allowUnknown": true` in file/aio configuration.
|
||||
|
||||
:::
|
||||
|
||||
#### Configuration
|
||||
|
||||
<Tabs groupId="configType" queryString>
|
||||
<TabItem value="env" label="ENV">
|
||||
| Environmental Variable | Required? | Default | Description |
|
||||
|----------------------------|-----------|---------|--------------------------------------------------------------------------------------------|
|
||||
| -------------------------- | --------- | ------- | ------------------------------------------------------------------------------------------ |
|
||||
| `JELLYFIN_URL` | **Yes** | | The URL of the Jellyfin server IE `http://localhost:8096` |
|
||||
| `JELLYFIN_USER` | **Yes** | | The user to authenticate with the API |
|
||||
| `JELLYFIN_APIKEY` | No | | The API Key to use for authentication **(Must provide either apikey or password)** |
|
||||
| `JELLYFIN_PASSWORD` | No | | The password of the user to authenticate for. **(Must provide either apikey or password)** |
|
||||
| `JELLYFIN_USERS_ALLOW` | No | | Comma-separated list of usernames (from Jellyfin) to scrobble for |
|
||||
| `JELLYFIN_USERS_BLOCK` | No | | Comma-separated list of usernames (from Jellyfin) to disallow scrobble for |
|
||||
| `JELLYFIN_DEVICES_ALLOW` | No | | Comma-separated list of devices to scrobble from |
|
||||
| `JELLYFIN_DEVICES_BLOCK` | No | | Comma-separated list of devices to disallow scrobbles from |
|
||||
| `JELLYFIN_LIBRARIES_ALLOW` | No | | Comma-separated list of libraries to allow scrobbles from |
|
||||
| `JELLYFIN_LIBRARIES_BLOCK` | No | | Comma-separated list of libraries to disallow scrobbles from |
|
||||
</TabItem>
|
||||
<TabItem value="file" label="File">
|
||||
<details>
|
||||
@@ -415,7 +652,7 @@ By default, multi-scrobbler will only scrobble for the user authenticated with t
|
||||
|
||||
</details>
|
||||
|
||||
or <SchemaLink lower objectName="JellySourceConfig"/>
|
||||
or <SchemaLink lower objectName="JellyApiSourceConfig"/>
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="aio" label="AIO">
|
||||
@@ -467,8 +704,67 @@ See the [Last.fm (Client)](#lastfm) setup for registration instructions.
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
### [Last.fm (Endpoint)](https://www.last.fm)
|
||||
|
||||
This Source enables multi-scrobbler to accept scrobbles from outside applications as if it was a Last.fm server.
|
||||
|
||||
##### URL
|
||||
|
||||
If a **slug** is **not** provided in configuration then multi-scrobbler will accept Last.fm scrobbles at
|
||||
|
||||
```
|
||||
http://localhost:9078/2.0/
|
||||
```
|
||||
|
||||
which is the "standard" Last.fm server path for scrobbling.
|
||||
|
||||
Use a slug only if you need to setup multiple Last.fm Endpoint sources and cannot use different tokens.
|
||||
|
||||
If a slug is used then the URL will be:
|
||||
|
||||
```
|
||||
http://localhost:9078/api/lastfm/mySlug
|
||||
```
|
||||
|
||||
#### Configuration
|
||||
|
||||
<Tabs groupId="configType" queryString>
|
||||
<TabItem value="env" label="ENV">
|
||||
| Environmental Variable | Required? | Default | Description |
|
||||
| :--------------------- | :-------- | ------- | ------------------------------------------------------------------------------------------------------------------ |
|
||||
| `LFMENDPOINT_ENABLE` | No | | Use LFM Endpoint as a Source without any other configuration. Only required if slug/token are not provided as ENVs |
|
||||
| `LFM_SLUG` | No | | (Optional) The URL suffix to use for accepting LFM scrobbles |
|
||||
</TabItem>
|
||||
<TabItem value="file" label="File">
|
||||
<details>
|
||||
Change `configureAs` to `source`
|
||||
|
||||
<summary>Example</summary>
|
||||
|
||||
<CodeBlock title="CONFIG_DIR/endpointlfm.json" language="json5">{LastfmEndpointConfig}</CodeBlock>
|
||||
|
||||
</details>
|
||||
|
||||
or <SchemaLink lower objectName="LastFMEndpointSourceConfig"/>
|
||||
</TabItem>
|
||||
<TabItem value="aio" label="AIO">
|
||||
<details>
|
||||
Change `configureAs` to `source`
|
||||
|
||||
<summary>Example</summary>
|
||||
|
||||
<AIOExample data={LastfmEndpointConfig} name="endpointlfm"/>
|
||||
|
||||
</details>
|
||||
|
||||
or <SchemaLink lower objectName="LastFMEndpointSourceConfig"/>
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
### [Listenbrainz (Source)](https://listenbrainz.org)
|
||||
|
||||
This Source monitors a Listenbrainz account's scrobble history and then re-scrobbles discovered tracks to configured clients.
|
||||
|
||||
You will need to run your own Listenbrainz server or have an account [on the official instance](https://listenbrainz.org/login/)
|
||||
|
||||
On your [profile page](https://listenbrainz.org/profile/) find your **User Token** to use in the configuration.
|
||||
@@ -508,6 +804,73 @@ On your [profile page](https://listenbrainz.org/profile/) find your **User Token
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
### [Listenbrainz (Endpoint)](https://listenbrainz.org)
|
||||
|
||||
This Source enables multi-scrobbler to accept scrobbles from outside applications as if it was a Listenbrainz server. An example of this would be configuring [pano-scrobbler](https://github.com/kawaiiDango/pano-scrobbler) with a custom Listenbrainz URL to point to multi-scrobbler.
|
||||
|
||||
##### URL
|
||||
|
||||
If a **slug** is **not** provided in configuration then multi-scrobbler will accept Listenbrainz scrobbles at
|
||||
|
||||
```
|
||||
http://localhost:9078/1/submit-listens
|
||||
```
|
||||
|
||||
which is the "standard" Listenbrainz server path for scrobbling.
|
||||
|
||||
Use a slug only if you need to setup multiple Listenbrainz Endpoint sources and cannot use different tokens.
|
||||
|
||||
If a slug is used then the URL will be:
|
||||
|
||||
```
|
||||
http://localhost:9078/api/listenbrainz/mySlug
|
||||
```
|
||||
|
||||
:::note
|
||||
Some Listenbrainz applications may require custom Listenbrainz URLs to be a real domain (`example.com`) and/or use SSL (`https://`). In this case you should setup multi-scrobbler behind a reverse proxy to support this functionality. It is out of the scope of this project to do this solely within multi-scrobbler.
|
||||
:::
|
||||
|
||||
##### Token
|
||||
|
||||
Most Listenbrainz clients require a token (Authentication Token) to be provided during setup. This value can be anything you want, just make sure to use the same value for `token` in your multi-scrobbler configuration for the endpoint.
|
||||
|
||||
|
||||
#### Configuration
|
||||
|
||||
<Tabs groupId="configType" queryString>
|
||||
<TabItem value="env" label="ENV">
|
||||
| Environmental Variable | Required? | Default | Description |
|
||||
| :--------------------- | :-------- | ------- | ----------------------------------------------------------------------------------------------------------------- |
|
||||
| `LZENDPOINT_ENABLE` | No | | Use LZ Endpoint as a Source without any other configuration. Only required if slug/token are not provided as ENVs |
|
||||
| `LZE_TOKEN` | No | | LZ "Authentication Token" you provided to the scrobbling application |
|
||||
| `LZE_SLUG` | No | | (Optional) The URL suffix to use for accepting LZ scrobbles |
|
||||
</TabItem>
|
||||
<TabItem value="file" label="File">
|
||||
<details>
|
||||
Change `configureAs` to `source`
|
||||
|
||||
<summary>Example</summary>
|
||||
|
||||
<CodeBlock title="CONFIG_DIR/endpointlz.json" language="json5">{ListenbrainzEndpointConfig}</CodeBlock>
|
||||
|
||||
</details>
|
||||
|
||||
or <SchemaLink lower objectName="ListenbrainzEndpointSourceConfig"/>
|
||||
</TabItem>
|
||||
<TabItem value="aio" label="AIO">
|
||||
<details>
|
||||
Change `configureAs` to `source`
|
||||
|
||||
<summary>Example</summary>
|
||||
|
||||
<AIOExample data={ListenbrainzEndpointConfig} name="endpointlz"/>
|
||||
|
||||
</details>
|
||||
|
||||
or <SchemaLink lower objectName="ListenbrainzEndpointSourceConfig"/>
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
### [~~Deezer~~](https://deezer.com/)
|
||||
|
||||
:::warning
|
||||
@@ -576,34 +939,190 @@ After starting multi-scrobbler with credentials in-place open the dashboard (`ht
|
||||
|
||||
### [Youtube Music](https://music.youtube.com)
|
||||
|
||||
:::note
|
||||
:::warning
|
||||
|
||||
* YT Music authentication is "browser based" which means your credentials may expire after a (long?) period of time OR if you log out of https://music.youtube.com. In the event this happens just repeat the steps below to get new credentials. [See the FAQ](../FAQ.md#youtube-music-fails-after-some-time) for a more detailed explanation.
|
||||
* Communication to YT Music is **unofficial** and not supported or endorsed by Google. This means that **this integration may stop working at any time** if Google decides to change how YT Music works in the browser.
|
||||
* Due to this scrobble history from YTM is often inconsistent and can cause missed scrobbles. [See the FAQ](../FAQ.md#youtube-music-misses-scrobbles) for a more detailed explanation.
|
||||
Communication with YT Music is **unofficial** and not supported or endorsed by Google. This means that **this integration may stop working at any time** if Google decides to change how YT Music works in the browser.
|
||||
|
||||
:::
|
||||
|
||||
Credentials for YT Music are obtained from a browser request to https://music.youtube.com **once you are logged in.** [Specific requirements are here and summarized below:](https://github.com/nickp10/youtube-music-ts-api/blob/master/DOCUMENTATION.md#authenticate)
|
||||
:::tip[Scrobble Troubleshooting]
|
||||
|
||||
* Open a new tab
|
||||
* Open the developer tools (Ctrl-Shift-I) and select the “Network” tab
|
||||
* Go to https://music.youtube.com and ensure you are logged in
|
||||
Due to monitoring being unofficial, listening history from YTM can be inconsistent and can cause missed scrobbles.
|
||||
|
||||
Then...
|
||||
[**See the FAQ**](../FAQ.md#youtube-music-misses-or-duplicates-scrobbles) for a detailed explanation, how to see more details about MS's detection of tracks, and how to properly report an issue.
|
||||
|
||||
1. Find and select an authenticated POST request. The simplest way is to filter by /browse using the search bar of the developer tools. If you don’t see the request, try scrolling down a bit or clicking on the library button in the top bar.
|
||||
2. **Make sure **Headers** pane is selected and open
|
||||
3. In the **Request Headers** section find and copy the **entire value** found after `Cookie:` and use this as the `cookie` value in your multi-scrobbler config
|
||||
4. If present, in the **Request Headers** section find and copy the number found in `X-google-AuthUser` and use this as the value for `authUser` in your multi-scrobbler config
|
||||
:::
|
||||
|
||||

|
||||
#### Authentication
|
||||
|
||||
Only one of these methods needs to be used. **Cookies** are easier but **OAuth Client** may be more stable.
|
||||
|
||||
<Tabs groupId="ytmAuth" queryString>
|
||||
<TabItem value="cookie" label="Cookies">
|
||||
:::info
|
||||
|
||||
If cookies stop working for you or are being invalidated often try switching to **OAuth Client** authentication.
|
||||
|
||||
:::
|
||||
|
||||
Use instructions from
|
||||
|
||||
* https://github.com/patrickkfkan/Volumio-YouTube.js/wiki/How-to-obtain-Cookie or
|
||||
* https://ytmusicapi.readthedocs.io/en/stable/setup/browser.html#copy-authentication-headers
|
||||
|
||||
to get the **Cookie** value from a browser.
|
||||
|
||||
It is highly recommended to [get the cookie from an Incognito/Private Session](https://github.com/LuanRT/YouTube.js/issues/803#issuecomment-2504032666) to limit the chance the session is invalidated from normal browsing.
|
||||
|
||||
Add the cookie to your `ytmusic.json` config in `data` or as an ENV:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "ytmusic",
|
||||
"enable": true,
|
||||
"name": "MyYTM",
|
||||
"data": {
|
||||
"cookie": "__Secure-1PSIDTS=sidts-CjEB3EgAEvCd-......"
|
||||
},
|
||||
"options": {
|
||||
"logAuthUpdateChanges": true,
|
||||
"logDiff": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If MS gives you authentication errors (session invalidated) at some point in the future follow the same instructions to get new cookies.
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="oauth" label="OAuth Client">
|
||||
:::note
|
||||
|
||||
This is likely to be the most stable method and least likely to be blocked or have authentication invalidated after an extended period. It requires more setup but is worth the effort.
|
||||
|
||||
:::
|
||||
|
||||
[Based on the instructions from here...](https://github.com/LuanRT/YouTube.js/issues/803#issuecomment-2479689924)
|
||||
|
||||
* Login to [Google Cloud console](https://console.cloud.google.com/) (create an account, if necessary)
|
||||
* [Create a new project](https://console.cloud.google.com/projectcreate)
|
||||
* Go to APIs and services.
|
||||
* Configure the OAuth consent screen
|
||||
* Use the old experience if possible
|
||||
* If new is unavoidable then do not fill out any branding and under Authorized Domains you can delete the empty one (in order to save)
|
||||
* Add yourself as an authorized user
|
||||
* Navigate to Credentials
|
||||
* Create Credentials -> choose "OAuth client ID"
|
||||
* Application Type is **Web Application**
|
||||
* **Name** is whatever you want, leave Authorization Javascript origins blank
|
||||
* Authorized redirect URIs
|
||||
* This must be **exactly** the same as what is displayed in MS! For now leave it blank so we can generate it from MS first
|
||||
* Create
|
||||
* In the newly created client popup save the **Client ID** and **Client Secret**, then copy them into `ytmusic.json` or appropriate ENVs:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "ytmusic",
|
||||
"enable": true,
|
||||
"name": "MyYTM",
|
||||
"data": {
|
||||
"clientId": "8910....6jqupl.apps.googleusercontent.com",
|
||||
"clientSecret": "GOCSPX-WGXL6BSuQ343..."
|
||||
},
|
||||
"options": {
|
||||
"logAuthUpdateChanges": true,
|
||||
"logDiff": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Now, start MS and during the YTMusic startup it will log something like this:
|
||||
|
||||
```
|
||||
Will use custom OAuth Client:
|
||||
Client ID: ...
|
||||
Client Secret: ...
|
||||
Redirect URI: http://localhost:9078/api/ytmusic/callback?name=MyYTM
|
||||
```
|
||||
|
||||
If the beginning of the Redirect URI (before `api`) is EXACTLY how you would reach the MS dashboard from your browser (EX `http://localhost:9078`) then edit your google oauth client section for `Authorized redirect URIs` and add the URL MS has displayed.
|
||||
|
||||
If it is NOT EXACTLY the same you either need to set MS's [base url](https://foxxmd.github.io/multi-scrobbler/docs/configuration/#base-url) or you can provide your own (Custom) Redirect URI for MS to use by setting it in `ytmusic.json` or ENV.
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Using a Custom Redirect URI</summary>
|
||||
|
||||
The three parts of the URL that must be the same:
|
||||
|
||||
* it must start with `api` (after domain or subdirectory IE `my.domain.tld/api...` or `whatever.tld/subDir/api...`
|
||||
* it must end in `ytmusic/callback`
|
||||
* It must include `name=[NameOfSource]` in the query string
|
||||
|
||||
Remember to add your custom URL to the `Authorized redirect URIs` section in the google oauth client!
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "ytmusic",
|
||||
"enable": true,
|
||||
"name": "MyYTM",
|
||||
"data": {
|
||||
"clientId": "8910....6jqupl.apps.googleusercontent.com",
|
||||
"clientSecret": "GOCSPX-WGXL6BSuQ343...",
|
||||
"redirectUri": "http://my.custom.domain/api/ytmusic/callback?name=MyYTM"
|
||||
},
|
||||
"options": {
|
||||
"logAuthUpdateChanges": true,
|
||||
"logDiff": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
AFTER changing the Authorized redirect URIs on Google Cloud console you may need to wait a few minutes for it to take affect. Then restart MS. From the dashboard click `(Re)authenticate` on the YTmusic source card and follow the auth flow:
|
||||
|
||||
* On the screen about "testing" make sure you hit **Continue** (not Back To Safety)
|
||||
* Make sure to select ALL scopes/permissions/grants it asks you about
|
||||
* `Select what [YourAppName] can access` -> Select all
|
||||
|
||||
Once the flow is finished MS will get the credentials and start polling automatically. You should not need to re-authenticate again after restarting MS as it saves the credentials to the `/config` folder.
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="ytt" label="YoutubeTV">
|
||||
|
||||
:::warning
|
||||
|
||||
Using the built-in YoutubeTV authentication is unlikely to work due to [Google restricting what permissions TV clients can have](https://github.com/yt-dlp/yt-dlp/issues/11462#issuecomment-2471703090). This authentication method should not be used.
|
||||
|
||||
:::
|
||||
|
||||
To authenticate start multi-scrobbler with an empty YT Music configuration. An authentication URL/code will be logged in additon to being available from the dashboard.
|
||||
|
||||
```
|
||||
ERROR: Sign in with the code 'CLV-KFA-BVKY' using the authentication link on the dashboard or https://www.google.com/device
|
||||
```
|
||||
|
||||
Visit the authentication URL and enter the code that was provided (also available on the dashboard). After completing the setup flow MS will log `Auth success` and the YT Music dashboard card will display as **Idle** after refreshing. Click the **Start** link to begin monitoring.
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
#### Configuration
|
||||
|
||||
<Tabs groupId="configType" queryString>
|
||||
<TabItem value="env" label="ENV">
|
||||
No ENV support
|
||||
|
||||
|
||||
|
||||
| Environmental Variable | Required? | Default | Description |
|
||||
|------------------------|-----------|---------|-----------------------------------------------|
|
||||
| YTM_COOKIE | No | | Value for Cookie Authentication |
|
||||
| YTM_CLIENT_ID | No | | Client ID for OAuth Athentication |
|
||||
| YTM_CLIENT_SECRET | No | | Client Secret for OAuth Athentication |
|
||||
| YTM_REDIRECT_URI | No | | A custom redirect URI for OAuth Athentication |
|
||||
| YTM_LOG_DIFF | No | false | Log YTM history changes |
|
||||
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="file" label="File">
|
||||
<details>
|
||||
@@ -1008,7 +1527,7 @@ Multi-scrobbler is **not** designed to be publicly accessible from the internet.
|
||||
|
||||
##### Multiple Users
|
||||
|
||||
If you would like use multiple WebScrobbler sources they can be matched using a **slug** at the end of the **API URL.** This requires using [a file-based config.](./configuration?configType=file#webscrobbler-config)
|
||||
If you would like use multiple WebScrobbler sources they can be matched using a **slug** at the end of the **API URL.** This requires using [a file-based config.](./?configType=file#webscrobbler-config)
|
||||
|
||||
<details>
|
||||
|
||||
@@ -1211,7 +1730,7 @@ MS can be forced to track media from an application regardless of media type. Th
|
||||
|
||||
Please include any/all logs with raw output if there are any errors encountered as this is critical to diagnosing issues.
|
||||
|
||||
To diagnose bad/incomplete track information or strange MS player behavior please turn on **payload logging** and include log output of the source running to help diagnose this issue.
|
||||
To diagnose bad/incomplete track information or strange MS player behavior please turn on **payload logging** and include log output of the source running to help diagnose this issue. Either enable in config using the below example OR enable [Debug Mode.](#debug-mode)
|
||||
|
||||
<details>
|
||||
|
||||
@@ -1402,9 +1921,7 @@ If the file being played in VLC does not have ID3 tags/metadata information mult
|
||||
|
||||
To extract this information use [named capture groups](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Regular_expressions/Named_capturing_group) in your expression matching the field that should be extracted: `artist` `title` and/or `album`. MS accepts multiple expressions, the first one that matches a filename will be used.
|
||||
|
||||
<!-- Would like to put this in a <details> node but the <> in the named capture groups are causing the parser to incorrectly parse them as an actual tag -->
|
||||
|
||||
Example usage in [a file-based config:](./configuration?configType=file#webscrobbler-config)
|
||||
Example usage in [a file-based config:](./?configType=file#webscrobbler-config)
|
||||
|
||||
```json5 title="config/vlc.json" {10-15}
|
||||
[
|
||||
@@ -1446,6 +1963,224 @@ If you find that VLC is incorrectly reporting track information (in its interfac
|
||||
]
|
||||
```
|
||||
|
||||
### [Azuracast](https://www.azuracast.com/)
|
||||
|
||||
:::tip[Scrobbling Threshold]
|
||||
|
||||
Azuracast may not correctly report track length or position. If this is the case you should reduce the "listened to" **duration** using [Scrobble Thresholds](#scrobble-thresholds) so that multi-scrobbler scrobbles correctly.
|
||||
|
||||
:::
|
||||
|
||||
The Azuracast server should have **Use High-Performance Now Playing Updates** enabled in _Administration -> System Settings_
|
||||
|
||||
##### URL
|
||||
|
||||
The URL used by MS to connect to Azuracast has the syntax:
|
||||
|
||||
```
|
||||
[ws|wss]://HOST:[PORT]
|
||||
```
|
||||
|
||||
MS will automatically add the path required for websockets, [`/api/live/nowplaying/websocket`](https://www.azuracast.com/docs/developers/now-playing-data/#websockets), to your URL if none is provided. If you use a reverse proxy with a path-based URL or otherwise need a custom path to access the websockets path correctly then explicitly provide it. Examples:
|
||||
|
||||
```
|
||||
URL From Config => MS Uses
|
||||
|
||||
'ws://192.168.0.101' => ws://192.168.0.101/api/live/nowplaying/websocket
|
||||
'ws://azura.mydomain.com' => ws://azura.mydomain.com.com/api/live/nowplaying/websocket
|
||||
'wss://mydomain.com/custom/azura/ws' => wss://mydomain.com/custom/azura/ws
|
||||
```
|
||||
|
||||
##### Scrobble Behavior
|
||||
|
||||
Multi-scrobbler will set the default [**scrobbling behavior**](#should-scrobble-behavior) based on if either of these settings is configured:
|
||||
|
||||
* live status (`AZURA_LIVE` or `monitorWhenLive`) or
|
||||
* listener count (`AZURA_LISTENERS_NUM` or `monitorWhenListeners`)
|
||||
|
||||
If either is configured then MS will automatically scrobble based on when these conditions are met.
|
||||
|
||||
If neither is configured automatic scrobble defaults to **off** and you should either [manually initiate it or explicitly set the default behavior.](#should-scrobble-behavior)
|
||||
|
||||
#### Configuration
|
||||
|
||||
<Tabs groupId="configType" queryString>
|
||||
<TabItem value="env" label="ENV">
|
||||
| Environmental Variable | Required? | Default | Description |
|
||||
| :--------------------- | :-------- | :------ | ---------------------------------------------------------------------------------------------- |
|
||||
| `AZ_URL` | Yes | | Azuracast URL *without station name* |
|
||||
| `AZ_STATION` | Yes | | The station name shown on the public page |
|
||||
| `AZURA_LIVE` | No | Yes | Only scrobble when station status is ONLINE |
|
||||
| `AZURA_LISTENERS_NUM` | No | `true` | Only scrobble if station has any listeners (`true`) or listeners are equal-to/greater-than `X` |
|
||||
</TabItem>
|
||||
<TabItem value="file" label="File">
|
||||
<details>
|
||||
|
||||
<summary>Example</summary>
|
||||
|
||||
<CodeBlock title="CONFIG_DIR/azuracast.json" language="json5">{AzuracastConfig}</CodeBlock>
|
||||
|
||||
</details>
|
||||
|
||||
or <SchemaLink lower objectName="AzuracastSourceConfig"/>
|
||||
</TabItem>
|
||||
<TabItem value="aio" label="AIO">
|
||||
<details>
|
||||
|
||||
<summary>Example</summary>
|
||||
|
||||
<AIOExample data={AzuracastConfig} name="azuracast"/>
|
||||
|
||||
</details>
|
||||
|
||||
or <SchemaLink lower objectName="AzuracastSourceConfig"/>
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
### [Yamaha MusicCast](https://usa.yamaha.com/products/contents/audio_visual/musiccast/index.html)
|
||||
|
||||
Monitor Musiccast device/receivers for music played on Network/USB/CD inputs.
|
||||
|
||||
:::tip[Scrobbling Threshold]
|
||||
|
||||
Musiccast may not correctly report track length. If this is the case you should reduce the "listened to" **duration** using [Scrobble Thresholds](#scrobble-thresholds) so that multi-scrobbler scrobbles correctly.
|
||||
|
||||
:::
|
||||
|
||||
##### URL
|
||||
|
||||
If only a domain/IP:PORT is provided to multi-scrobbler it will automatically append the default Musiccast API path. If your Musiccast device is located behind a custom URL (or reverse proxy) you should explicitly provide the base path to use.
|
||||
|
||||
EX
|
||||
|
||||
| `url` | Transformed URL |
|
||||
| --------------------------------------- | :---------------------------------------------- |
|
||||
| `http://192.168.0.101` | `http://192.168.0.101/YamahaExtendedControl/v1` |
|
||||
| `http://mydomain.com/reverseProxy/cast` | `http://mydomain.com/reverseProxy/cast` |
|
||||
|
||||
#### Configuration
|
||||
|
||||
<Tabs groupId="configType" queryString>
|
||||
<TabItem value="env" label="ENV">
|
||||
or <SchemaLink lower objectName="AzuracastSourceConfig"/>
|
||||
| Environmental Variable | Required? | Default | Description |
|
||||
| :--------------------- | :-------- | ------- | :----------------------- |
|
||||
| `MCAST_URL` | Yes | | The Musiccast device URL |
|
||||
</TabItem>
|
||||
<TabItem value="file" label="File">
|
||||
<details>
|
||||
Change `configureAs` to `source`
|
||||
|
||||
<summary>Example</summary>
|
||||
|
||||
<CodeBlock title="CONFIG_DIR/musiccast.json" language="json5">{MusiccastConfig}</CodeBlock>
|
||||
|
||||
</details>
|
||||
|
||||
or <SchemaLink lower objectName="MusicCastSourceConfig"/>
|
||||
</TabItem>
|
||||
<TabItem value="aio" label="AIO">
|
||||
<details>
|
||||
Change `configureAs` to `source`
|
||||
|
||||
<summary>Example</summary>
|
||||
|
||||
<AIOExample data={MusiccastConfig} name="musiccast"/>
|
||||
|
||||
</details>
|
||||
|
||||
or <SchemaLink lower objectName="MusicCastSourceConfig"/>
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
### [Icecast](https://icecast.org)
|
||||
|
||||
Parse metadata from any Icecast Station (streams) to use for scrobbling.
|
||||
|
||||
:::note
|
||||
|
||||
There is no standard format for presenting track information in an Icecast stream and the majority of streams that do include metadata only use a single string (`StreamTitle`) from which metadata can be parsed.
|
||||
|
||||
Multi-scrobbler will do its best to parse Artist/Album/Track information from this data but the quality and consistency for this is solely based on how the Icecast broadcaster chooses to format their tracks.
|
||||
|
||||
:::
|
||||
|
||||
:::tip[Scrobbling Behavior]
|
||||
|
||||
Multi-scrobbler cannot automatically determine if you are listening to a Station. Therefore, **automatic scrobbling for Icecast Sources is off by default.**
|
||||
|
||||
Use [Should Scrobble Behavior](#should-scrobble-behavior) to initiate scrobbling or configure automatic scrobble.
|
||||
|
||||
:::
|
||||
|
||||
:::tip[Scrobbling Threshold]
|
||||
|
||||
Icecast does not report track lengths so multi-scrobbler cannot determine if it should scrobble based on percent played. You should instead set a shorter "listened to" **duration** using [Scrobble Thresholds.](#scrobble-thresholds)
|
||||
|
||||
:::
|
||||
|
||||
|
||||
##### URL and Sources
|
||||
|
||||
The URL should be the URL you would use to stream audio.
|
||||
|
||||
Additional sources are supported based on those found in [`icecast-metadata-stats`](https://github.com/eshaz/icecast-metadata-js/tree/main/src/icecast-metadata-stats#supported-status-apis). To use these with automatic parsing [based on the regular URL](https://github.com/eshaz/icecast-metadata-js/tree/main/src/icecast-metadata-stats#options) include a `sources` array in your config the same way as shown in `icecast-metadata-stats`:
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Example</summary>
|
||||
|
||||
```json5 title="config.json"
|
||||
[
|
||||
{
|
||||
"name": "myIcy",
|
||||
"enable": true,
|
||||
"data": {
|
||||
"url": "http://myIcecast.com/radio",
|
||||
"sources": ["icy", "ogg", "sevenhtml"]
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
```
|
||||
</details>
|
||||
|
||||
To manually set these sources include the same [`*Endpoint` options](https://github.com/eshaz/icecast-metadata-js/tree/main/src/icecast-metadata-stats#options) found in `icecast-metadata-stats`.
|
||||
|
||||
If no `sources` are configured for multi-scrobbler it will automatically try to use `icy` and `ogg`.
|
||||
|
||||
#### Configuration
|
||||
|
||||
<Tabs groupId="configType" queryString>
|
||||
<TabItem value="env" label="ENV">
|
||||
| Environmental Variable | Required? | Default | Description |
|
||||
| :--------------------- | :-------- | :------ | ----------- |
|
||||
| `ICECAST_URL` | Yes | | Icecast URL |
|
||||
</TabItem>
|
||||
<TabItem value="file" label="File">
|
||||
<details>
|
||||
|
||||
<summary>Example</summary>
|
||||
|
||||
<CodeBlock title="CONFIG_DIR/icecast.json" language="json5">{IcecastConfig}</CodeBlock>
|
||||
|
||||
</details>
|
||||
|
||||
or <SchemaLink lower objectName="IcecastSourceConfig"/>
|
||||
</TabItem>
|
||||
<TabItem value="aio" label="AIO">
|
||||
<details>
|
||||
|
||||
<summary>Example</summary>
|
||||
|
||||
<AIOExample data={IcecastConfig} name="icecast"/>
|
||||
|
||||
</details>
|
||||
|
||||
or <SchemaLink lower objectName="IcecastSourceConfig"/>
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
## Client Configurations
|
||||
|
||||
### [Maloja](https://github.com/krateng/maloja)
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 226 KiB |
@@ -69,7 +69,9 @@ Scenario:
|
||||
"name": "fredPlex",
|
||||
"clients": ["fredMaloja"],
|
||||
"data": {
|
||||
"user": ["fred@email.com"]
|
||||
"token": "JtRnwQWD__XMJF8sT3jc",
|
||||
"url": "192.168.0.233:32400",
|
||||
"usersallow": ["fredUser"]
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -77,8 +79,10 @@ Scenario:
|
||||
"name": "maryPlex",
|
||||
"clients": ["maryMaloja"],
|
||||
"data": {
|
||||
"user": ["mary@email.com"], // still need to specify mary as user so not all users who play from 'podcasts' get scrobbled
|
||||
"libraries": ["podcasts"]
|
||||
"token": "JtRnwQWD__XMJF8sT3jc",
|
||||
"url": "192.168.0.233:32400",
|
||||
"usersallow": ["maryUser"], // still need to specify mary as user so not all users who play from 'podcasts' get scrobbled
|
||||
"librariesAllow": ["podcasts"]
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -86,7 +90,9 @@ Scenario:
|
||||
"name": "partyPlex",
|
||||
// omitting clients (or making it empty) will make this Source scrobble to all Clients
|
||||
"data": {
|
||||
"libraries": ["party"],
|
||||
"token": "JtRnwQWD__XMJF8sT3jc",
|
||||
"url": "192.168.0.233:32400",
|
||||
"librariesAllow": ["party"]
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -94,7 +100,10 @@ Scenario:
|
||||
"name": "FredJelly",
|
||||
// omitting clients (or making it empty) will make this Source scrobble to all Clients
|
||||
"data": {
|
||||
"user": ["fred@email.com"]
|
||||
"url": "http://localhost:8096",
|
||||
"user": "FoxxMD",
|
||||
"apiKey": "c9fae8756fbf481ebd9c5bb56bd6540c",
|
||||
"usersAllow": ["fredUser"]
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -113,7 +122,6 @@ Scenario:
|
||||
"clients": ["foxxMaloja"],
|
||||
"data": {
|
||||
"cookie": "__Secure-3PAPISID=3AxsXpy0MKGu75Qb/AkISXGqOnSDn1jEKn; DEVICE_INFO=ChxOekU0Tmpjek5EWTBPRGd3TlRBMk16QXpNdz09EJbS8Z0GGJbS8Z0G; ...",
|
||||
"authUser": 1
|
||||
}
|
||||
},
|
||||
],
|
||||
@@ -195,21 +203,27 @@ Scenario:
|
||||
"name": "fredPlex",
|
||||
"clients": ["fredMaloja"],
|
||||
"data": {
|
||||
"user": ["fred@email.com"]
|
||||
"token": "JtRnwQWD__XMJF8sT3jc",
|
||||
"url": "192.168.0.233:32400",
|
||||
"usersallow": ["fredUser"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "maryPlex",
|
||||
"clients": ["maryMaloja"],
|
||||
"data": {
|
||||
"user": ["mary@email.com"],
|
||||
"libraries": ["podcasts"]
|
||||
"token": "JtRnwQWD__XMJF8sT3jc",
|
||||
"url": "192.168.0.233:32400",
|
||||
"usersallow": ["maryUser"],
|
||||
"librariesAllow": ["podcasts"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "partyPlex",
|
||||
"data": {
|
||||
"libraries": ["party"]
|
||||
"token": "JtRnwQWD__XMJF8sT3jc",
|
||||
"url": "192.168.0.233:32400",
|
||||
"librariesAllow": ["party"]
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -220,7 +234,10 @@ Scenario:
|
||||
{
|
||||
"name": "FredJelly",
|
||||
"data": {
|
||||
"user": ["fred@email.com"]
|
||||
"url": "http://localhost:8096",
|
||||
"user": "FoxxMD",
|
||||
"apiKey": "c9fae8756fbf481ebd9c5bb56bd6540c",
|
||||
"usersAllow": ["fredUser"]
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -234,7 +251,6 @@ Scenario:
|
||||
"clients": ["foxxMaloja"],
|
||||
"data": {
|
||||
"cookie": "__Secure-3PAPISID=3AxsXpy0MKGu75Qb/AkISXGqOnSDn1jEKn; DEVICE_INFO=ChxOekU0Tmpjek5EWTBPRGd3TlRBMk16QXpNdz09EJbS8Z0GGJbS8Z0G; ...",
|
||||
"authUser": 1
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
---
|
||||
sidebar_position: 2
|
||||
title: Plex/Tautulli
|
||||
title: Plex/Tautulli (Deprecated)
|
||||
---
|
||||
|
||||
:::warning[Deprecated]
|
||||
|
||||
Multi-scrobbler >= 0.8.8 supports a Plex Source that [directly uses the API](configuration/configuration.mdx?plexType=api#plex) which removes the need to use Tautulli since it does not require Plex Pass.
|
||||
|
||||
The Tautulli source **and** Plex Webhook source are deprecated and will be removed in an upcoming release. Existing users should migrate to the API source as soon as possible.
|
||||
|
||||
:::
|
||||
|
||||
Tracks played on [Plex](https://plex.tv/) can be scrobbled either by:
|
||||
* A [Tautulli](https://tautulli.com/) notification agent with a webhook.
|
||||
* Using Plex [Webhooks](https://support.plex.tv/articles/115002267687-webhooks) (restricted to Plex Pass users)
|
||||
|
||||
@@ -132,13 +132,11 @@ Putting it all together:
|
||||
"playTransform": {
|
||||
"preCompare": {
|
||||
"title": [
|
||||
[
|
||||
"badTerm",
|
||||
{
|
||||
"search": "badTerm",
|
||||
"replace": "goodTerm"
|
||||
}
|
||||
]
|
||||
"badTerm",
|
||||
{
|
||||
"search": "badTerm",
|
||||
"replace": "goodTerm"
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
@@ -481,3 +479,45 @@ Removes the phrase `(Album Version)` from the Title of a Play
|
||||
}
|
||||
```
|
||||
</details>
|
||||
|
||||
### Extract primary Artist from delimited, multi-Artist string
|
||||
|
||||
<details>
|
||||
|
||||
When the Artist string is actually a multi-artist, delimited string, this search-and-replace will replace the string with just the first artist found.
|
||||
|
||||
Ex
|
||||
|
||||
```
|
||||
My Artist One / My Artist Two / Another Guy
|
||||
My Artist One
|
||||
```
|
||||
|
||||
Artists are delimited with a spaced forward slash (`/`) in the regex below. Replace the contents of the `delim` capture group with the delimiter for your use case. Some more common scenarios:
|
||||
|
||||
* `(?<delim>\\/)` No spaces between slash IE `My Artist One/My Artist Two/Another Guy`
|
||||
* `(?<delim>\\s*\\\\\s*)` Backslash instead of forward slash IE `My Artist One \ My Artist Two \ Another Guy`
|
||||
* `(?<delim>,)` Comma IE `My Artist One, My Artist Two, Another Guy`
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Example</summary>
|
||||
```json5 title="config.json"
|
||||
{
|
||||
"sourceDefaults": {
|
||||
"playTransform": {
|
||||
"preCompare": {
|
||||
"artists": [
|
||||
{
|
||||
"search": "(.*?)(?<delim>\\s*\\/\\s*)(.*$)",
|
||||
"replace": "$1"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
</details>
|
||||
|
||||
</details>
|
||||
@@ -14,14 +14,20 @@ Multi-scrobbler is written entirely in [Typescript](https://www.typescriptlang.o
|
||||
|
||||
## Project Setup
|
||||
|
||||
Development requires [Node v18.19.1](https://nodejs.org/en) or higher is installed on your system. It is recommended to use [nvm](https://github.com/nvm-sh/nvm) to manage the installed node version.
|
||||
Development requires [Node v18.19.1](https://nodejs.org/en) or higher is installed on your system.
|
||||
|
||||
:::tip
|
||||
|
||||
When running locally (not with a devcontainer) you can use [nvm](https://github.com/nvm-sh/nvm) to manage the installed node version.
|
||||
|
||||
:::
|
||||
|
||||
Clone this repository somewhere and then install from the working directory
|
||||
|
||||
```shell
|
||||
git clone https://github.com/FoxxMD/multi-scrobbler.git .
|
||||
cd multi-scrobbler
|
||||
nvm use # optional, to set correct Node version
|
||||
nvm use # optional, sets correct node version when running without devcontainer
|
||||
npm install
|
||||
npm run start
|
||||
```
|
||||
@@ -32,8 +38,9 @@ This repository contains [workspace settings](https://github.com/FoxxMD/multi-sc
|
||||
|
||||
* Run/Debug [Launch configurations](https://code.visualstudio.com/Docs/editor/debugging#_launch-configurations) for the application and tests
|
||||
* [Devcontainer](https://code.visualstudio.com/docs/devcontainers/containers) for development with all dependencies already installed
|
||||
* Useful extensions for linting and running tests
|
||||
|
||||
To use the Devcontainer simple open the repository in VSCode and "Use Devcontainer" when the notification is presented. You will still need to run `nvm use` and `npm install` the first time it is used.
|
||||
To use the Devcontainer simple open the repository in VSCode and "Use Devcontainer" when the notification is presented. `npm install` will be run when a new container is created.
|
||||
|
||||
## Common Development
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ title: 'Overview'
|
||||
|
||||
import Tabs from '@theme/Tabs';
|
||||
import TabItem from '@theme/TabItem';
|
||||
import CodeBlock from '@theme/CodeBlock';
|
||||
import ComposeStack from '!!raw-loader!../../../docker-compose.yml';
|
||||
|
||||
:::tip
|
||||
|
||||
@@ -160,7 +162,15 @@ Cross-platform images are built for x86 (Intel/AMD) and ARM64 (IE Raspberry Pi)
|
||||
|
||||
:::
|
||||
|
||||
Or use the provided [docker-compose.yml](../../../docker-compose.yml) after modifying it to fit your configuration.
|
||||
Or use the example `docker-compose.yml`, found in the repo, after modifying it to fit your configuration:
|
||||
|
||||
<details>
|
||||
|
||||
<summary>`docker-compose.yml`</summary>
|
||||
|
||||
<CodeBlock language="yaml">{ComposeStack}</CodeBlock>
|
||||
|
||||
</details>
|
||||
|
||||
Recommended configuration steps for docker or docker-compose usage:
|
||||
|
||||
@@ -253,7 +263,7 @@ set the [Base URL](../configuration/configuration.mdx#base-url) as the IP of the
|
||||
multi-scrobbler:
|
||||
# ...
|
||||
environment:
|
||||
- BASE_URL="http://hostMachineIP"
|
||||
- BASE_URL=http://hostMachineIP
|
||||
```
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
@@ -316,7 +326,7 @@ The example scenario:
|
||||
</Tabs>
|
||||
</TabItem>
|
||||
<TabItem value="docker-compose" label="Docker Compose">
|
||||
See [`docker-compose.yml`](../../../docker-compose.yml) file for more options and annotations.
|
||||
See [`docker-compose.yml`](#docker) sample above for more options and annotations.
|
||||
|
||||
```yaml title="docker-compose.yml"
|
||||
services:
|
||||
@@ -327,7 +337,7 @@ The example scenario:
|
||||
- TZ=Etc/GMT # Specify timezone from TZ Database name found here https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
|
||||
- SPOTIFY_CLIENT_ID=yourId
|
||||
- SPOTIFY_CLIENT_SECRET=yourSecret
|
||||
- BASE_URL="http://192.168.0.100:9078"
|
||||
- BASE_URL=http://192.168.0.100:9078
|
||||
- MALOJA_URL=http://domain.tld:42010
|
||||
- MALOJA_API_KEY=1234
|
||||
- PUID=1000
|
||||
|
||||
@@ -30,7 +30,7 @@ services:
|
||||
container_name: multi-scrobbler
|
||||
environment:
|
||||
- TZ=Etc/GMT # Specify timezone from TZ Database name found here https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
|
||||
- BASE_URL="http://192.168.0.100:9078"
|
||||
- BASE_URL=http://192.168.0.100:9078
|
||||
# all Environmental Variables in below examples go here!
|
||||
|
||||
volumes:
|
||||
@@ -46,13 +46,14 @@ services:
|
||||
|
||||
<Tabs>
|
||||
<TabItem value="jellyfin" label="Jellyfin">
|
||||
Follow the Jellyfin configuration [instructions for setting up a **webhook**.](./configuration/configuration.mdx#jellyfin)
|
||||
Follow the Jellyfin configuration [instructions for setting up an **API Key**.](./configuration/configuration.mdx#jellyfin)
|
||||
|
||||
After webhook is setup add **at least one** of these values to the `environment` section in the [`docker-compose.yml` you created.](#create-docker-compose-file)
|
||||
Then, add these values to the `environment` section in the [`docker-compose.yml` you created.](#create-docker-compose-file)
|
||||
|
||||
```yaml title="~/msData/docker-compose.yml"
|
||||
- JELLYFIN_USER=myUserName # comma-separated list of users to monitor
|
||||
- JELLYFIN_SERVER=myServerName # comma-separated list of servers that should be monitored
|
||||
- JELLYFIN_URL=192.168.0.110:8096 # URL to Jellyfin UI
|
||||
- JELLYFIN_APIKEY=c9fae8756fbf481ebd9c5bb56b # Api Key created in previous step
|
||||
- JELLYFIN_USER=MyUser # Your username in Jellyfin
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem value="spotify" label="Spotify">
|
||||
@@ -109,7 +110,7 @@ services:
|
||||
Finally, add these values to the `environment` section in the [`docker-compose.yml` you created for multi-scrobbler earlier.](#create-docker-compose-file)
|
||||
|
||||
```yaml title="~/msData/docker-compose.yml"
|
||||
- MALOJA_URL="http://192.168.0.100:42010"
|
||||
- MALOJA_URL=http://192.168.0.100:42010
|
||||
- MALOJA_API_KEY=myApiKey
|
||||
```
|
||||
</TabItem>
|
||||
|
||||
@@ -4,6 +4,9 @@ import * as themes from 'prism-react-renderer';
|
||||
//import sidebars from './sidebars';
|
||||
|
||||
const config: Config = {
|
||||
future: {
|
||||
experimental_faster: true
|
||||
},
|
||||
title: 'Multi-Scrobbler',
|
||||
tagline: 'Scrobble all the things',
|
||||
favicon: 'img/favicon.ico',
|
||||
@@ -175,6 +178,7 @@ const config: Config = {
|
||||
if (process.env.ANALYTICS !== undefined && process.env.ANALYTICS !== '') {
|
||||
const script = {
|
||||
src: process.env.ANALYTICS,
|
||||
defer: true
|
||||
}
|
||||
if (process.env.ANALYTICS_DOMAIN !== undefined && process.env.ANALYTICS_DOMAIN !== '') {
|
||||
script['data-domain'] = process.env.ANALYTICS_DOMAIN;
|
||||
|
||||
Generated
+5669
-2365
File diff suppressed because it is too large
Load Diff
+17
-15
@@ -3,24 +3,26 @@
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "npx docusaurus start",
|
||||
"build": "npx docusaurus build",
|
||||
"swizzle": "npx docusaurus swizzle",
|
||||
"deploy": "npx docusaurus deploy",
|
||||
"clear": "npx docusaurus clear",
|
||||
"serve": "npx docusaurus serve",
|
||||
"write-translations": "npx docusaurus write-translations",
|
||||
"write-heading-ids": "npx docusaurus write-heading-ids",
|
||||
"start": "docusaurus start --host 0.0.0.0",
|
||||
"build": "docusaurus build",
|
||||
"swizzle": "docusaurus swizzle",
|
||||
"deploy": "docusaurus deploy",
|
||||
"clear": "docusaurus clear",
|
||||
"serve": "docusaurus serve",
|
||||
"write-translations": "docusaurus write-translations",
|
||||
"write-heading-ids": "docusaurus write-heading-ids",
|
||||
"typecheck": "tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
"@docusaurus/core": "^3.4.0",
|
||||
"@docusaurus/preset-classic": "^3.4.0",
|
||||
"@easyops-cn/docusaurus-search-local": "^0.44.3",
|
||||
"@docusaurus/core": "^3.6.3",
|
||||
"@docusaurus/faster": "^3.7.0",
|
||||
"@docusaurus/preset-classic": "^3.6.3",
|
||||
"@easyops-cn/docusaurus-search-local": "^0.48.3",
|
||||
"@mdx-js/react": "^3.0.0",
|
||||
"clsx": "^2.0.0",
|
||||
"docusaurus-json-schema-plugin": "^1.12.1",
|
||||
"docusaurus-theme-github-codeblock": "^2.0.2",
|
||||
"json5": "^2.2.3",
|
||||
"micromark-extension-directive": "^3.0.1",
|
||||
"prism-react-renderer": "^2.3.0",
|
||||
"raw-loader": "^4.0.2",
|
||||
@@ -28,11 +30,11 @@
|
||||
"react-dom": "^18.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@docusaurus/module-type-aliases": "^3.4.0",
|
||||
"@docusaurus/tsconfig": "^3.4.0",
|
||||
"@docusaurus/types": "^3.4.0",
|
||||
"@docusaurus/module-type-aliases": "^3.6.3",
|
||||
"@docusaurus/tsconfig": "^3.6.3",
|
||||
"@docusaurus/types": "^3.6.3",
|
||||
"@types/react": "^18.2.29",
|
||||
"typescript": "~5.2.2"
|
||||
"typescript": "~5.6.2"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
|
||||
@@ -5,6 +5,7 @@ import ErrorBoundary from "@docusaurus/ErrorBoundary"
|
||||
import Error from "@theme/Error"
|
||||
import { Simulate } from "react-dom/test-utils";
|
||||
import error = Simulate.error;
|
||||
import json5 from 'json5';
|
||||
|
||||
export interface AIOProps {
|
||||
data: string
|
||||
@@ -22,7 +23,7 @@ const AIOExample = (props: AIOProps) => {
|
||||
let configObj;
|
||||
// eslint-disable-next-line prefer-const
|
||||
try {
|
||||
configObj = JSON.parse(data);
|
||||
configObj = json5.parse(data);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return <Admonition type="danger" title="Unexpected Error">
|
||||
|
||||
@@ -14,13 +14,15 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c
|
||||
|
||||
* Supports scrobbling from many **Sources**
|
||||
* [Spotify](docs/configuration#spotify)
|
||||
* [Plex](docs/configuration#plex) or [Tautulli](docs/configuration#tautulli)
|
||||
* [Plex](docs/configuration#plex) or [~~Tautulli~~](docs/configuration#tautulli)
|
||||
* [Subsonic-compatible APIs](docs/configuration#subsonic) (like [Airsonic](https://airsonic.github.io/) and [Navidrome](https://www.navidrome.org/))
|
||||
* [Jellyfin](docs/configuration#jellyfin)
|
||||
* [WebScrobbler](docs/configuration#webscrobbler)
|
||||
* [Youtube Music](docs/configuration#youtube-music)
|
||||
* [Last.fm](docs/configuration#lastfm-source)
|
||||
* [Last.fm (Endpoint)](docs/configuration#lastfm-endpoint)
|
||||
* [ListenBrainz](docs/configuration#listenbrainz-source)
|
||||
* [ListenBrainz (Endpoint)](docs/configuration#listenbrainz-endpoint)
|
||||
* [~~Deezer~~](docs/configuration#deezer)
|
||||
* [MPRIS (Linux Desktop)](docs/configuration#mpris)
|
||||
* [Mopidy](docs/configuration#mopidy)
|
||||
@@ -30,6 +32,9 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c
|
||||
* [Musikcube](docs/configuration#musikcube)
|
||||
* [MPD (Music Player Daemon)](docs/configuration#mpd-music-player-daemon)
|
||||
* [VLC](docs/configuration#vlc)
|
||||
* [Azuracast](docs/configuration#azuracast)
|
||||
* [Yamaha MusicCast](docs/configuration#yamaha-musiccast)
|
||||
* [Icecast](docs/configuration#icecast)
|
||||
* Supports scrobbling to many **Clients**
|
||||
* [Maloja](docs/configuration#maloja)
|
||||
* [Last.fm](docs/configuration#lastfm)
|
||||
@@ -40,7 +45,7 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c
|
||||
* Graceful network and client failure handling (queued scrobbles that auto-retry)
|
||||
* Smart handling of credentials (persistent, authorization through app)
|
||||
* Easy configuration through ENVs or JSON
|
||||
* Modify data before scrobbling with [regular expression or search patterns](docs/transforms)
|
||||
* Modify data before scrobbling with [regular expression or search patterns](docs/configuration/transforms)
|
||||
* Install using [Docker images for x86/ARM](docs/installation#docker), [flatpak](docs/installation#flatpak), or [locally with NodeJS](docs/installation#nodejs)
|
||||
|
||||
[**Quick Start Guide**](docs/quickstart)
|
||||
|
||||
+1
-2972
File diff suppressed because one or more lines are too long
+1
-415
File diff suppressed because one or more lines are too long
+1
-1826
File diff suppressed because one or more lines are too long
+29
-36
@@ -4,40 +4,33 @@ import eslint from '@eslint/js';
|
||||
import tsEslint from 'typescript-eslint';
|
||||
import arrow from 'eslint-plugin-prefer-arrow-functions';
|
||||
|
||||
export default tsEslint.config(
|
||||
eslint.configs.recommended,
|
||||
...tsEslint.configs.recommended,
|
||||
// use to enable typed linting (way more errors) https://typescript-eslint.io/linting/typed-linting
|
||||
/* ...tsEslint.configs.recommendedTypeChecked,
|
||||
{
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: true,
|
||||
tsconfigDirName: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
},*/
|
||||
{
|
||||
files: ['src/backend/**/*.ts'],
|
||||
ignores: ['eslint.config.js'],
|
||||
plugins: {
|
||||
"prefer-arrow-functions": arrow
|
||||
},
|
||||
rules: {
|
||||
'no-useless-catch': 'off',
|
||||
'@typescript-eslint/no-unused-vars': 'off',
|
||||
"prefer-arrow-functions/prefer-arrow-functions": [
|
||||
"warn",
|
||||
{
|
||||
"allowNamedFunctions": false,
|
||||
"classPropertiesAllowed": false,
|
||||
"disallowPrototype": false,
|
||||
"returnStyle": "unchanged",
|
||||
"singleReturnOnly": false
|
||||
}
|
||||
],
|
||||
"arrow-body-style": ["warn", "as-needed"],
|
||||
"@typescript-eslint/no-explicit-any": "warn"
|
||||
}
|
||||
export default tsEslint.config({
|
||||
files: ['src/backend/**/*.ts'],
|
||||
plugins: {
|
||||
"prefer-arrow-functions": arrow
|
||||
},
|
||||
ignores: [
|
||||
'eslint.config.js',
|
||||
'src/backend/tests/**/*.ts'
|
||||
],
|
||||
extends: [
|
||||
eslint.configs.recommended,
|
||||
...tsEslint.configs.recommended,
|
||||
],
|
||||
rules: {
|
||||
'no-useless-catch': 'off',
|
||||
'@typescript-eslint/no-unused-vars': 'off',
|
||||
"prefer-arrow-functions/prefer-arrow-functions": [
|
||||
"warn",
|
||||
{
|
||||
"allowNamedFunctions": false,
|
||||
"classPropertiesAllowed": false,
|
||||
"disallowPrototype": false,
|
||||
"returnStyle": "unchanged",
|
||||
"singleReturnOnly": false
|
||||
}
|
||||
],
|
||||
"arrow-body-style": ["warn", "as-needed"],
|
||||
"@typescript-eslint/no-explicit-any": "warn"
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
@@ -51,6 +51,8 @@
|
||||
</screenshot>
|
||||
</screenshots>
|
||||
<releases>
|
||||
<release version="0.9.2" date="2025-03-24"/>
|
||||
<release version="0.9.0" date="2025-01-15"/>
|
||||
<release version="0.8.4" date="2024-09-27"/>
|
||||
<release version="0.8.3" date="2024-08-28"/>
|
||||
<release version="0.8.2" date="2024-08-20"/>
|
||||
|
||||
Generated
+1483
-925
File diff suppressed because it is too large
Load Diff
+28
-18
@@ -1,27 +1,24 @@
|
||||
{
|
||||
"name": "multi-scrobbler",
|
||||
"version": "0.8.4",
|
||||
"version": "0.9.2",
|
||||
"type": "module",
|
||||
"description": "scrobble plays from multiple sources to multiple clients",
|
||||
"scripts": {
|
||||
"schema": "npm run -s schema-aio & npm run -s schema-source & npm run -s schema-client & npm run -s schema-aiosource & npm run -s schema-aioclient",
|
||||
"schema-client": "typescript-json-schema src/backend/tsconfig.json ClientConfig --out src/backend/common/schema/client.json --required --titles --tsNodeRegister --refs --validationKeywords deprecationMessage --constAsEnum",
|
||||
"schema-source": "typescript-json-schema src/backend/tsconfig.json SourceConfig --out src/backend/common/schema/source.json --required --titles --tsNodeRegister --refs --validationKeywords deprecationMessage --constAsEnum",
|
||||
"schema-aio": "typescript-json-schema src/backend/tsconfig.json AIOConfig --out src/backend/common/schema/aio.json --required --titles --tsNodeRegister --refs --validationKeywords deprecationMessage --constAsEnum",
|
||||
"schema-aiosource": "typescript-json-schema src/backend/tsconfig.json AIOSourceConfig --out src/backend/common/schema/aio-source.json --titles --required --tsNodeRegister --refs --validationKeywords deprecationMessage --constAsEnum",
|
||||
"schema-aioclient": "typescript-json-schema src/backend/tsconfig.json AIOClientConfig --out src/backend/common/schema/aio-client.json --titles --required --tsNodeRegister --refs --validationKeywords deprecationMessage --constAsEnum",
|
||||
"schema": "tsx src/backend/utils/SchemaStaticUtil.ts",
|
||||
"circular": "madge --circular --extensions ts src/index.ts",
|
||||
"test": "npm run -s test:backend",
|
||||
"test:backend": "mocha --reporter spec --recursive src/backend/tests/**/*.test.ts",
|
||||
"test:backend": "mocha --reporter spec",
|
||||
"dev": "nodemon -w src/backend -x tsx src/backend/index.ts",
|
||||
"start": "NODE_ENV=production tsx src/backend/index.ts",
|
||||
"build:frontend": "vite build",
|
||||
"build:backend": "tsc -p src/backend",
|
||||
"build": "npm run -s build:backend && npm run -s build:frontend && npm run -s docs:build",
|
||||
"docs:install": "cd docsite && npm ci",
|
||||
"build:parallel": "concurrently --kill-others-on-fail --names backend,frontend,docs \"npm run -s build:backend\" \"npm run -s build:frontend\" \"npm run docs:build\"",
|
||||
"docs:install": "cd docsite && npm ci --no-audit",
|
||||
"docs:start": "cd docsite && npm start",
|
||||
"docs:build": "cd docsite && npm run build",
|
||||
"postinstall": "patch-package"
|
||||
"docs:build": "npm run schema && cd docsite && npm run build",
|
||||
"postinstall": "patch-package",
|
||||
"install:parallel": "concurrently --kill-others-on-fail --names app,docs \"npm ci --no-audit\" \"npm run docs:install\""
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
@@ -54,14 +51,17 @@
|
||||
"@fortawesome/react-fontawesome": "^0.2.0",
|
||||
"@foxxmd/chromecast-client": "^1.0.4",
|
||||
"@foxxmd/get-version": "^0.0.3",
|
||||
"@foxxmd/logging": "^0.2.1",
|
||||
"@foxxmd/logging": "^0.2.2",
|
||||
"@foxxmd/regex-buddy-core": "^0.1.2",
|
||||
"@foxxmd/string-sameness": "^0.4.0",
|
||||
"@jellyfin/sdk": "^0.10.0",
|
||||
"@gr2m/net-interceptor": "^1.0.0",
|
||||
"@jellyfin/sdk": "^0.11.0",
|
||||
"@kenyip/backoff-strategies": "^1.0.4",
|
||||
"@lukehagar/plexjs": "^0.32.1",
|
||||
"@react-nano/use-event-source": "^0.13.0",
|
||||
"@reduxjs/toolkit": "^1.9.5",
|
||||
"@supercharge/promise-pool": "^3.0.0",
|
||||
"@types/express-serve-static-core": "^4.19.6",
|
||||
"address": "^1.2.2",
|
||||
"ajv": "^8.12.0",
|
||||
"autoprefixer": "^10.4.17",
|
||||
@@ -82,7 +82,10 @@
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fixed-size-list": "^0.3.0",
|
||||
"formidable": "^3.5",
|
||||
"glob": "^11.0.0",
|
||||
"google-auth-library": "^9.15.0",
|
||||
"gotify": "^1.1.0",
|
||||
"icecast-metadata-stats": "^0.1.12",
|
||||
"iso-websocket": "^0.3.0",
|
||||
"iti": "^0.6.0",
|
||||
"json5": "^2.2.3",
|
||||
@@ -108,11 +111,12 @@
|
||||
"superagent": "^8.0.9",
|
||||
"tailwindcss": "^3.4.4",
|
||||
"toad-scheduler": "^3.0.0",
|
||||
"ts-json-schema-generator": "^2.3.0",
|
||||
"tsx": "^4.7.0",
|
||||
"vite-express": "^0.16.0",
|
||||
"vlc-client": "^1.1.1",
|
||||
"xml2js": "0.6.0",
|
||||
"youtube-music-ts-api": "^1.7.0"
|
||||
"xml2js": "0.6.1",
|
||||
"youtubei.js": "^13.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@dbus-types/notifications": "^0.0.5",
|
||||
@@ -151,13 +155,14 @@
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-plugin-prefer-arrow-functions": "^3.2.4",
|
||||
"mocha": "^10.3.0",
|
||||
"mockdate": "^3.0.5",
|
||||
"msw": "^2.1.2",
|
||||
"nodemon": "^3.0.3",
|
||||
"ts-essentials": "^9.1.2",
|
||||
"typescript": "^5.5.4",
|
||||
"typescript": "5.5.4",
|
||||
"typescript-eslint": "^7.0.1",
|
||||
"typescript-json-schema": "^0.61.0",
|
||||
"vite": "^5.2.12"
|
||||
"vite": "^5.2.12",
|
||||
"with-local-tmp-dir": "^5.1.1"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
@@ -174,6 +179,11 @@
|
||||
"overrides": {
|
||||
"spotify-web-api-node": {
|
||||
"superagent": "$superagent"
|
||||
},
|
||||
"with-local-tmp-dir": {
|
||||
"tmp-promise": {
|
||||
"tmp": "0.2.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
diff --git a/node_modules/icecast-metadata-stats/src/IcecastMetadataStats.js b/node_modules/icecast-metadata-stats/src/IcecastMetadataStats.js
|
||||
index 7b70725..8836a99 100644
|
||||
--- a/node_modules/icecast-metadata-stats/src/IcecastMetadataStats.js
|
||||
+++ b/node_modules/icecast-metadata-stats/src/IcecastMetadataStats.js
|
||||
@@ -59,6 +59,7 @@ const sources = Symbol();
|
||||
const interval = Symbol();
|
||||
const onStats = Symbol();
|
||||
const onStatsFetch = Symbol();
|
||||
+const onError = Symbol();
|
||||
|
||||
const state = Symbol();
|
||||
const intervalId = Symbol();
|
||||
@@ -106,6 +107,7 @@ export default class IcecastMetadataStats {
|
||||
[interval]: (options.interval || 30) * 1000,
|
||||
[onStats]: options.onStats || noOp,
|
||||
[onStatsFetch]: options.onStatsFetch || noOp,
|
||||
+ [onError]: options.onError,
|
||||
[icyMetaInt]: options.icyMetaInt,
|
||||
[icyCharacterEncoding]: options.icyCharacterEncoding,
|
||||
[icyDetectionTimeout]: options.icyDetectionTimeout,
|
||||
@@ -425,13 +427,21 @@ export default class IcecastMetadataStats {
|
||||
signal: p.get(this)[controller].signal,
|
||||
})
|
||||
.then((res) => {
|
||||
- if (!res.ok) throw new Error(`HTTP Error ${res.status}`);
|
||||
+ if (!res.ok) {
|
||||
+ const responseError = new Error(`HTTP Error ${res.status}`);
|
||||
+ responseError.res = res;
|
||||
+ throw responseError;
|
||||
+ }
|
||||
return res;
|
||||
})
|
||||
.then(mapper)
|
||||
.catch((e) => {
|
||||
if (e.name !== "AbortError") {
|
||||
- console.warn(`Failed to fetch ${p.get(this)[endpoint]}`, e);
|
||||
+ if(p.get(this)[onError] !== undefined) {
|
||||
+ p.get(this)[onError](new Error(`Failed to fetch ${p.get(this)[endpoint]}`, {cause: e}));
|
||||
+ } else {
|
||||
+ console.warn(`Failed to fetch ${p.get(this)[endpoint]}`, e);
|
||||
+ }
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
@@ -0,0 +1,39 @@
|
||||
diff --git a/node_modules/ts-json-schema-generator/dist/src/AnnotationsReader/ExtendedAnnotationsReader.js b/node_modules/ts-json-schema-generator/dist/src/AnnotationsReader/ExtendedAnnotationsReader.js
|
||||
index 107dc62..d8cda3c 100644
|
||||
--- a/node_modules/ts-json-schema-generator/dist/src/AnnotationsReader/ExtendedAnnotationsReader.js
|
||||
+++ b/node_modules/ts-json-schema-generator/dist/src/AnnotationsReader/ExtendedAnnotationsReader.js
|
||||
@@ -49,7 +49,7 @@ class ExtendedAnnotationsReader extends BasicAnnotationsReader_js_1.BasicAnnotat
|
||||
.replace(/\r/g, "")
|
||||
.trim();
|
||||
const description = markdownDescription.replace(/(?<=[^\n])\n(?=[^\n*-])/g, " ").trim();
|
||||
- return this.markdownDescription ? { description, markdownDescription } : { description };
|
||||
+ return this.markdownDescription ? { description: markdownDescription } : { description };
|
||||
}
|
||||
getTypeAnnotation(node) {
|
||||
const symbol = (0, symbolAtNode_js_1.symbolAtNode)(node);
|
||||
diff --git a/node_modules/ts-json-schema-generator/dist/src/SchemaGenerator.js b/node_modules/ts-json-schema-generator/dist/src/SchemaGenerator.js
|
||||
index e3b7761..93f914a 100644
|
||||
--- a/node_modules/ts-json-schema-generator/dist/src/SchemaGenerator.js
|
||||
+++ b/node_modules/ts-json-schema-generator/dist/src/SchemaGenerator.js
|
||||
@@ -95,7 +95,7 @@ class SchemaGenerator {
|
||||
children.reduce((definitions, child) => {
|
||||
const name = child.getName();
|
||||
if (!(name in definitions)) {
|
||||
- definitions[name] = this.typeFormatter.getDefinition(child.getType());
|
||||
+ definitions[name] = {...this.typeFormatter.getDefinition(child.getType()), title: name};
|
||||
}
|
||||
return definitions;
|
||||
}, childDefinitions);
|
||||
diff --git a/node_modules/ts-json-schema-generator/src/AnnotationsReader/ExtendedAnnotationsReader.ts b/node_modules/ts-json-schema-generator/src/AnnotationsReader/ExtendedAnnotationsReader.ts
|
||||
index ba8fd57..806396c 100644
|
||||
--- a/node_modules/ts-json-schema-generator/src/AnnotationsReader/ExtendedAnnotationsReader.ts
|
||||
+++ b/node_modules/ts-json-schema-generator/src/AnnotationsReader/ExtendedAnnotationsReader.ts
|
||||
@@ -57,7 +57,7 @@ export class ExtendedAnnotationsReader extends BasicAnnotationsReader {
|
||||
|
||||
const description = markdownDescription.replace(/(?<=[^\n])\n(?=[^\n*-])/g, " ").trim();
|
||||
|
||||
- return this.markdownDescription ? { description, markdownDescription } : { description };
|
||||
+ return this.markdownDescription ? { description: markdownDescription } : { description };
|
||||
}
|
||||
private getTypeAnnotation(node: ts.Node): Annotations | undefined {
|
||||
const symbol = symbolAtNode(node);
|
||||
@@ -1,13 +0,0 @@
|
||||
diff --git a/node_modules/typescript-json-schema/dist/typescript-json-schema.js b/node_modules/typescript-json-schema/dist/typescript-json-schema.js
|
||||
index 5b5ecec..1e48e77 100644
|
||||
--- a/node_modules/typescript-json-schema/dist/typescript-json-schema.js
|
||||
+++ b/node_modules/typescript-json-schema/dist/typescript-json-schema.js
|
||||
@@ -55,7 +55,7 @@ var crypto_1 = require("crypto");
|
||||
var ts = require("typescript");
|
||||
var path_equal_1 = require("path-equal");
|
||||
var vm = require("vm");
|
||||
-var REGEX_FILE_NAME_OR_SPACE = /(\bimport\(".*?"\)|".*?")\.| /g;
|
||||
+var REGEX_FILE_NAME_OR_SPACE = /(\bimport\(".*?"(, \{ assert: \{ "resolution-mode": "(import|require)" \} \})?\)|".*?")\.| /g;// /(\bimport\(".*?"\)|".*?")\.| /g;
|
||||
var REGEX_TSCONFIG_NAME = /^.*\.json$/;
|
||||
var REGEX_TJS_JSDOC = /^-([\w]+)\s+(\S|\S[\s\S]*\S)\s*$/g;
|
||||
var REGEX_GROUP_JSDOC = /^[.]?([\w]+)\s+(\S|\S[\s\S]*\S)\s*$/g;
|
||||
File diff suppressed because one or more lines are too long
@@ -5,7 +5,7 @@ import {
|
||||
import deepEqual from 'fast-deep-equal';
|
||||
import { Simulate } from "react-dom/test-utils";
|
||||
import { PlayObject } from "../../core/Atomic.js";
|
||||
import { buildTrackString } from "../../core/StringUtils.js";
|
||||
import { buildTrackString, truncateStringToLength } from "../../core/StringUtils.js";
|
||||
|
||||
import {
|
||||
configPartsToStrongParts, countRegexes,
|
||||
@@ -23,6 +23,9 @@ import {
|
||||
import { CommonClientConfig } from "./infrastructure/config/client/index.js";
|
||||
import { CommonSourceConfig } from "./infrastructure/config/source/index.js";
|
||||
import play = Simulate.play;
|
||||
import { WebhookPayload } from "./infrastructure/config/health/webhooks.js";
|
||||
import { AuthCheckError, BuildDataError, ConnectionCheckError, PostInitError, TransformRulesError } from "./errors/MSErrors.js";
|
||||
import { messageWithCauses, messageWithCausesTruncatedDefault } from "../utils/ErrorUtils.js";
|
||||
|
||||
export default abstract class AbstractComponent {
|
||||
requiresAuth: boolean = false;
|
||||
@@ -41,37 +44,73 @@ export default abstract class AbstractComponent {
|
||||
regexCache!: ReturnType<typeof cacheFunctions>;
|
||||
|
||||
logger: Logger;
|
||||
componentLogger?: Logger;
|
||||
|
||||
protected constructor(config: CommonClientConfig | CommonSourceConfig) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
initialize = async () => {
|
||||
public abstract notify(payload: WebhookPayload): Promise<void>;
|
||||
|
||||
protected abstract getIdentifier(): string;
|
||||
|
||||
initialize = async (options: {force?: boolean, notify?: boolean, notifyTitle?: string} = {}) => {
|
||||
|
||||
const {force = false, notify = false, notifyTitle = 'Init Error'} = options;
|
||||
|
||||
this.logger.debug('Attempting to initialize...');
|
||||
try {
|
||||
this.initializing = true;
|
||||
await this.buildInitData();
|
||||
if(this.componentLogger === undefined) {
|
||||
await this.buildComponentLogger();
|
||||
}
|
||||
await this.buildInitData(force);
|
||||
this.buildTransformRules();
|
||||
await this.checkConnection();
|
||||
await this.testAuth();
|
||||
await this.checkConnection(force);
|
||||
await this.testAuth(force);
|
||||
this.logger.info('Fully Initialized!');
|
||||
try {
|
||||
await this.postInitialize();
|
||||
} catch (e) {
|
||||
this.logger.warn(new Error('Error occurred during post-initialization hook but was caught', {cause: e}));
|
||||
throw new PostInitError('Error occurred during post-initialization hook', {cause: e});
|
||||
}
|
||||
return true;
|
||||
} catch(e) {
|
||||
this.logger.error(new Error('Initialization failed', {cause: e}));
|
||||
return false;
|
||||
if(notify) {
|
||||
await this.notify({title: `${this.getIdentifier()} - ${notifyTitle}`, message: truncateStringToLength(500)(messageWithCausesTruncatedDefault(e)), priority: 'error'});
|
||||
}
|
||||
throw new Error('Initialization failed', {cause: e});
|
||||
} finally {
|
||||
this.initializing = false;
|
||||
}
|
||||
}
|
||||
|
||||
public async buildInitData() {
|
||||
private async buildComponentLogger() {
|
||||
await this.doBuildComponentLogger();
|
||||
return;
|
||||
}
|
||||
|
||||
protected async doBuildComponentLogger() {
|
||||
return;
|
||||
}
|
||||
|
||||
tryInitialize = async (options: {force?: boolean, notify?: boolean, notifyTitle?: string} = {}) => {
|
||||
if(this.initializing) {
|
||||
throw new Error(`Already trying to initialize, cannot attempt while an existing initialization attempt is running.`)
|
||||
}
|
||||
try {
|
||||
return await this.initialize(options);
|
||||
} catch (e) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
public async buildInitData(force: boolean = false) {
|
||||
if(this.buildOK) {
|
||||
return;
|
||||
if(!force) {
|
||||
return;
|
||||
}
|
||||
this.logger.debug('Build OK but step was forced');
|
||||
}
|
||||
try {
|
||||
const res = await this.doBuildInitData();
|
||||
@@ -88,7 +127,7 @@ export default abstract class AbstractComponent {
|
||||
this.buildOK = true;
|
||||
} catch (e) {
|
||||
this.buildOK = false;
|
||||
throw new Error('Building required data for initialization failed', {cause: e});
|
||||
throw new BuildDataError('Building required data for initialization failed', {cause: e});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,13 +148,13 @@ export default abstract class AbstractComponent {
|
||||
this.doBuildTransformRules();
|
||||
} catch (e) {
|
||||
this.buildOK = false;
|
||||
throw new Error('Could not build playTransform rules. Check your configuration is valid.', {cause: e});
|
||||
throw new TransformRulesError('Could not build playTransform rules. Check your configuration is valid.', {cause: e});
|
||||
}
|
||||
try {
|
||||
const ruleCount = countRegexes(this.transformRules);
|
||||
this.regexCache = cacheFunctions(ruleCount);
|
||||
} catch (e) {
|
||||
this.logger.warn(new Error('Failed to count number of rule regexes for caching but will continue will fallback to 100', {cause: e}));
|
||||
this.logger.warn(new TransformRulesError('Failed to count number of rule regexes for caching but will continue will fallback to 100', {cause: e}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,7 +218,13 @@ export default abstract class AbstractComponent {
|
||||
}
|
||||
}
|
||||
|
||||
public async checkConnection() {
|
||||
public async checkConnection(force: boolean = false) {
|
||||
if(this.connectionOK) {
|
||||
if(!force) {
|
||||
return;
|
||||
}
|
||||
this.logger.debug('Connection OK but step was forced')
|
||||
}
|
||||
try {
|
||||
const res = await this.doCheckConnection();
|
||||
if (res === undefined) {
|
||||
@@ -194,7 +239,7 @@ export default abstract class AbstractComponent {
|
||||
this.connectionOK = true;
|
||||
} catch (e) {
|
||||
this.connectionOK = false;
|
||||
throw new Error('Communicating with upstream service failed', {cause: e});
|
||||
throw new ConnectionCheckError('Communicating with upstream service failed', {cause: e});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,6 +259,8 @@ export default abstract class AbstractComponent {
|
||||
|
||||
canTryAuth = () => this.isUsable() && this.authGated() && this.authFailure !== true
|
||||
|
||||
canAuthUnattended = () => !this.authGated || !this.requiresAuthInteraction || (this.requiresAuthInteraction && !this.authFailure);
|
||||
|
||||
protected doAuthentication = async (): Promise<boolean> => this.authed
|
||||
|
||||
// default init function, should be overridden if auth stage is required
|
||||
@@ -221,8 +268,21 @@ export default abstract class AbstractComponent {
|
||||
if(!this.requiresAuth) {
|
||||
return;
|
||||
}
|
||||
if(this.authed && !force) {
|
||||
return;
|
||||
if(this.authed) {
|
||||
if(!force) {
|
||||
return;
|
||||
}
|
||||
this.logger.debug('Auth OK but step was forced');
|
||||
}
|
||||
|
||||
if(this.authFailure) {
|
||||
if(!force) {
|
||||
if(this.requiresAuthInteraction) {
|
||||
throw new AuthCheckError('Authentication failure: Will not retry auth because user interaction is required for authentication');
|
||||
}
|
||||
throw new AuthCheckError('Authentication failure: Will not retry auth because authentication previously failed and must be reauthenticated');
|
||||
}
|
||||
this.logger.debug('Auth previously failed for non upstream/network reasons but retry is being forced');
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -232,7 +292,7 @@ export default abstract class AbstractComponent {
|
||||
// only signal as auth failure if error was NOT either a node network error or a non-showstopping upstream error
|
||||
this.authFailure = !(hasNodeNetworkException(e) || hasUpstreamError(e, false));
|
||||
this.authed = false;
|
||||
this.logger.error(new Error(`Authentication test failed!${this.authFailure === false ? ' Due to a network issue. Will retry authentication on next heartbeat.' : ''}`, {cause: e}));
|
||||
throw new AuthCheckError(`Authentication test failed!${this.authFailure === false ? ' Due to a network issue. Will retry authentication on next heartbeat.' : ''}`, {cause: e});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -318,4 +378,8 @@ export default abstract class AbstractComponent {
|
||||
return play;
|
||||
}
|
||||
}
|
||||
|
||||
public additionalApiData(): Record<string, any> {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
export abstract class NamedError extends Error {
|
||||
public abstract name: string;
|
||||
}
|
||||
|
||||
export abstract class StageError extends NamedError {}
|
||||
|
||||
export class BuildDataError extends StageError {
|
||||
name = 'Init Build Data';
|
||||
}
|
||||
|
||||
export class TransformRulesError extends StageError {
|
||||
name = 'Transform Rules';
|
||||
}
|
||||
|
||||
export class ConnectionCheckError extends StageError {
|
||||
name = 'Conenction Check';
|
||||
}
|
||||
|
||||
export class AuthCheckError extends StageError {
|
||||
name = 'Authentication Check';
|
||||
}
|
||||
|
||||
export class PostInitError extends StageError {
|
||||
name = 'Post Initialization';
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { Dayjs } from "dayjs";
|
||||
import { Request, Response } from "express";
|
||||
import { NextFunction, ParamsDictionary, Query } from "express-serve-static-core";
|
||||
import { FixedSizeList } from 'fixed-size-list';
|
||||
import { PlayMeta, PlayObject } from "../../../core/Atomic.js";
|
||||
import { isPlayObject, PlayMeta, PlayObject } from "../../../core/Atomic.js";
|
||||
import TupleMap from "../TupleMap.js";
|
||||
|
||||
export type SourceType =
|
||||
@@ -15,9 +15,12 @@ export type SourceType =
|
||||
| 'jellyfin'
|
||||
| 'lastfm'
|
||||
| 'deezer'
|
||||
| 'endpointlz'
|
||||
| 'endpointlfm'
|
||||
| 'ytmusic'
|
||||
| 'mpris'
|
||||
| 'mopidy'
|
||||
| 'musiccast'
|
||||
| 'listenbrainz'
|
||||
| 'jriver'
|
||||
| 'kodi'
|
||||
@@ -25,7 +28,9 @@ export type SourceType =
|
||||
| 'chromecast'
|
||||
| 'musikcube'
|
||||
| 'mpd'
|
||||
| 'vlc';
|
||||
| 'vlc'
|
||||
| 'icecast'
|
||||
| 'azuracast';
|
||||
|
||||
export const sourceTypes: SourceType[] = [
|
||||
'spotify',
|
||||
@@ -35,9 +40,12 @@ export const sourceTypes: SourceType[] = [
|
||||
'jellyfin',
|
||||
'lastfm',
|
||||
'deezer',
|
||||
'endpointlz',
|
||||
'endpointlfm',
|
||||
'ytmusic',
|
||||
'mpris',
|
||||
'mopidy',
|
||||
'musiccast',
|
||||
'listenbrainz',
|
||||
'jriver',
|
||||
'kodi',
|
||||
@@ -45,9 +53,15 @@ export const sourceTypes: SourceType[] = [
|
||||
'chromecast',
|
||||
'musikcube',
|
||||
'mpd',
|
||||
'vlc'
|
||||
'vlc',
|
||||
'icecast',
|
||||
'azuracast'
|
||||
];
|
||||
|
||||
export const isSourceType = (data: string): data is SourceType => {
|
||||
return sourceTypes.includes(data as SourceType);
|
||||
}
|
||||
|
||||
export const lowGranularitySources: SourceType[] = ['subsonic', 'ytmusic'];
|
||||
|
||||
export type ClientType =
|
||||
@@ -59,6 +73,9 @@ export const clientTypes: ClientType[] = [
|
||||
'lastfm',
|
||||
'listenbrainz'
|
||||
];
|
||||
export const isClientType = (data: string): data is ClientType => {
|
||||
return clientTypes.includes(data as ClientType);
|
||||
}
|
||||
|
||||
export type InitState = 0 | 1 | 2;
|
||||
export const NOT_INITIALIZED: InitState = 0;
|
||||
@@ -103,17 +120,23 @@ export interface ConfigMeta {
|
||||
|
||||
export type SourceData = (PlayObject | PlayerStateData);
|
||||
|
||||
export interface PlayerStateData {
|
||||
platformId: PlayPlatformId
|
||||
export interface PlayerStateData extends PlayerStateDataMaybePlay {
|
||||
play: PlayObject
|
||||
}
|
||||
|
||||
export interface PlayerStateDataMaybePlay {
|
||||
platformId: PlayPlatformId
|
||||
/** The ID/Key for individual sessions on a device/platform */
|
||||
sessionId?: string
|
||||
play?: PlayObject
|
||||
status?: ReportedPlayerStatus
|
||||
position?: number
|
||||
timestamp?: Dayjs
|
||||
}
|
||||
|
||||
export const asPlayerStateData = (obj: object): obj is PlayerStateData => {
|
||||
return 'platformId' in obj && 'play' in obj;
|
||||
}
|
||||
export const asPlayerStateData = (obj: object): obj is PlayerStateData => asPlayerStateDataMaybePlay(obj) && 'play' in obj && isPlayObject(obj.play)
|
||||
|
||||
export const asPlayerStateDataMaybePlay = (obj: object): obj is PlayerStateDataMaybePlay => 'platformId' in obj
|
||||
|
||||
export interface FormatPlayObjectOptions {
|
||||
newFromSource?: boolean
|
||||
|
||||
@@ -5,6 +5,7 @@ import { RequestRetryOptions } from "./common.js";
|
||||
import { WebhookConfig } from "./health/webhooks.js";
|
||||
import { CommonSourceOptions, SourceRetryOptions } from "./source/index.js";
|
||||
import { SourceAIOConfig } from "./source/sources.js";
|
||||
import { ClientType, SourceType } from "../Atomic.js";
|
||||
|
||||
|
||||
export interface SourceDefaults extends CommonSourceOptions {
|
||||
@@ -70,7 +71,22 @@ export interface AIOClientConfig {
|
||||
clients?: ClientAIOConfig[]
|
||||
}
|
||||
|
||||
export interface AIOClientRelaxedConfig {
|
||||
clientDefaults?: RequestRetryOptions
|
||||
clients?: object[]
|
||||
}
|
||||
|
||||
export interface AIOSourceConfig {
|
||||
sourceDefaults?: SourceRetryOptions
|
||||
sources?: SourceAIOConfig[]
|
||||
}
|
||||
|
||||
export interface AIOSourceRelaxedConfig {
|
||||
sourceDefaults?: SourceRetryOptions
|
||||
sources?: object[]
|
||||
}
|
||||
|
||||
export interface TypedConfig<T = string> {
|
||||
type: T
|
||||
// [key: string]: any
|
||||
}
|
||||
@@ -48,5 +48,32 @@ export interface PollingOptions {
|
||||
* @examples [30]
|
||||
* */
|
||||
maxInterval?: number
|
||||
|
||||
/**
|
||||
* Number of seconds after which A Player is considered Stale
|
||||
*
|
||||
* When Polling the source does not recieve data about a specific Player after X seconds it becomes Stale. When the Player becomes Stale:
|
||||
*
|
||||
* * The current listening session is ended. If the Player becomes active again a new listening session is started (Player will miss `interval` seconds of listening)
|
||||
* * If the player has an existing session w/ track then MS attempts to scrobble it
|
||||
*
|
||||
* This option DOES NOT need to be set. It is automatically calculated as (`interval` * 3) when not defined.
|
||||
*/
|
||||
staleAfter?: number
|
||||
|
||||
/**
|
||||
* Number of seconds after which A Player is considered Orphaned
|
||||
*
|
||||
* When Polling the source does not recieve data about a specific Player after X seconds it becomes Orphaned. When the Player becomes Orphaned:
|
||||
*
|
||||
* * The current Player session is ended and the Player is removed from MS
|
||||
* * MS attempts to scrobble, if the Player has an existing session w/ track
|
||||
*
|
||||
* A Player should become Orphaned EQUAL TO OR AFTER it becomes Stale.
|
||||
*
|
||||
* * This option DOES NOT need to be set. It is automatically calculated as (`interval` * 5) when not defined.
|
||||
* * If it is set it must be equal to or larger than `staleAfter` or (`interval * 3`)
|
||||
*/
|
||||
orphanedAfter?: number
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { CommonSourceConfig, CommonSourceData, CommonSourceOptions, ManualListeningOptions } from "./index.js";
|
||||
|
||||
export interface AzuraStationInfoResponse {
|
||||
id: string
|
||||
name: string
|
||||
shortcode: string
|
||||
is_public: boolean
|
||||
}
|
||||
|
||||
export interface AzuraListenersResponse {
|
||||
total: number
|
||||
unique: number
|
||||
current: number
|
||||
}
|
||||
|
||||
export interface AzuraSongResponse {
|
||||
id: string
|
||||
text: string
|
||||
artist: string
|
||||
title: string
|
||||
album: string
|
||||
genre: string
|
||||
isrc: string
|
||||
}
|
||||
|
||||
export interface AzuraNowPlayingResponse {
|
||||
sh_id: number
|
||||
played_at: number
|
||||
duration: number
|
||||
streamer: string
|
||||
elapsed: number
|
||||
remaining: number
|
||||
song: AzuraSongResponse
|
||||
}
|
||||
|
||||
export interface AzuraLiveResponse {
|
||||
is_live: boolean
|
||||
streamer_name: string
|
||||
broadcast_start: number | null
|
||||
}
|
||||
|
||||
export interface AzuraStationResponse {
|
||||
is_online: boolean
|
||||
station: AzuraStationInfoResponse
|
||||
listeners: AzuraListenersResponse
|
||||
now_playing: AzuraNowPlayingResponse
|
||||
}
|
||||
|
||||
|
||||
export interface AzuracastData extends CommonSourceData {
|
||||
/**
|
||||
* Base URL of the Azuracast instance
|
||||
*
|
||||
* This does NOT include the station. If a station is included it will be ignored. Use `station` field to specify station, if necessary
|
||||
*
|
||||
*
|
||||
* @examples ["https://radio.mydomain.tld", "http://localhost:80"]
|
||||
* */
|
||||
url: string
|
||||
|
||||
/**
|
||||
* The specific station to monitor
|
||||
*
|
||||
* Scrobbling will only occur if any of the monitor conditions are met AND the station is ONLINE.
|
||||
*
|
||||
* To monitor multiple stations create a Source for each station.
|
||||
*
|
||||
* @examples ["my-station-1"]
|
||||
* */
|
||||
station: string
|
||||
|
||||
/**
|
||||
* Only activate scrobble monitoring if station
|
||||
*
|
||||
* * `true` => has any current listeners
|
||||
* * `number` => has EQUAL TO or MORE THAN X number of listeners
|
||||
*
|
||||
*/
|
||||
monitorWhenListeners?: boolean | number
|
||||
|
||||
/**
|
||||
* Only activate scrobble monitoring if station has a live DJ/Streamer
|
||||
*
|
||||
* @default true
|
||||
*/
|
||||
monitorWhenLive?: boolean
|
||||
|
||||
/**
|
||||
* API Key used to access data about private streams
|
||||
*
|
||||
* https://www.azuracast.com/docs/developers/apis/#api-authentication
|
||||
* */
|
||||
apiKey?: string
|
||||
}
|
||||
|
||||
export interface AzuracastSourceoptions extends CommonSourceOptions, ManualListeningOptions {
|
||||
|
||||
}
|
||||
|
||||
export interface AzuracastSourceConfig extends CommonSourceConfig {
|
||||
data: AzuracastData
|
||||
}
|
||||
|
||||
export interface AzuracastSourceAIOConfig extends AzuracastSourceConfig {
|
||||
type: 'azuracast'
|
||||
}
|
||||
@@ -19,7 +19,7 @@ export interface DeezerData extends CommonSourceData {
|
||||
* @default "http://localhost:9078/deezer/callback"
|
||||
* @examples ["http://localhost:9078/deezer/callback"]
|
||||
* */
|
||||
redirectUri: string
|
||||
redirectUri?: string
|
||||
/**
|
||||
* optional, how long to wait before calling spotify for new tracks (in seconds)
|
||||
*
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { CommonSourceConfig, CommonSourceData } from "./index.js";
|
||||
|
||||
export interface LastFMEndpointData extends CommonSourceData {
|
||||
/**
|
||||
* The URL ending that should be used to identify scrobbles for this source
|
||||
*
|
||||
* If you are using multiple Last.fm endpoint sources (scrobbles for many users) you can use a slug to match Sources with individual users/origins
|
||||
*
|
||||
* Example:
|
||||
*
|
||||
* * slug: 'usera' => API URL: http://localhost:9078/api/lastfm/usera
|
||||
* * slug: 'originb' => API URL: http://localhost:9078/api/lastfm/originb
|
||||
*
|
||||
* If no slug is found from an extension's incoming webhook event the first Last.fm source without a slug will be used
|
||||
* */
|
||||
slug?: string | null
|
||||
}
|
||||
|
||||
export interface LastFMEndpointSourceConfig extends CommonSourceConfig {
|
||||
data?: LastFMEndpointData
|
||||
}
|
||||
|
||||
export interface LastFMEndpointSourceAIOConfig extends LastFMEndpointSourceConfig {
|
||||
type: 'endpointlfm'
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { CommonSourceConfig, CommonSourceData } from "./index.js";
|
||||
|
||||
export interface ListenbrainzEndpointData extends CommonSourceData {
|
||||
/**
|
||||
* The URL ending that should be used to identify scrobbles for this source
|
||||
*
|
||||
* If you are using multiple Listenbrainz endpoint sources (scrobbles for many users) you can use a slug to match Sources with individual users/origins
|
||||
*
|
||||
* Example:
|
||||
*
|
||||
* * slug: 'usera' => API URL: http://localhost:9078/api/listenbrainz/usera
|
||||
* * slug: 'originb' => API URL: http://localhost:9078/api/listenbrainz/originb
|
||||
*
|
||||
* If no slug is found from an extension's incoming webhook event the first Listenbrainz source without a slug will be used
|
||||
* */
|
||||
slug?: string | null
|
||||
|
||||
/**
|
||||
* If an LZ submission request contains this token in the Authorization Header it will be used to match the submission with this Source
|
||||
*
|
||||
* See: https://listenbrainz.readthedocs.io/en/latest/users/api/index.html#add-the-user-token-to-your-requests
|
||||
* */
|
||||
token?: string | null
|
||||
}
|
||||
|
||||
export interface ListenbrainzEndpointSourceConfig extends CommonSourceConfig {
|
||||
data?: ListenbrainzEndpointData
|
||||
}
|
||||
|
||||
export interface ListenbrainzEndpointSourceAIOConfig extends ListenbrainzEndpointSourceConfig {
|
||||
type: 'endpointlz'
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { CommonSourceConfig, CommonSourceData, CommonSourceOptions, ManualListeningOptions } from "./index.js";
|
||||
|
||||
|
||||
export interface IcecastMetadata {
|
||||
icy?: {
|
||||
/** Title of the ICY metadata update, usually Artist - Title */
|
||||
StreamTitle?: string
|
||||
/** URL of the ICY metadata update, usually album art */
|
||||
StreamUrl?: string
|
||||
}
|
||||
ogg?: {
|
||||
/** Title of the OGG metadata update, usually Artist - Title */
|
||||
TITLE?: string
|
||||
ALBUM?: string
|
||||
ARTIST?: string
|
||||
}
|
||||
}
|
||||
|
||||
export type IcecastSource = 'icy' | 'ogg' | 'icestats' | 'stats' | 'sevenhtml' | 'nextsongs';
|
||||
|
||||
export interface IcecastOptions {
|
||||
sources?: IcecastSource[]
|
||||
icestatsEndpoint?: string
|
||||
statsEndpoint?: string
|
||||
nextsongsEndpoint?: string
|
||||
sevenhtmlEndpoint?: string
|
||||
icyMetaInt?: number
|
||||
}
|
||||
|
||||
export interface IcecastData extends CommonSourceData, IcecastOptions {
|
||||
/**
|
||||
* The Icecast stream URL
|
||||
* */
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface IcecastSourceOptions extends CommonSourceOptions, ManualListeningOptions {
|
||||
}
|
||||
|
||||
export interface IcecastSourceConfig extends CommonSourceConfig {
|
||||
data: IcecastData
|
||||
options?: IcecastSourceOptions
|
||||
}
|
||||
|
||||
export interface IcecastSourceAIOConfig extends IcecastSourceConfig {
|
||||
type: 'icecast'
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { FileLogOptions, LogLevel } from "@foxxmd/logging";
|
||||
import { PlayTransformConfig, PlayTransformOptions } from "../../Atomic.js";
|
||||
import { CommonConfig, CommonData, RequestRetryOptions } from "../common.js";
|
||||
|
||||
@@ -42,8 +43,8 @@ export interface ScrobbleThresholds {
|
||||
|
||||
export interface CommonSourceOptions extends SourceRetryOptions {
|
||||
/**
|
||||
* If this source has INGRESS to MS (sends a payload, rather than MS GETTING requesting a payload)
|
||||
* then setting this option to true will make MS log the payload JSON to DEBUG output
|
||||
* * If this source has INGRESS to MS (sends a payload, rather than MS GETTING requesting a payload) then setting this option to true will make MS log the payload JSON to DEBUG output
|
||||
* * If this source is POLLING then it will log the raw data for each unique track/response the first time it is seen
|
||||
*
|
||||
* @default false
|
||||
* @examples [false]
|
||||
@@ -72,6 +73,13 @@ export interface CommonSourceOptions extends SourceRetryOptions {
|
||||
* */
|
||||
logPlayerState?: boolean
|
||||
|
||||
/**
|
||||
* **Exprimental:** Log to a separate file for this Source.
|
||||
*
|
||||
* Useful for debugging long-running Sources
|
||||
*/
|
||||
logToFile?: true | LogLevel | FileLogOptions
|
||||
|
||||
/**
|
||||
* If this source
|
||||
*
|
||||
@@ -101,6 +109,15 @@ export interface CommonSourceOptions extends SourceRetryOptions {
|
||||
playTransform?: PlayTransformOptions
|
||||
}
|
||||
|
||||
export interface ManualListeningOptions {
|
||||
/**
|
||||
* For Sources that support manual listening, should MS default to scrobbling when no user interaction has occurred?
|
||||
*
|
||||
* If not specified MS will use a Source's specific behavior, see Source's documentation.
|
||||
*/
|
||||
systemScrobble?: boolean
|
||||
}
|
||||
|
||||
export interface CommonSourceData extends CommonData {
|
||||
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { CommonSourceConfig, CommonSourceData, CommonSourceOptions } from "./index.js";
|
||||
import {
|
||||
// @ts-expect-error weird typings?
|
||||
CollectionType
|
||||
} from "@jellyfin/sdk/lib/generated-client/index.js";
|
||||
|
||||
export interface JellyData extends CommonSourceData {
|
||||
/**
|
||||
@@ -54,23 +58,38 @@ export interface JellyApiData extends CommonSourceData {
|
||||
|
||||
/**
|
||||
* Only scrobble if device or application name contains strings from this list (case-insensitive)
|
||||
*
|
||||
* Note: This only applies to real-time scrobbling as JF does not track device info in user activity history
|
||||
* */
|
||||
devicesAllow?: string | string[]
|
||||
/**
|
||||
* Do not scrobble if device or application name contains strings from this list (case-insensitive)
|
||||
*
|
||||
* Note: This only applies to real-time scrobbling as JF does not track device info in user activity history
|
||||
* */
|
||||
devicesBlock?: string | string[]
|
||||
|
||||
/**
|
||||
* Only scrobble if library name contains string from this list (case-insensitive)
|
||||
* */
|
||||
librariesAllow?: string | string[]
|
||||
/**
|
||||
* Do not scrobble if library name contains strings from this list (case-insensitive)
|
||||
* */
|
||||
librariesBlock?: string | string[]
|
||||
|
||||
/**
|
||||
* Allow MS to scrobble audio media in libraries classified other than 'music'
|
||||
*
|
||||
* `librariesAllow` will achieve the same result as this but this is more convenient if you do not want to explicitly list every library name or are only using `librariesBlock`
|
||||
*/
|
||||
additionalAllowedLibraryTypes?: CollectionType[]
|
||||
|
||||
/**
|
||||
* Force media with a type of "Unknown" to be counted as Audio
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
allowUnknown?: boolean
|
||||
}
|
||||
|
||||
export interface JellyApiOptions extends CommonSourceOptions {
|
||||
/* /!**
|
||||
* Set a persistent device id suffix
|
||||
* *!/
|
||||
deviceId?: string*/
|
||||
}
|
||||
|
||||
export interface JellySourceConfig extends CommonSourceConfig {
|
||||
@@ -89,3 +108,6 @@ export interface JellyApiSourceConfig extends CommonSourceConfig {
|
||||
export interface JellyApiSourceAIOConfig extends JellyApiSourceConfig {
|
||||
type: 'jellyfin'
|
||||
}
|
||||
|
||||
|
||||
export type JellyfinCompatConfig = JellyApiSourceConfig | JellySourceConfig;
|
||||
@@ -0,0 +1,87 @@
|
||||
import { REPORTED_PLAYER_STATUSES, ReportedPlayerStatus } from "../../Atomic.js";
|
||||
import { CommonSourceConfig, CommonSourceData } from "./index.js";
|
||||
|
||||
export type PlaybackStatus = 'play' | 'stop' | 'pause' | 'fast_reverse' | 'fast_forward'
|
||||
|
||||
export interface MusicCastResponse {
|
||||
response_code: number
|
||||
}
|
||||
|
||||
export interface DeviceInfoResponse extends MusicCastResponse {
|
||||
model_name: string
|
||||
device_id: string
|
||||
system_version: number
|
||||
api_version: number
|
||||
}
|
||||
|
||||
export interface DeviceStatusResponse extends MusicCastResponse {
|
||||
power: 'on' | 'standby'
|
||||
}
|
||||
|
||||
/** use with /netusb/getPlayInfo or /cd/getPlayInfo */
|
||||
export interface PlayInfoCDResponse extends MusicCastResponse {
|
||||
device_status: 'open' | 'close' | 'ready' | 'not_ready'
|
||||
playback: 'play' | 'stop' | 'pause' | 'fast_reverse' | 'fast_forward'
|
||||
/** in seconds */
|
||||
play_time: number
|
||||
/** in seconds */
|
||||
total_time: number
|
||||
artist: string
|
||||
album: string
|
||||
track: string
|
||||
}
|
||||
|
||||
export interface PlayInfoNetResponse extends PlayInfoCDResponse {
|
||||
input: string
|
||||
}
|
||||
|
||||
export const MusicCastResponseCodes = new Map<number, string>([
|
||||
[0, 'Success'],
|
||||
[1, 'Initializing'],
|
||||
[2, 'Internal Error'],
|
||||
[3, 'Invalid Request'],
|
||||
[4, 'Invalid Parameter'],
|
||||
[5, 'Guarded (Unable to setup in current status)'],
|
||||
[6, 'Time out'],
|
||||
[100, 'Access Error'],
|
||||
[101, 'Other Error'],
|
||||
[107, 'Service Maintenance'],
|
||||
[109, 'License Error'],
|
||||
[110, 'Read Only Mode'],
|
||||
[112, 'Access Denied'],
|
||||
[115, 'Simultaneous logins has reached the upper limit'],
|
||||
[200, 'Linking in progress'],
|
||||
[201, 'Unlinking in progress']
|
||||
]);
|
||||
|
||||
export const playbackToReportedStatus = (pb: PlaybackStatus): ReportedPlayerStatus => {
|
||||
switch(pb) {
|
||||
case 'play':
|
||||
case 'fast_forward':
|
||||
case 'fast_reverse':
|
||||
return REPORTED_PLAYER_STATUSES.playing;
|
||||
case 'pause':
|
||||
return REPORTED_PLAYER_STATUSES.paused;
|
||||
case 'stop':
|
||||
return REPORTED_PLAYER_STATUSES.stopped;
|
||||
default:
|
||||
return REPORTED_PLAYER_STATUSES.unknown;
|
||||
}
|
||||
}
|
||||
|
||||
export interface MusicCastData extends CommonSourceData {
|
||||
/**
|
||||
* The host or URL of the YamahaExtendedControl endpoint to use
|
||||
*
|
||||
* @examples [["192.168.0.101","http://192.168.0.101/YamahaExtendedControl"]]
|
||||
* */
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface MusicCastSourceConfig extends CommonSourceConfig {
|
||||
data: MusicCastData
|
||||
}
|
||||
|
||||
export interface MusicCastSourceAIOConfig extends MusicCastSourceConfig {
|
||||
type: 'musiccast'
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { CommonSourceConfig, CommonSourceData } from "./index.js";
|
||||
import { PollingOptions } from "../common.js";
|
||||
import { CommonSourceConfig, CommonSourceData, CommonSourceOptions } from "./index.js";
|
||||
|
||||
export interface PlexSourceData extends CommonSourceData {
|
||||
/**
|
||||
@@ -34,3 +35,54 @@ export interface PlexSourceConfig extends CommonSourceConfig {
|
||||
export interface PlexSourceAIOConfig extends PlexSourceConfig {
|
||||
type: 'plex'
|
||||
}
|
||||
|
||||
export interface PlexApiData extends CommonSourceData, PollingOptions {
|
||||
token?: string
|
||||
/**
|
||||
* http(s)://HOST:PORT of the Plex server to connect to
|
||||
* */
|
||||
url: string
|
||||
|
||||
/**
|
||||
* Only scrobble for specific users (case-insensitive)
|
||||
*
|
||||
* If `true` MS will scrobble activity from all users
|
||||
* */
|
||||
usersAllow?: string | true | string[]
|
||||
/**
|
||||
* Do not scrobble for these users (case-insensitive)
|
||||
* */
|
||||
usersBlock?: string | string[]
|
||||
|
||||
/**
|
||||
* Only scrobble if device or application name contains strings from this list (case-insensitive)
|
||||
* */
|
||||
devicesAllow?: string | string[]
|
||||
/**
|
||||
* Do not scrobble if device or application name contains strings from this list (case-insensitive)
|
||||
* */
|
||||
devicesBlock?: string | string[]
|
||||
|
||||
/**
|
||||
* Only scrobble if library name contains string from this list (case-insensitive)
|
||||
* */
|
||||
librariesAllow?: string | string[]
|
||||
/**
|
||||
* Do not scrobble if library name contains strings from this list (case-insensitive)
|
||||
* */
|
||||
librariesBlock?: string | string[]
|
||||
}
|
||||
|
||||
export interface PlexApiOptions extends CommonSourceOptions {
|
||||
}
|
||||
|
||||
export interface PlexApiSourceConfig extends CommonSourceConfig {
|
||||
data: PlexApiData
|
||||
options: PlexApiOptions
|
||||
}
|
||||
|
||||
export interface PlexApiSourceAIOConfig extends PlexApiSourceConfig {
|
||||
type: 'plex'
|
||||
}
|
||||
|
||||
export type PlexCompatConfig = PlexApiSourceConfig | PlexSourceConfig;
|
||||
@@ -1,4 +1,7 @@
|
||||
import { AzuracastSourceAIOConfig, AzuracastSourceConfig } from "./azuracast.js";
|
||||
import { ChromecastSourceAIOConfig, ChromecastSourceConfig } from "./chromecast.js";
|
||||
import { ListenbrainzEndpointSourceAIOConfig, ListenbrainzEndpointSourceConfig } from "./endpointlz.js";
|
||||
import { LastFMEndpointSourceAIOConfig, LastFMEndpointSourceConfig } from "./endpointlfm.js";
|
||||
import { DeezerSourceAIOConfig, DeezerSourceConfig } from "./deezer.js";
|
||||
import { JellyApiSourceAIOConfig, JellyApiSourceConfig, JellySourceAIOConfig, JellySourceConfig } from "./jellyfin.js";
|
||||
import { JRiverSourceAIOConfig, JRiverSourceConfig } from "./jriver.js";
|
||||
@@ -9,20 +12,25 @@ import { MopidySourceAIOConfig, MopidySourceConfig } from "./mopidy.js";
|
||||
import { MPDSourceAIOConfig, MPDSourceConfig } from "./mpd.js";
|
||||
import { MPRISSourceAIOConfig, MPRISSourceConfig } from "./mpris.js";
|
||||
import { MusikcubeSourceAIOConfig, MusikcubeSourceConfig } from "./musikcube.js";
|
||||
import { PlexSourceAIOConfig, PlexSourceConfig } from "./plex.js";
|
||||
import { MusicCastSourceConfig, MusicCastSourceAIOConfig } from "./musiccast.js";
|
||||
import { PlexSourceAIOConfig, PlexSourceConfig, PlexApiSourceConfig, PlexApiSourceAIOConfig } from "./plex.js";
|
||||
import { SpotifySourceAIOConfig, SpotifySourceConfig } from "./spotify.js";
|
||||
import { SubsonicSourceAIOConfig, SubSonicSourceConfig } from "./subsonic.js";
|
||||
import { TautulliSourceAIOConfig, TautulliSourceConfig } from "./tautulli.js";
|
||||
import { VLCSourceAIOConfig, VLCSourceConfig } from "./vlc.js";
|
||||
import { WebScrobblerSourceAIOConfig, WebScrobblerSourceConfig } from "./webscrobbler.js";
|
||||
import { YTMusicSourceAIOConfig, YTMusicSourceConfig } from "./ytmusic.js";
|
||||
import { IcecastSourceAIOConfig, IcecastSourceConfig } from "./icecast.js";
|
||||
|
||||
|
||||
export type SourceConfig =
|
||||
SpotifySourceConfig
|
||||
| PlexSourceConfig
|
||||
| PlexApiSourceConfig
|
||||
| TautulliSourceConfig
|
||||
| DeezerSourceConfig
|
||||
| ListenbrainzEndpointSourceConfig
|
||||
| LastFMEndpointSourceConfig
|
||||
| SubSonicSourceConfig
|
||||
| JellySourceConfig
|
||||
| JellyApiSourceConfig
|
||||
@@ -36,14 +44,20 @@ export type SourceConfig =
|
||||
| WebScrobblerSourceConfig
|
||||
| ChromecastSourceConfig
|
||||
| MusikcubeSourceConfig
|
||||
| MusicCastSourceConfig
|
||||
| MPDSourceConfig
|
||||
| VLCSourceConfig;
|
||||
| VLCSourceConfig
|
||||
| IcecastSourceConfig
|
||||
| AzuracastSourceConfig;
|
||||
|
||||
export type SourceAIOConfig =
|
||||
SpotifySourceAIOConfig
|
||||
| PlexSourceAIOConfig
|
||||
| PlexApiSourceAIOConfig
|
||||
| TautulliSourceAIOConfig
|
||||
| DeezerSourceAIOConfig
|
||||
| ListenbrainzEndpointSourceAIOConfig
|
||||
| LastFMEndpointSourceAIOConfig
|
||||
| SubsonicSourceAIOConfig
|
||||
| JellySourceAIOConfig
|
||||
| JellyApiSourceAIOConfig
|
||||
@@ -57,5 +71,8 @@ export type SourceAIOConfig =
|
||||
| WebScrobblerSourceAIOConfig
|
||||
| ChromecastSourceAIOConfig
|
||||
| MusikcubeSourceAIOConfig
|
||||
| MusicCastSourceAIOConfig
|
||||
| MPDSourceAIOConfig
|
||||
| VLCSourceAIOConfig;
|
||||
| VLCSourceAIOConfig
|
||||
| IcecastSourceAIOConfig
|
||||
| AzuracastSourceAIOConfig;
|
||||
|
||||
@@ -20,7 +20,7 @@ export interface SpotifySourceData extends CommonSourceData, PollingOptions {
|
||||
* @default "http://localhost:9078/callback"
|
||||
* @examples ["http://localhost:9078/callback"]
|
||||
* */
|
||||
redirectUri: string
|
||||
redirectUri?: string
|
||||
/**
|
||||
* How long to wait before polling the source API for new tracks (in seconds)
|
||||
*
|
||||
|
||||
@@ -25,7 +25,7 @@ export interface VLCSourceOptions extends CommonSourceOptions {
|
||||
*
|
||||
* Used when VLC reports only the filename for the current audio track
|
||||
* */
|
||||
filenamePatterns?: string[]
|
||||
filenamePatterns?: string | string[]
|
||||
/**
|
||||
* Log to DEBUG when a filename-only track is matched or not matched by filenamePatterns
|
||||
*
|
||||
|
||||
@@ -1,32 +1,94 @@
|
||||
import { PollingOptions } from "../common.js";
|
||||
import { CommonSourceConfig, CommonSourceData, CommonSourceOptions } from "./index.js";
|
||||
|
||||
export interface YTMusicCredentials {
|
||||
export interface InnertubeOptions {
|
||||
/**
|
||||
* Proof of Origin token
|
||||
*
|
||||
* May be required if YTM starts returning 403
|
||||
*
|
||||
* @see https://github.com/yt-dlp/yt-dlp/wiki/Extractors#po-token-guide
|
||||
*/
|
||||
po_token?: string
|
||||
|
||||
/**
|
||||
* Visitor ID value found in VISITOR_INFO1_LIVE or visitorData cookie
|
||||
*
|
||||
* May be required if YTM starts returning 403
|
||||
*
|
||||
* @see https://github.com/yt-dlp/yt-dlp/wiki/Extractors#po-token-guide
|
||||
*/
|
||||
visitor_data?: string
|
||||
|
||||
/**
|
||||
* If account login results in being able to choose multiple account, use a zero-based index to choose which one to monitor
|
||||
*
|
||||
* @examples [0,1]
|
||||
*/
|
||||
account_index?: number
|
||||
|
||||
location?: string
|
||||
lang?: string
|
||||
generate_session_locally?: boolean
|
||||
device_category?: string
|
||||
client_type?: string
|
||||
timezone?: string
|
||||
}
|
||||
|
||||
export interface YTMusicData extends CommonSourceData, PollingOptions {
|
||||
/**
|
||||
* The cookie retrieved from the Request Headers of music.youtube.com after logging in.
|
||||
*
|
||||
* See https://github.com/nickp10/youtube-music-ts-api/blob/master/DOCUMENTATION.md#authenticate and https://ytmusicapi.readthedocs.io/en/latest/setup.html#copy-authentication-headers for how to retrieve this value.
|
||||
* See https://ytmusicapi.readthedocs.io/en/stable/setup/browser.html#copy-authentication-headers for how to retrieve this value.
|
||||
*
|
||||
* @examples ["VISITOR_INFO1_LIVE=jMp2xA1Xz2_PbVc; __Secure-3PAPISID=3AxsXpy0M/AkISpjek; ..."]
|
||||
* */
|
||||
cookie: string
|
||||
cookie?: string
|
||||
|
||||
/**
|
||||
* If the 'X-Goog-AuthUser' header is present in the Request Headers for music.youtube.com it must also be included
|
||||
*
|
||||
* @example [0]
|
||||
* */
|
||||
authUser?: number
|
||||
* Google Cloud Console project OAuth Client ID
|
||||
*
|
||||
* Generated from a custom OAuth Client, see docs
|
||||
*/
|
||||
clientId?: string
|
||||
|
||||
/**
|
||||
* Google Cloud Console project OAuth Client Secret
|
||||
*
|
||||
* Generated from a custom OAuth Client, see docs
|
||||
*/
|
||||
clientSecret?: string
|
||||
|
||||
/**
|
||||
* Google Cloud Console project OAuth Client Authorized redirect URI
|
||||
*
|
||||
* Generated from a custom OAuth Client, see docs. multi-scrobbler will generate a default based on BASE_URL.
|
||||
* Only specify this if the default does not work for you.
|
||||
*/
|
||||
redirectUri?: string
|
||||
|
||||
/**
|
||||
* Additional options for authorization and tailoring YTM client
|
||||
*/
|
||||
innertubeOptions?: InnertubeOptions
|
||||
}
|
||||
|
||||
export interface YTMusicData extends YTMusicCredentials, CommonSourceData, PollingOptions {
|
||||
}
|
||||
export interface YTMusicSourceConfig extends CommonSourceConfig {
|
||||
data: YTMusicData
|
||||
data?: YTMusicData
|
||||
options?: CommonSourceOptions & {
|
||||
/**
|
||||
* When true MS will log to DEBUG what parts of the cookie are updated by YTM
|
||||
* When true MS will log to DEBUG all of the credentials data it receives from YTM
|
||||
* */
|
||||
logAuthUpdateChanges?: boolean
|
||||
logAuth?: boolean
|
||||
/**
|
||||
* Always log history diff
|
||||
*
|
||||
* By default MS will log to `WARN` if history diff is inconsistent but does not log if diff is expected (on new tracks found)
|
||||
* Set this to `true` to ALWAYS log diff on new tracks. Expected diffs will log to `DEBUG` and inconsistent diffs will continue to log to `WARN`
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
logDiff?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,10 @@ declare module 'lastfm-node-client' {
|
||||
mbid?: string
|
||||
}
|
||||
|
||||
export interface TrackScrobbleRequest extends TrackScrobblePayload {
|
||||
method: 'track.scrobble'
|
||||
}
|
||||
|
||||
export interface TrackScrobbleResponse {
|
||||
scrobbles: {
|
||||
'@attr': {
|
||||
@@ -52,6 +56,10 @@ declare module 'lastfm-node-client' {
|
||||
|
||||
export type NowPlayingPayload = Omit<TrackScrobblePayload, 'mbid' | 'timestamp'>
|
||||
|
||||
export type NowPlayingRequest = NowPlayingPayload & { method: 'track.updateNowPlaying' };
|
||||
|
||||
export type LastfmTrackUpdateRequest = TrackScrobbleRequest | NowPlayingRequest;
|
||||
|
||||
export interface NowPlayingResponse {
|
||||
nowplaying: {
|
||||
ignoredMessage: {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { childLogger, Logger, loggerAppRolling, LogOptions, parseLogOptions, } from '@foxxmd/logging';
|
||||
import { buildDestinationJsonPrettyStream, buildDestinationStdout, buildLogger } from "@foxxmd/logging/factory";
|
||||
import { childLogger, FileLogOptions, Logger, loggerAppRolling, LogLevel, LogLevelStreamEntry, LogOptions, parseLogOptions } from '@foxxmd/logging';
|
||||
import { buildDestinationJsonPrettyStream, buildDestinationRollingFile, buildDestinationStdout, buildLogger } from "@foxxmd/logging/factory";
|
||||
import { PassThrough, Transform } from "node:stream";
|
||||
import path from "path";
|
||||
import process from "process";
|
||||
import { projectDir } from "./index.js";
|
||||
import { isDebugMode } from '../utils.js';
|
||||
|
||||
export let logPath = path.resolve(projectDir, `./logs`);
|
||||
if (typeof process.env.CONFIG_DIR === 'string') {
|
||||
@@ -22,7 +23,8 @@ export const initLogger = (): [Logger, Transform] => {
|
||||
|
||||
export const appLogger = async (config: LogOptions = {}): Promise<[Logger, PassThrough]> => {
|
||||
const stream = new PassThrough({objectMode: true});
|
||||
const opts = parseLogOptions(config)
|
||||
const { file } = config;
|
||||
const opts = parseLogOptions(isDebugMode() ? {...config, file: typeof file === 'object' ? {...file, level: 'debug'} : 'debug', console: 'debug', level: 'debug'} : config);
|
||||
const logger = await loggerAppRolling(config, {
|
||||
logBaseDir: typeof process.env.CONFIG_DIR === 'string' ? process.env.CONFIG_DIR : undefined,
|
||||
logDefaultPath: './logs/scrobble.log',
|
||||
@@ -32,6 +34,46 @@ export const appLogger = async (config: LogOptions = {}): Promise<[Logger, PassT
|
||||
});
|
||||
return [logger, stream];
|
||||
}
|
||||
|
||||
export const componentFileLogger = async (type: string, name: string, fileConfig: true | LogLevel | FileLogOptions, config: LogOptions = {}): Promise<Logger> => {
|
||||
const opts = parseLogOptions(config, {
|
||||
logBaseDir: typeof process.env.CONFIG_DIR === 'string' ? process.env.CONFIG_DIR : undefined,
|
||||
logDefaultPath: './logs/scrobble.log'
|
||||
});
|
||||
|
||||
const base = path.dirname(typeof opts.file.path === 'function' ? opts.file.path() : opts.file.path);
|
||||
const componentLogPath = path.join(base, `${type}-${name}.log`);
|
||||
|
||||
const componentConfig: LogOptions = {
|
||||
level: opts.level ?? 'debug'
|
||||
};
|
||||
if (fileConfig === true) {
|
||||
componentConfig.file = {
|
||||
path: componentLogPath,
|
||||
}
|
||||
} else if (typeof fileConfig === 'string') {
|
||||
componentConfig.file = {
|
||||
level: fileConfig as LogLevel,
|
||||
path: componentLogPath
|
||||
}
|
||||
} else {
|
||||
componentConfig.file = fileConfig;
|
||||
}
|
||||
|
||||
const strongOpts = parseLogOptions(componentConfig);
|
||||
|
||||
const streams: LogLevelStreamEntry[] = [];
|
||||
|
||||
if(strongOpts.file.level !== false) {
|
||||
const file = await buildDestinationRollingFile(componentConfig.file.level ?? componentConfig.level, {...strongOpts.file})
|
||||
streams.push(file);
|
||||
|
||||
return buildLogger('debug' as LogLevel, streams);
|
||||
} else {
|
||||
throw new Error('File must be set');
|
||||
}
|
||||
}
|
||||
|
||||
export class MaybeLogger {
|
||||
logger?: Logger
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-2
@@ -151,8 +151,7 @@ export class JRiverApiClient extends AbstractApiClient {
|
||||
if(this.config.username === undefined || this.config.password === undefined) {
|
||||
msg = 'Authentication failed. No username/password was provided in config! Did you mean to do this?';
|
||||
}
|
||||
this.logger.error(new Error(msg, {cause: e}));
|
||||
return false;
|
||||
throw new Error(msg, {cause: e});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-2
@@ -142,8 +142,7 @@ export class KodiApiClient extends AbstractApiClient {
|
||||
if(this.config.username === undefined || this.config.password === undefined) {
|
||||
msg = 'Authentication failed. No username/password was provided in config! Did you mean to do this?';
|
||||
}
|
||||
this.logger.error(new Error(msg, {cause: e}));
|
||||
return false;
|
||||
throw new Error(msg, {cause: e});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+69
-3
@@ -1,6 +1,8 @@
|
||||
import dayjs from "dayjs";
|
||||
import dayjs, { Dayjs } from "dayjs";
|
||||
import LastFm, {
|
||||
AuthGetSessionResponse,
|
||||
LastfmTrackUpdateRequest,
|
||||
NowPlayingPayload,
|
||||
NowPlayingResponse,
|
||||
TrackObject,
|
||||
TrackScrobblePayload,
|
||||
@@ -8,13 +10,15 @@ import LastFm, {
|
||||
} from "lastfm-node-client";
|
||||
import { PlayObject } from "../../../core/Atomic.js";
|
||||
import { nonEmptyStringOrDefault, splitByFirstFound } from "../../../core/StringUtils.js";
|
||||
import { joinedUrl, readJson, removeUndefinedKeys, sleep, writeFile } from "../../utils.js";
|
||||
import { readJson, removeUndefinedKeys, sleep, writeFile } from "../../utils.js";
|
||||
import { joinedUrl } from "../../utils/NetworkUtils.js";
|
||||
import { getScrobbleTsSOCDate } from "../../utils/TimeUtils.js";
|
||||
import { getNodeNetworkException, isNodeNetworkException } from "../errors/NodeErrors.js";
|
||||
import { UpstreamError } from "../errors/UpstreamError.js";
|
||||
import { AbstractApiOptions, DEFAULT_RETRY_MULTIPLIER, FormatPlayObjectOptions } from "../infrastructure/Atomic.js";
|
||||
import { LastfmData } from "../infrastructure/config/client/lastfm.js";
|
||||
import AbstractApiClient from "./AbstractApiClient.js";
|
||||
import { parseArtistCredits } from "../../utils/StringUtils.js";
|
||||
|
||||
const badErrors = [
|
||||
'api key suspended',
|
||||
@@ -223,13 +227,18 @@ export default class LastfmApiClient extends AbstractApiClient {
|
||||
artist = artists[0];
|
||||
}
|
||||
|
||||
const additionalRichPayload: Partial<TrackScrobblePayload> = {};
|
||||
if(duration !== 0) {
|
||||
additionalRichPayload.duration = duration;
|
||||
}
|
||||
|
||||
const rawPayload: TrackScrobblePayload = {
|
||||
artist: artist,
|
||||
duration,
|
||||
track,
|
||||
album,
|
||||
timestamp: getScrobbleTsSOCDate(playObj).unix(),
|
||||
mbid,
|
||||
...additionalRichPayload
|
||||
};
|
||||
|
||||
// LFM does not support multiple artists in scrobble payload
|
||||
@@ -274,3 +283,60 @@ export default class LastfmApiClient extends AbstractApiClient {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const scrobblePayloadToPlay = (obj: LastfmTrackUpdateRequest): PlayObject => {
|
||||
const {
|
||||
artist,
|
||||
track,
|
||||
duration,
|
||||
album,
|
||||
albumArtist,
|
||||
} = obj;
|
||||
|
||||
let ts: Dayjs | undefined;
|
||||
if('timestamp' in obj) {
|
||||
ts = dayjs.unix(obj.timestamp);
|
||||
}
|
||||
const mbid = 'mbid' in obj ? obj.mbid : undefined;
|
||||
|
||||
|
||||
let artists: string[] = [];
|
||||
|
||||
const credits = parseArtistCredits(artist);
|
||||
if(credits !== undefined) {
|
||||
artists.push(credits.primary);
|
||||
if(credits.secondary !== undefined) {
|
||||
const nonEmptyArtists = credits.secondary.filter(x => x !== undefined && x !== null && x.trim() !== '');
|
||||
if(nonEmptyArtists.length > 0) {
|
||||
artists = [...artists, ...nonEmptyArtists];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
artists = [artist];
|
||||
}
|
||||
|
||||
const play: PlayObject = {
|
||||
data: {
|
||||
track,
|
||||
album: nonEmptyStringOrDefault(album),
|
||||
albumArtists: nonEmptyStringOrDefault(albumArtist) !== undefined ? [albumArtist] : undefined,
|
||||
duration: typeof duration === 'string' ? parseInt(duration, 10) : duration,
|
||||
playDate: ts,
|
||||
artists
|
||||
},
|
||||
meta: {
|
||||
source: 'lastfm',
|
||||
nowPlaying: obj.method === 'track.updateNowPlaying'
|
||||
}
|
||||
};
|
||||
|
||||
if(nonEmptyStringOrDefault(mbid) !== undefined) {
|
||||
play.data.meta = {
|
||||
brainz: {
|
||||
track: mbid
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return play;
|
||||
}
|
||||
+39
-3
@@ -2,8 +2,7 @@ import { stringSameness } from '@foxxmd/string-sameness';
|
||||
import dayjs from "dayjs";
|
||||
import request, { Request, Response } from 'superagent';
|
||||
import { PlayObject } from "../../../core/Atomic.js";
|
||||
import { slice } from "../../../core/StringUtils.js";
|
||||
import { combinePartsToString } from "../../utils.js";
|
||||
import { combinePartsToString, slice } from "../../../core/StringUtils.js";
|
||||
import {
|
||||
findDelimiters,
|
||||
normalizeStr,
|
||||
@@ -82,7 +81,7 @@ export interface ListenPayload {
|
||||
}
|
||||
|
||||
export interface SubmitPayload {
|
||||
listen_type: 'single',
|
||||
listen_type: 'single' | 'playing_now',
|
||||
payload: [ListenPayload]
|
||||
}
|
||||
|
||||
@@ -301,6 +300,43 @@ export class ListenbrainzApiClient extends AbstractApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
static listenPayloadToPlay(payload: ListenPayload, nowPlaying: boolean = false): PlayObject {
|
||||
const {
|
||||
listened_at = dayjs().unix(),
|
||||
track_metadata: {
|
||||
artist_name,
|
||||
track_name,
|
||||
additional_info: {
|
||||
duration,
|
||||
track_mbid,
|
||||
artist_mbids,
|
||||
release_mbid,
|
||||
release_group_mbid
|
||||
} = {}
|
||||
} = {},
|
||||
} = payload;
|
||||
|
||||
return {
|
||||
data: {
|
||||
playDate: typeof listened_at === 'number' ? dayjs.unix(listened_at) : dayjs(listened_at),
|
||||
track: track_name,
|
||||
artists: [artist_name],
|
||||
duration,
|
||||
meta: {
|
||||
brainz: {
|
||||
artist: artist_mbids !== undefined ? artist_mbids : undefined,
|
||||
album: release_mbid,
|
||||
albumArtist: release_group_mbid,
|
||||
track: track_mbid
|
||||
}
|
||||
}
|
||||
},
|
||||
meta: {
|
||||
nowPlaying,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static listenResponseToPlay(listen: ListenResponse): PlayObject {
|
||||
const {
|
||||
listened_at,
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { childLogger } from "@foxxmd/logging";
|
||||
import { URLData } from "../../../../core/Atomic.js";
|
||||
import { joinedUrl, normalizeWSAddress } from "../../../utils/NetworkUtils.js";
|
||||
import { AbstractApiOptions } from "../../infrastructure/Atomic.js";
|
||||
import { AzuracastData, AzuraStationResponse } from "../../infrastructure/config/source/azuracast.js";
|
||||
import AbstractApiClient from "../AbstractApiClient.js";
|
||||
import { WS, CloseEvent, ErrorEvent, RetryEvent } from 'iso-websocket'
|
||||
|
||||
|
||||
export class AzuracastApiClient extends AbstractApiClient {
|
||||
|
||||
declare config: AzuracastData
|
||||
|
||||
urlData: URLData;
|
||||
|
||||
wsNowPlaying: AzuraStationResponse
|
||||
wsCurrenTime: number = 0;
|
||||
socket!: WS;
|
||||
|
||||
constructor(name: any, config: AzuracastData, options: AbstractApiOptions) {
|
||||
super('Azuracast API', name, config, options);
|
||||
|
||||
this.urlData = normalizeWSAddress(config.url);
|
||||
}
|
||||
|
||||
connectWS() {
|
||||
|
||||
const url = joinedUrl(this.urlData.url, '/api/live/nowplaying/websocket');
|
||||
const socket = new WebSocket(url);
|
||||
|
||||
socket.onopen = (e) => {
|
||||
socket.send(JSON.stringify({
|
||||
subs: {
|
||||
[`station:${this.config.station}`]: {"recover": true}
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
socket.onerror = (e) => {
|
||||
this.logger.error(e);
|
||||
}
|
||||
|
||||
// Handle a now-playing event from a station. Update your now-playing data accordingly.
|
||||
function handleSseData(ssePayload, useTime = true) {
|
||||
const jsonData = ssePayload.data;
|
||||
|
||||
if (useTime && 'current_time' in jsonData) {
|
||||
this.wsCurrenTime = jsonData.current_time;
|
||||
}
|
||||
|
||||
this.wsNowPlaying = jsonData.np as AzuraStationResponse;
|
||||
}
|
||||
|
||||
socket.onmessage = (e) => {
|
||||
|
||||
const jsonData = JSON.parse(e.data as string);
|
||||
|
||||
if ('connect' in jsonData) {
|
||||
const connectData = jsonData.connect;
|
||||
|
||||
if ('data' in connectData) {
|
||||
// Legacy SSE data
|
||||
connectData.data.forEach(
|
||||
(initialRow) => handleSseData(initialRow)
|
||||
);
|
||||
} else {
|
||||
// New Centrifugo time format
|
||||
if ('time' in connectData) {
|
||||
this.wsCurrenTime = Math.floor(connectData.time / 1000);
|
||||
}
|
||||
|
||||
// New Centrifugo cached NowPlaying initial push.
|
||||
for (const subName in connectData.subs) {
|
||||
const sub = connectData.subs[subName];
|
||||
if ('publications' in sub && sub.publications.length > 0) {
|
||||
sub.publications.forEach((initialRow) => handleSseData(initialRow, false));
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if ('pub' in jsonData) {
|
||||
handleSseData(jsonData.pub);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
+38
-30
@@ -4,6 +4,7 @@ import dayjs from 'dayjs';
|
||||
import duration from 'dayjs/plugin/duration.js';
|
||||
import isBetween from 'dayjs/plugin/isBetween.js';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime.js';
|
||||
import isToday from 'dayjs/plugin/isToday.js';
|
||||
import timezone from 'dayjs/plugin/timezone.js';
|
||||
import utc from 'dayjs/plugin/utc.js';
|
||||
import * as path from "path";
|
||||
@@ -15,14 +16,19 @@ import { getRoot, parseVersion } from "./ioc.js";
|
||||
import { initServer } from "./server/index.js";
|
||||
import { createHeartbeatClientsTask } from "./tasks/heartbeatClients.js";
|
||||
import { createHeartbeatSourcesTask } from "./tasks/heartbeatSources.js";
|
||||
import { parseBool, readJson, sleep } from "./utils.js";
|
||||
import { isDebugMode, parseBool, readJson, retry, sleep } from "./utils.js";
|
||||
import { createVegaGenerator } from './utils/SchemaUtils.js';
|
||||
import ScrobbleClients from './scrobblers/ScrobbleClients.js';
|
||||
import ScrobbleSources from './sources/ScrobbleSources.js';
|
||||
|
||||
dayjs.extend(utc)
|
||||
dayjs.extend(isBetween);
|
||||
dayjs.extend(relativeTime);
|
||||
dayjs.extend(duration);
|
||||
dayjs.extend(timezone);
|
||||
dayjs.extend(isToday);
|
||||
|
||||
// eslint-disable-next-line prefer-arrow-functions/prefer-arrow-functions
|
||||
(async function () {
|
||||
|
||||
const scheduler = new ToadScheduler()
|
||||
@@ -75,14 +81,20 @@ const configDir = process.env.CONFIG_DIR || path.resolve(projectDir, `./config`)
|
||||
process.env.DEBUG_MODE = b.toString();
|
||||
}
|
||||
|
||||
initLogger.info(`Debug Mode: ${isDebugMode() ? 'YES' : 'NO'}`);
|
||||
|
||||
await parseVersion();
|
||||
|
||||
const [aLogger, appLoggerStream] = await appLogger(logging)
|
||||
logger = childLogger(aLogger, 'App');
|
||||
|
||||
const root = getRoot({...config, logger});
|
||||
const root = getRoot({...config, logger, loggingConfig: logging, loggerStream: appLoggerStream});
|
||||
initLogger.info(`Version: ${root.get('version')}`);
|
||||
|
||||
initLogger.info('Generating schema definitions...');
|
||||
createVegaGenerator()
|
||||
initLogger.info('Schema definitions generated');
|
||||
|
||||
initServer(logger, appLoggerStream, output);
|
||||
|
||||
if(process.env.IS_LOCAL === 'true') {
|
||||
@@ -100,18 +112,12 @@ const configDir = process.env.CONFIG_DIR || path.resolve(projectDir, `./config`)
|
||||
/*
|
||||
* setup clients
|
||||
* */
|
||||
const scrobbleClients = root.get('clients');
|
||||
const scrobbleClients = root.get('clients') as ScrobbleClients;
|
||||
await scrobbleClients.buildClientsFromConfig(notifiers);
|
||||
if (scrobbleClients.clients.length === 0) {
|
||||
logger.warn('No scrobble clients were configured!')
|
||||
} else {
|
||||
logger.info('Starting scrobble clients...');
|
||||
}
|
||||
for(const client of scrobbleClients.clients) {
|
||||
await client.initScrobbleMonitoring();
|
||||
}
|
||||
|
||||
const scrobbleSources = root.get('sources');
|
||||
/*
|
||||
* setup sources
|
||||
* */
|
||||
const scrobbleSources = root.get('sources') as ScrobbleSources;
|
||||
await scrobbleSources.buildSourcesFromConfig([]);
|
||||
|
||||
// check ambiguous client/source types like this for now
|
||||
@@ -124,27 +130,29 @@ const configDir = process.env.CONFIG_DIR || path.resolve(projectDir, `./config`)
|
||||
logger.warn(`Last.FM source and clients have same names [${nameColl.map(x => x.name).join(',')}] -- this may cause issues`);
|
||||
}
|
||||
|
||||
let anyNotReady = false;
|
||||
for (const source of scrobbleSources.sources.filter(x => x.canPoll === true)) {
|
||||
await sleep(1500); // stagger polling by 1.5 seconds so that log messages for each source don't get mixed up
|
||||
if(source.isReady()) {
|
||||
source.poll();
|
||||
} else {
|
||||
anyNotReady = true;
|
||||
}
|
||||
}
|
||||
if (anyNotReady) {
|
||||
logger.info(`Some sources are not ready, open the dashboard to continue`);
|
||||
const clientTask = createHeartbeatClientsTask(scrobbleClients, logger);
|
||||
clientTask.execute();
|
||||
try {
|
||||
await retry(() => {
|
||||
if(clientTask.isExecuting) {
|
||||
throw new Error('Waiting')
|
||||
}
|
||||
return true;
|
||||
},{retries: scrobbleClients.clients.length + 1, retryIntervalMs: 2000});
|
||||
} catch (e) {
|
||||
logger.warn('Waited too long for clients to start! Moving ahead with sources init...');
|
||||
}
|
||||
scheduler.addSimpleIntervalJob(new SimpleIntervalJob({
|
||||
minutes: 20,
|
||||
runImmediately: false
|
||||
}, clientTask, {id: 'clients_heart'}));
|
||||
|
||||
const sourceTask = createHeartbeatSourcesTask(scrobbleSources, logger);
|
||||
scheduler.addSimpleIntervalJob(new SimpleIntervalJob({
|
||||
minutes: 20,
|
||||
runImmediately: false
|
||||
}, createHeartbeatSourcesTask(scrobbleSources, logger)));
|
||||
scheduler.addSimpleIntervalJob(new SimpleIntervalJob({
|
||||
minutes: 20,
|
||||
runImmediately: false
|
||||
}, createHeartbeatClientsTask(scrobbleClients, logger)));
|
||||
runImmediately: true
|
||||
}, sourceTask, {id: 'sources_heart'}));
|
||||
|
||||
logger.info('Scheduler started.');
|
||||
|
||||
} catch (e) {
|
||||
|
||||
+21
-5
@@ -1,5 +1,5 @@
|
||||
import { getVersion } from "@foxxmd/get-version";
|
||||
import { Logger } from "@foxxmd/logging";
|
||||
import { Logger, LogOptions } from "@foxxmd/logging";
|
||||
import { EventEmitter } from "events";
|
||||
import { createContainer } from "iti";
|
||||
import path from "path";
|
||||
@@ -8,7 +8,9 @@ import { WildcardEmitter } from "./common/WildcardEmitter.js";
|
||||
import { Notifiers } from "./notifier/Notifiers.js";
|
||||
import ScrobbleClients from "./scrobblers/ScrobbleClients.js";
|
||||
import ScrobbleSources from "./sources/ScrobbleSources.js";
|
||||
import { generateBaseURL } from "./utils.js";
|
||||
|
||||
import { generateBaseURL } from "./utils/NetworkUtils.js";
|
||||
import { PassThrough } from "stream";
|
||||
|
||||
let version: string = 'unknown';
|
||||
|
||||
@@ -23,13 +25,17 @@ export interface RootOptions {
|
||||
port?: string | number
|
||||
logger: Logger
|
||||
disableWeb?: boolean
|
||||
loggerStream?: PassThrough
|
||||
loggingConfig?: LogOptions
|
||||
}
|
||||
|
||||
const createRoot = (options?: RootOptions) => {
|
||||
const {
|
||||
port = 9078,
|
||||
baseUrl = process.env.BASE_URL,
|
||||
disableWeb: dw
|
||||
disableWeb: dw,
|
||||
loggerStream,
|
||||
loggingConfig
|
||||
} = options || {};
|
||||
const configDir = process.env.CONFIG_DIR || path.resolve(projectDir, `./config`);
|
||||
let disableWeb = dw;
|
||||
@@ -37,15 +43,25 @@ const createRoot = (options?: RootOptions) => {
|
||||
disableWeb = process.env.DISABLE_WEB === 'true';
|
||||
}
|
||||
|
||||
const cEmitter = new WildcardEmitter();
|
||||
// do nothing, just catch
|
||||
cEmitter.on('error', (e) => null);
|
||||
const sEmitter = new WildcardEmitter();
|
||||
sEmitter.on('error', (e) => {
|
||||
const f = e;
|
||||
});
|
||||
|
||||
return createContainer().add({
|
||||
version,
|
||||
configDir: configDir,
|
||||
isProd: process.env.NODE_ENV !== undefined && (process.env.NODE_ENV === 'production' || process.env.NODE_ENV === 'prod'),
|
||||
port: process.env.PORT ?? port,
|
||||
disableWeb,
|
||||
clientEmitter: () => new WildcardEmitter(),
|
||||
sourceEmitter: () => new WildcardEmitter(),
|
||||
clientEmitter: () => cEmitter,
|
||||
sourceEmitter: () => sEmitter,
|
||||
notifierEmitter: () => new EventEmitter(),
|
||||
loggerStream,
|
||||
loggingConfig,
|
||||
}).add((items) => {
|
||||
const localUrl = generateBaseURL(baseUrl, items.port)
|
||||
return {
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
WebhookPayload
|
||||
} from "../common/infrastructure/config/health/webhooks.js";
|
||||
import { AbstractWebhookNotifier } from "./AbstractWebhookNotifier.js";
|
||||
import { URLData } from "../../core/Atomic.js";
|
||||
import { isPortReachable, joinedUrl, normalizeWebAddress } from "../utils/NetworkUtils.js";
|
||||
|
||||
const shortKey = truncateStringToLength(10);
|
||||
|
||||
@@ -20,6 +22,8 @@ export class AppriseWebhookNotifier extends AbstractWebhookNotifier {
|
||||
|
||||
priorities: PrioritiesConfig;
|
||||
|
||||
protected endpoint: URLData;
|
||||
|
||||
urls: string[];
|
||||
keys: string[];
|
||||
|
||||
@@ -42,18 +46,24 @@ export class AppriseWebhookNotifier extends AbstractWebhookNotifier {
|
||||
}
|
||||
|
||||
initialize = async () => {
|
||||
// check url is correct
|
||||
// check url is correct as a courtesy
|
||||
this.endpoint = normalizeWebAddress(this.config.host);
|
||||
this.logger.verbose(`Config URL: '${this.config.host}' => Normalized: '${this.endpoint.normal}'`)
|
||||
|
||||
this.initialized = true; // always set as ready to go. Server issues may be transient.
|
||||
|
||||
try {
|
||||
await request.get(this.config.host);
|
||||
await isPortReachable(this.endpoint.port, { host: this.endpoint.url.hostname });
|
||||
} catch (e) {
|
||||
this.logger.error(new Error('Failed to contact Apprise server', {cause: e}));
|
||||
this.logger.warn(new Error('Unable to detect if server is reachable', { cause: e }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.keys.length > 0) {
|
||||
let anyOk = false;
|
||||
for (const key of this.keys) {
|
||||
try {
|
||||
const resp = await request.get(`${this.config.host}/json/urls/${key}`);
|
||||
const resp = await request.get(joinedUrl(this.endpoint.url, `/json/urls/${key}`).toString());
|
||||
if (resp.statusCode === 204) {
|
||||
this.logger.warn(`Details for Config ${shortKey(key)} returned no content. Double check the key is set correctly or that the apprise Config is not empty.`);
|
||||
} else {
|
||||
@@ -64,9 +74,7 @@ export class AppriseWebhookNotifier extends AbstractWebhookNotifier {
|
||||
}
|
||||
}
|
||||
if (!anyOk) {
|
||||
this.logger.error('No Apprise Configs were valid!');
|
||||
this.initialized = false;
|
||||
return;
|
||||
this.logger.warn('No Apprise Configs were valid!');
|
||||
}
|
||||
}
|
||||
this.initialized = true;
|
||||
@@ -83,7 +91,7 @@ export class AppriseWebhookNotifier extends AbstractWebhookNotifier {
|
||||
if (this.keys.length > 0) {
|
||||
for (const key of this.keys) {
|
||||
try {
|
||||
const resp = await this.callApi(request.post(`${this.config.host}/notify/${key}`)
|
||||
const resp = await this.callApi(request.post(joinedUrl(this.endpoint.url, `/notify/${key}`).toString())
|
||||
.type('json')
|
||||
.send(body));
|
||||
anyOk = true;
|
||||
@@ -99,7 +107,7 @@ export class AppriseWebhookNotifier extends AbstractWebhookNotifier {
|
||||
body.urls = this.urls.join(',')
|
||||
}
|
||||
try {
|
||||
const resp = await this.callApi(request.post(`${this.config.host}/notify`)
|
||||
const resp = await this.callApi(request.post(joinedUrl(this.endpoint.url, '/notify').toString())
|
||||
.type('json')
|
||||
.send(body));
|
||||
anyOk = true;
|
||||
|
||||
@@ -4,6 +4,8 @@ import { gotify } from 'gotify';
|
||||
import request from 'superagent';
|
||||
import { GotifyConfig, PrioritiesConfig, WebhookPayload } from "../common/infrastructure/config/health/webhooks.js";
|
||||
import { AbstractWebhookNotifier } from "./AbstractWebhookNotifier.js";
|
||||
import { isPortReachable, normalizeWebAddress } from "../utils/NetworkUtils.js";
|
||||
import { URLData } from "../../core/Atomic.js";
|
||||
|
||||
export class GotifyWebhookNotifier extends AbstractWebhookNotifier {
|
||||
|
||||
@@ -11,6 +13,8 @@ export class GotifyWebhookNotifier extends AbstractWebhookNotifier {
|
||||
|
||||
priorities: PrioritiesConfig;
|
||||
|
||||
protected endpoint: URLData;
|
||||
|
||||
constructor(defaultName: string, config: GotifyConfig, logger: Logger) {
|
||||
super('Gotify', defaultName, config, logger);
|
||||
this.requiresAuth = true;
|
||||
@@ -29,14 +33,25 @@ export class GotifyWebhookNotifier extends AbstractWebhookNotifier {
|
||||
}
|
||||
|
||||
initialize = async () => {
|
||||
// check url is correct
|
||||
// check url is correct as a courtesy
|
||||
this.endpoint = normalizeWebAddress(this.config.url);
|
||||
this.logger.verbose(`Config URL: '${this.config.url}' => Normalized: '${this.endpoint.normal}'`)
|
||||
|
||||
this.initialized = true; // always set as ready to go. Server issues may be transient.
|
||||
|
||||
try {
|
||||
await isPortReachable(this.endpoint.port, { host: this.endpoint.url.hostname });
|
||||
} catch (e) {
|
||||
this.logger.warn(new Error('Unable to detect if server is reachable', { cause: e }));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = this.config.url;
|
||||
const resp = await request.get(`${url}/version`);
|
||||
this.logger.verbose(`Initialized. Found Server version ${resp.body.version}`);
|
||||
this.initialized = true;
|
||||
this.logger.verbose(`Found Server version ${resp.body.version}`);
|
||||
} catch (e) {
|
||||
this.logger.error(`Failed to contact server | Error: ${e.message}`);
|
||||
this.logger.warn(new Error('Server was reachable but could not determine version', { cause: e }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,19 +63,19 @@ export class GotifyWebhookNotifier extends AbstractWebhookNotifier {
|
||||
doNotify = async (payload: WebhookPayload) => {
|
||||
try {
|
||||
await gotify({
|
||||
server: this.config.url,
|
||||
server: this.endpoint.normal,
|
||||
app: this.config.token,
|
||||
message: payload.message,
|
||||
title: payload.title,
|
||||
priority: this.priorities[payload.priority]
|
||||
});
|
||||
this.logger.verbose(`Pushed notification.`);
|
||||
} catch (e: any) {
|
||||
} catch (e) {
|
||||
if(e instanceof HTTPError && e.response.statusCode === 401) {
|
||||
this.logger.warn(`Unable to push notification. Error returned with 401 which means the TOKEN provided is probably incorrect. Disabling Notifier | Error => ${e.response.body}`);
|
||||
this.logger.warn(new Error(`Unable to push notification. Error returned with 401 which means the TOKEN provided is probably incorrect. Disabling Notifier \n Response Error => ${e.response.body}`, {cause: e}));
|
||||
this.authed = false;
|
||||
} else {
|
||||
this.logger.warn(`Failed to push notification | Error => ${e.message}`);
|
||||
this.logger.warn(new Error('Failed to push notification', {cause: e}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import { Config, publish } from 'ntfy';
|
||||
import request from "superagent";
|
||||
import { NtfyConfig, PrioritiesConfig, WebhookPayload } from "../common/infrastructure/config/health/webhooks.js";
|
||||
import { AbstractWebhookNotifier } from "./AbstractWebhookNotifier.js";
|
||||
import { URLData } from "../../core/Atomic.js";
|
||||
import { isPortReachable, normalizeWebAddress } from "../utils/NetworkUtils.js";
|
||||
|
||||
export class NtfyWebhookNotifier extends AbstractWebhookNotifier {
|
||||
|
||||
@@ -10,6 +12,8 @@ export class NtfyWebhookNotifier extends AbstractWebhookNotifier {
|
||||
|
||||
priorities: PrioritiesConfig;
|
||||
|
||||
protected endpoint: URLData;
|
||||
|
||||
constructor(defaultName: string, config: NtfyConfig, logger: Logger) {
|
||||
super('Ntfy', defaultName, config, logger);
|
||||
const {
|
||||
@@ -27,24 +31,35 @@ export class NtfyWebhookNotifier extends AbstractWebhookNotifier {
|
||||
}
|
||||
|
||||
initialize = async () => {
|
||||
// check url is correct
|
||||
// check url is correct as a courtesy
|
||||
this.endpoint = normalizeWebAddress(this.config.url);
|
||||
this.logger.verbose(`Config URL: '${this.config.url}' => Normalized: '${this.endpoint.normal}'`)
|
||||
|
||||
this.initialized = true; // always set as ready to go. Server issues may be transient.
|
||||
|
||||
try {
|
||||
await isPortReachable(this.endpoint.port, { host: this.endpoint.url.hostname });
|
||||
} catch (e) {
|
||||
this.logger.warn(new Error('Unable to detect if server is reachable', { cause: e }));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = this.config.url;
|
||||
const resp = await request.get(`${url}/v1/health`);
|
||||
if(resp.body !== undefined && typeof resp.body === 'object') {
|
||||
const {health} = resp.body;
|
||||
if(health === false) {
|
||||
this.logger.error('Found Ntfy server but it responded that it was not ready.')
|
||||
this.logger.warn('Found server but it responded that it was not ready.')
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
this.logger.error(`Found Ntfy server but expected a response with 'health' in payload. Found => ${resp.body}`);
|
||||
this.logger.warn(`Found server but expected a response with 'health' in payload. Found => ${resp.body}`);
|
||||
return;
|
||||
}
|
||||
this.logger.info('Initialized. Found Ntfy server');
|
||||
this.initialized = true;
|
||||
this.logger.info('Found Ntfy server');
|
||||
} catch (e) {
|
||||
this.logger.error(`Failed to contact Ntfy server | Error: ${e.message}`);
|
||||
this.logger.error(new Error('Failed to contact server', {cause: e}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,7 +69,7 @@ export class NtfyWebhookNotifier extends AbstractWebhookNotifier {
|
||||
message: payload.message,
|
||||
topic: this.config.topic,
|
||||
title: payload.title,
|
||||
server: this.config.url,
|
||||
server: this.endpoint.normal,
|
||||
priority: this.priorities[payload.priority],
|
||||
};
|
||||
if (this.config.username !== undefined) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user